Refactor zip creation to reduce peak disk usage - #174
Open
arobson-ods wants to merge 6 commits into
Open
arobson-ods wants to merge 6 commits into
arobson-ods wants to merge 6 commits into
Conversation
The body of the per-format loop is moved into create_and_upload_zip, leaving zipper_run to set up the working directory and iterate over the two ZIP formats. No change in behaviour: the inner `break` becomes a `return`, and the `else` branch of the validation check becomes a fall-through.
The files a ZIP is built from were kept until after the ZIP had been extracted again to verify it, so a run held the master XML, a staged copy, the ZIP and the extracted copy at once: around 39 Gb of the 50 Gb the container has. They are now deleted as soon as the ZIP exists, which takes the peak to around 26 Gb. Failures no longer leave copies behind either. Each format's working directory is removed whether or not the run through it completes, and verification now treats any error as a failed verification rather than only a corrupt ZIP found during extraction, removing the part-finished extraction in a finally. Previously anything else ended the run without clearing up, stranding up to 26 Gb which the next run then had to work around. Also stops the re-try re-downloading the XML after the final attempt has already failed, frees the failed ZIP before the re-download rather than after, and reports an error when both attempts fail: previously nothing was logged and the run recorded its duration as normal. Resolves #173
Logs space used and free on the filesystem holding the ZIP working directory at each stage of a run, and exports the same figures as the disk_free_bytes and disk_used_bytes Prometheus gauges. The container's disk is fixed at 50 Gb and a run needs a large fraction of it.
The directory was created only for datasets with no download, on the assumption that copytree would have brought it across for the rest along with their XML. The line creating the placeholder file sat outside that check and ran for every dataset, so a dataset recorded as downloaded whose XML was missing from the working directory raised FileNotFoundError, with no directory to write into. The two guards asked the same question by different means: the database flag for the directory, the filesystem for the file. Creating the directory unconditionally removes the disagreement.
clean_working_dir deleted every XML file but left datasets_in_working_dir untouched, so the new_or_updated_datasets comparison a few lines later found every dataset present with an unchanged hash and downloaded nothing. The re-try which the forced clean exists to support therefore built its ZIP from an empty directory, and an empty ZIP is a valid ZIP, so it passed verification and would have been published over the good one. The record of what the working directory holds is now emptied alongside the files themselves. On the first run of a session it is already empty, so the clear sits with the deletion rather than behind its own check.
Contributor
|
Thanks Andrew. Could you add an issue to cover that Follow Up point you mentioned? |
simon-20
requested changes
Oct 1, 2026
| context.logger.info( | ||
| "Disk usage for the filesystem holding the ZIP working dir ({}): " | ||
| "{:.1f} Gb used, {:.1f} Gb free, {:.1f} Gb total.".format( | ||
| stage, usage.used / 1024**3, usage.free / 1024**3, usage.total / 1024**3 |
Contributor
There was a problem hiding this comment.
Could you swap usage.free to be usage.available - I think that is more accurate, because some of the free value is reserved for root.
Contributor
Author
There was a problem hiding this comment.
Thanks @simon-20. Agree 'available' more accurate than 'free'. Do you mean I should do total, used, available = shutil.disk_usage(context["ZIP_WORKINGDIR"])? Also, should I apply same logic to the prometheus metric i.e. disk_free_bytes becomes disk_available_bytes?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses #173.
What happened
2026-09-16 21:43:49
This was the only
FileNotFoundErrorin the 30 day log. (Line numbers are from the deployed build, not this branch.)create_empty_files_for_non_downloadable_datasetsonly creates the publisher directory for datasets with no download, but the line creating the placeholder file sits outside that check and runs for every dataset. So a dataset flagged as downloaded gets no folder of its own; if its XML is also missing,open()fires on a path whose directory does not exist.prosperglobalukhas one dataset, so nothing else had created that folder.Raised from
prepare(), after thecopytree. There was notry/finallyinzipper_run, so it escaped, skipped theclean_working_dir()at the end of the loop, and left the whole 12.7 Gb-2directory on disk.22:52:56 — 69 minutes later, the first
OSError: [Errno 28] No space left on device. Then 135 out-of-disk failures over five and a half days, ending when the container was restarted.What Changed
valid_zip_created()catchesOSErroras well asBadZipFileand removes the extraction in afinally, so no error leaves a part-extracted copy. It also opens the archive inside thetry, so a truncated or missing ZIP is a failed verification rather than a raise.clean_working_dir()wiped every XML file but leftdatasets_in_working_diruntouched, so every dataset still compared as present and unchanged and nothing was re-downloaded. Separate bug, and no sign it ever fired. But it cannot be deferred. The forced clean fires whenvalid_zip_created()returnsFalse, and this PR widens that from "BadZipFileinsideextractall" to "anyBadZipFileorOSError". In 30 days of logs the old condition never fired; the new one would have fired 135 times, all during the September incident. So the path goes from never taken to taken on every disk-full failure and without this fix, each one publishes a near-empty ZIP over the good one.disk_free_bytesanddisk_used_bytes.Testing
tests/unit/test_zippers.pyis new and needs no Docker; there were previously no unit tests for either zipper module.black,isort,flake8,mypyclean;pyrightclean on changed files.extractallpatched to raiseENOSPC: the failure is reported, the re-try re-downloads and rebuilds with real data, and nothing is left behind.Notes for reviewer
extractallrather thanzf.testzip(). Considered usingtestzipas it verifies with no disk writes, but with the staged copy freed first the peak is the same either way. It sits where the archive is written, not at verification. Making this switch might be worthwhile if process would benefit from a performance improvement.Follow-up, not in this PR
Why the XML was missing at all. No blob fetch failed on the 16th, so the file was most likely deleted locally and never re-fetched.
clean_working_dirdeletes keyed on<reporting_org_short_name>/<short_name>, butnew_or_updated_datasetsonly re-downloads on a changedshort_nameor hash.reporting_org_short_nameis never compared. A dataset whose publisher path changes therefore loses its XML with nothing to bring it back. Unconfirmed (reporting_org_sync.pyhas no logging) but it fits: the crash path wasprosperglobaluk/mce-activity.xml, and the dataset is nowprosperglobaluk-activity.