Data export: the archive is built in /tmp and then copied, and each kept file is read whole into memory on the way #1125
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#1125
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What exists today
src/postulo/core/slow.py:47-58(theexporterrand):tempfile.mkstemp(prefix="postulo-export-", suffix=".zip")in the system temporary directory,write_archive(owner, writing)into it, thenarchive.file.save(name, File(reading)), which forFileSystemStoragecopies the bytes chunk by chunk into the media root (django/core/files/storage/filesystem.py:68-110; it only renames when the content hastemporary_file_path, which a plainFiledoes not), then the temporary file is unlinked. Every export is written twice.docker/compose.hardened.yml:22-27mounts/tmpas tmpfs for every service, and the worker hasmem_limit: 1g(line 39). Tmpfs pages are charged to the container's memory cgroup, so under the hardened file the whole archive sits in the worker's memory allowance while it is built and copied. Captured pages are part of the export (core/export.py:1738-1744) and an account may keep up to 1 GB of them (POSTULO_CAPTURE_ACCOUNT_MAX_BYTES,config/settings/base.py:775-776), so a large account can push the worker towards its limit and the OOM killer, whichB6then reports a day later.core/export.py:1774-1777copies each media file witharchive.writestr(name, handle.read()), reading it whole. That is bounded by the largest single file (uploads are capped at 20 MB by the form, kept page renderings at 10 MB), so this part is a small cost, not the scout's main point. The docstring at lines 1751-1753 says holding the export in memory was chosen deliberately because an export is "measured in megabytes"; with captures counted that is no longer always true, and streaming here is two lines, not new machinery.manage.py export_data(core/management/commands/export_data.py:27) writes to a file the operator names and is not affected by the temporary directory.What would change
write_archive, stream each file:with default_storage.open(name, "rb") as src, archive.open(f"{MEDIA_PREFIX}{name}", "w") as dst: shutil.copyfileobj(src, dst, 1024 * 1024). Same archive, same members.build_an_export, create the temporary file on the data volume rather than in/tmp(for example a.tmpdirectory underMEDIA_ROOT's parent, or the directory exports are kept in), and hand it to storage as an object withtemporary_file_path()(Django'sTemporaryUploadedFilepattern) soFileSystemStoragemoves it instead of copying. A remote storage backend still copies, as it must.core/export.py:1751-1753.No library needed:
zipfile.ZipFile.open(..., "w")andshutil.copyfileobjare the standard library.Tests: the existing export tests keep passing byte for byte on the manifest and members; a test that the errand leaves nothing in the temporary directory.
What it saves or protects
medium: an export stops occupying the worker's memory under the hardened compose file (where /tmp is tmpfs counted against
mem_limit: 1g) and stops being written twice; per-file peak memory drops from the largest file (up to 20 MB for an upload, 10 MB for a kept page rendering) to a copy bufferAccessibility
none
Decisions
Found by the efficiency and reliability audit of 2026-10-30 (finding D5; index postulo/postulo#1108). Checked against
mainatdaee30b15(2026-10-30).