fix(storage): open ZipStore archive on first use from every method and reopen it without truncating - #4450
Open
d-v-b wants to merge 31 commits into
Open
fix(storage): open ZipStore archive on first use from every method and reopen it without truncating#4450d-v-b wants to merge 31 commits into
d-v-b wants to merge 31 commits into
Conversation
…ifecycle # Conflicts: # src/zarr/storage/_zip.py # tests/test_store/test_zip.py
…n append mode Create the lock in `__init__` and route every archive access through `_zipfile()`, which opens the archive on first use. This covers `get`, `get_partial_values`, `set_if_not_exists` and `clear`, which still raised `AttributeError` on a never-opened store. After the first open, switch mode "w" or "x" to "a" so that `move()` and unpickling reopen the archive without truncating it or refusing to open it. Builds on the fix proposed by Othman El Hammouchi in zarr-developers#3593. Refs zarr-developers#3588 Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Othman El Hammouchi <othman.el.hammouchi@protonmail.com>
Assisted-by: ClaudeCode:claude-opus-5-5
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4450 +/- ##
==========================================
+ Coverage 94.43% 94.45% +0.01%
==========================================
Files 93 93
Lines 13219 13244 +25
==========================================
+ Hits 12483 12509 +26
+ Misses 736 735 -1
🚀 New features to boost your workflow:
|
Replace the close-twice case, which never reopened the archive and passed on main, with a read after close(), which erased a "w" archive on main. Assert return values and final archive contents in the first-use test instead of only checking that nothing raises. Cover reopening a file-object-backed store after close(). List the reopen after close(), the file-object case, and the first-use thread race in the changelog fragment. Assisted-by: ClaudeCode:claude-opus-5-5
close() marked the store closed before taking the lock, so a thread that used the store in that gap reopened the archive in append mode before its central directory was written. Its handle then wrote a directory that listed only its own entries, and the earlier entries were lost. Assisted-by: ClaudeCode:claude-opus-5-5
move() closed the store, moved the file, and reopened it without the lock, so a thread that used the store in between reopened the old path and the move then failed with "store is already open". Hold the lock for the whole move. _open() also opens under the lock, and unpickling opens through _zipfile(). clear() keeps opening a never-opened store before deleting the file, so a store in mode "x" refuses an existing file instead of deleting it. A test pins that behaviour. Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
ZipStore documents and accepts mode "x" at runtime, but the type of its mode parameter left it out, so type-checked callers needed an ignore. Assisted-by: ClaudeCode:claude-opus-5-5
Reopening after close() uses append mode, which reads the archive back. A write-only file object cannot be read, so zipfile started a new archive after the old one and the earlier entries were silently lost. Raise io.UnsupportedOperation instead. The first open is unchanged. The thread-race tests now join the writer with a timeout, so a deadlock fails the test instead of hanging the run. Assisted-by: ClaudeCode:claude-opus-5-5
…e under the lock close() now marks the store closed even when closing the archive raises, so the next use reopens the archive instead of failing on a closed handle. ZipStore overrides _ensure_open, whose base version checked _is_open outside the lock, so it could race a concurrent first use and fail with "store is already open". Reuse after close() now also requires a seekable file object, since append mode seeks. The docstring now says that "w" and "x" apply to the first open only. A new test holds the lock while another thread makes its first use, which also catches a _zipfile() that opens without the lock. Assisted-by: ClaudeCode:claude-opus-5-5
If closing the archive raises, its central directory may be missing, and reopening it in append mode makes zipfile start a new archive and silently drop the earlier entries. The store now records the failure and raises RuntimeError on its next use. list() now iterates a copy of the names without holding the lock, so other threads are not blocked while a caller iterates. move() opens a never-opened store before closing it, so mode "x" refuses an existing file instead of moving it. The mode docstring no longer says every reopen appends. The failed-close test replaces one that patched ZipFile.close with a closure over the same ZipFile. That cycle raised from ZipFile.__del__ during a later test's garbage collection, which the warnings-as-errors config reported as an error in an unrelated test. Assisted-by: ClaudeCode:claude-opus-5-5
clear() closed the archive, removed the file, and opened a new one. If a later step raised, the store stayed marked open on a closed handle and every use failed. It now closes the store through close() and reopens with "w", which truncates, so a failure leaves the store closed and the next use tries the truncating open again. The file is no longer removed first. Assisted-by: ClaudeCode:claude-opus-5-5
clear() replaces the archive, so it cannot drop the entries that the failed-close guard protects; it now resets the guard instead of raising. clear() removes the file again before creating the new archive, as main did, so links, permissions, and other open readers behave as before. __getstate__ copies the state under the lock, so a pickle cannot catch _sync_open between the "w" open and the switch to "a". A test covers unpickling state from before _was_opened and _close_failed existed. Assisted-by: ClaudeCode:claude-opus-5-5
If the reuse-after-close tests fail, the store is still open when its file object goes away, and ZipFile.__del__ then raises during a later test's garbage collection. The warnings-as-errors config reported that as an error in an unrelated test. Close the store in a finally block. Assisted-by: ClaudeCode:claude-opus-5-5
… unfinished archive Unpickling opened the archive right away. A copy of a store that was still writing, like those dask makes while building a graph, found no central directory and opened in append mode, which zipfile marks as modified; when the copy was garbage-collected after the original closed, it wrote an empty central directory at the end of the file and the archive read as empty. Unpickled stores now open on first use, and a store that has already written refuses to reopen a file with no central directory, raising zipfile.BadZipFile. clear() lifts the failed-close guard only after the old file is removed, and the docstring and error message no longer suggest clear() for stores it cannot clear. A test covers clear() on a mode "r" store with read_only=False. Assisted-by: ClaudeCode:claude-opus-5-5
…agment Assisted-by: ClaudeCode:claude-opus-5-5
clear() opened the store before removing the file, so a store whose file had become a non-zip hit the reopen guard, and one whose file was gone after a failed close() raised FileNotFoundError forever. It now opens first only for a store that was never opened, which is what keeps mode "x" from deleting an unclaimed file, and removes the file with missing_ok. The guard's message no longer blames another writer alone, and the changelog says which concurrent writers it detects. Tests cover clear() on a damaged or missing file, reopening after the file was deleted, and a first open with "a" on an empty file. Assisted-by: ClaudeCode:claude-opus-5-5
…port OS errors on reopen clear() opened a never-opened store in every mode, but only "x" needs it, and for mode "r" with read_only=False over a missing or damaged file that open failed and left the store unusable. The reopen guard used zipfile.is_zipfile, which swallows OSError, so a directory or an unreadable file at the path was reported as an unfinished archive; it now opens the file itself and lets such errors surface. The close() race test now starts the concurrent write while the central directory is being written, so removing the lock from close() fails it. New cases cover set_if_not_exists on an existing key, an old pickle in mode "a" over an empty file, KeyboardInterrupt during close(), clear() on a mode "r" store whose file is missing, and a directory at the archive path. Assisted-by: ClaudeCode:claude-opus-5-5
Mode "x" is switched to "a" by the first open, so checking _was_opened alongside it changed nothing. Assisted-by: ClaudeCode:claude-opus-5-5
d-v-b
marked this pull request as ready for review
September 29, 2026 21:08
This branch has not been deployed
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.
This is the result of a very tedious dive into problems at the boundary between our store API and the realities of zip archives. There are many ways in which an object storage API can fail to uphold the expectations of an archive that is unreadable until a finalization process has occurred, and this PR discovered and addressed many of them.
tl;dr is that these changes make our zip storage safer, by adding locks and checks in various places to prevent zip archive corruption, but the work done here represents a warning sign that zip storage is not in fact like S3, and we probably need to represent that formally with API design. I had claude write up a post-mortem / design recommendation here, in case anyone wants to read it. The good news is that many of the proposed fixes were already suggested in the zarr-python-planning storage API proposal
claude wrote the fix and the long-winded robot PR description that follows.
🤖 AI text below 🤖
ZipStorenow opens its archive on first use from every method, so callingget,get_partial_values,set_if_not_exists, orclearon a store that was never opened no longer raisesAttributeError. A store created withmode="w"ormode="x"now keeps its entries when it is used again afterclose(), moved withmove(), or unpickled, instead of truncating the archive or refusing to open it. Previously, even a read on a closedmode="w"store erased the archive, and such a store backed by a file object dropped its earlier entries when written to again afterclose(). A store backed by a readable, seekable file object now keeps them, and one backed by any other file object raisesio.UnsupportedOperationinstead, because it cannot read the archive back. Threads that use a store for the first time at the same moment no longer open the archive twice and lose writes, and neither does a thread that uses a store while another thread closes or moves it. If closing the archive fails, for example because the disk is full, every later use of the store that would reopen the archive now raisesRuntimeError, instead of reopening an archive that may lack its central directory and silently starting a new one; for a writable store backed by a path,clear()replaces the archive and makes the store usable again. Aclear()that fails partway now leaves the store closed instead of open on a closed archive. An unpickled store now opens its archive on first use, so a copy that is never used, such as those dask makes while building a graph, no longer touches the file; before, such a copy reopened the archive and corrupted it, so writing a dask array to aZipStoreproduced an unreadable file. A store that reopens its archive and finds it is not a zip, as happens while another copy is still in its first writing session, raiseszipfile.BadZipFileinstead of starting a new archive; other concurrent writers are not detected. On a store that was never opened,move()now applies the first-open mode at the original path, so mode"x"refuses an existing file instead of moving it. The type of themodeparameter now includes"x", which the store already accepted at runtime.This replaces #3593 and keeps its five commits by Othman El Hammouchi. The first fix commit also credits them as co-author. Refs #3588. Refs #3516: its reproducer, which writes a dask array to a
ZipStore, reads back correctly with this change and raisesBadZipFileon main.Root cause
Two design flaws caused these bugs:
_lockand_zfwere created in_sync_open(), so each method had to check_is_openbefore touching them. That check was copied into each method, and it ran outside any lock. On main,get,get_partial_valuesandset_if_not_existstake_lockbefore any check runs, andcleartouches_zfdirectly, so all four raiseAttributeErroron a store that was never opened. fix: ensureZipStoreis open before acquiring lock #3593 moved the checks around but dropped the one inlist_dir. Two threads could both see a closed store and both open it; with"w", the second open truncated the first one's writes._zmodewas used both for the first open and for every reopen, and unpickling reopened the archive right away. With"w", each reopen truncated the archive. On main, a"w"store has no keys afterclose()followed by any use, aftermove(), or after a pickle round trip.Change
__init__and again in__setstate__._zipfile()accessor opens the archive under the lock on first use and returns it. Every read and write gets the archive through it._open()opens under the lock, and_ensure_open()is overridden to call_zipfile(), because the base version checked_is_openoutside the lock._sync_openswitches mode"w"or"x"to"a".close()holds the lock until the archive is closed and the store is marked closed. Before, it marked the store closed first, so a thread that used the store in that gap reopened an archive whose central directory was not written yet.RuntimeError. The archive may lack its central directory, and reopening it in append mode would makezipfilestart a new archive and silently drop the earlier entries.clear()resets this, because it replaces the archive.move()holds the lock from closing the store until it reopens it at the new path, and opens a never-opened store first, so mode"x"refuses an existing file instead of moving it.clear()opens a never-opened"x"store before deleting the file, for the same reason. It then closes the store, removes the file if it is still there, and creates the new archive with"w", so it also replaces a file that is damaged or gone, and a failure partway leaves the store closed rather than open on a closed archive. It lifts the failed-close guard only once the old file is removed.close()requires it to be readable and seekable, and raisesio.UnsupportedOperationotherwise. On a write-only file,zipfilestarted a new archive after the old one and the earlier entries were lost. The first open is unchanged."w"and corrupted it. A store that has already opened its archive for writing refuses to reopen a file that is not a zip archive and raiseszipfile.BadZipFile, because appending to it would start a new archive after the entries already written. It opens the file itself rather than relying onzipfile.is_zipfile, which swallowsOSError, so a directory or an unreadable file at the path surfaces as that error. This catches a copy used while the original is still in its first writing session, when the file has no central directory yet.list()iterates a copy of the names without holding the lock, so other threads are not blocked while a caller iterates.__getstate__copies the state under the lock.ZipStoreAccessModeLiteralincludes"x", and the docstring says"w"and"x"apply to the first open only.Tests
test_methods_open_store_on_first_useandtest_listing_opens_store_on_first_usecall each public method on a store that was constructed but never opened, and check return values; the first also checks the archive's entries. They replacetest_lock_presentfrom fix: ensureZipStoreis open before acquiring lock #3593, which used an already-open fixture and could not catch the missing check inget.test_reopen_keeps_entriescovers modeswandxwith a read afterclose(),move(), and a pickle round trip.test_close_blocks_concurrent_reopen,test_move_blocks_concurrent_reopen, andtest_first_use_waits_for_lockstart work on another thread while this thread is writing the central directory inclose(), is insidemove(), or holds the lock, and check that the other thread waits.test_list_does_not_hold_lock_while_iteratingcallslist_dir()from another thread partway throughlist().test_failed_close_blocks_reopenmakes writing the central directory fail, checks that the next write raises, and thatclear()recovers the store.test_failed_clear_leaves_store_closed,test_clear_exclusive_mode_keeps_existing_file, andtest_move_exclusive_mode_keeps_existing_filecoverclear()andmove().test_write_after_close_keeps_entries,test_write_only_reuse_after_close_raises, andtest_unseekable_reuse_after_close_raisescover file objects.test_unused_copy_of_open_writer_does_not_writeandtest_used_copy_of_open_writer_raisespickle a store that is still writing: an unused copy collected after the original closes leaves the archive intact, and a used copy raises.test_failed_clear_keeps_failed_close_guardandtest_clear_read_mode_writable_storecoverclear()after a failed close and on amode="r", read_only=Falsestore.test_clear_replaces_damaged_file,test_reopen_recreates_deleted_file, andtest_first_open_append_on_empty_filecover a file that was replaced, removed, or empty between uses, and check that the reopen guard applies only to archives the store wrote.test_reopen_reports_directory_at_pathchecks that a directory at the path is not reported as an unfinished archive.test_unpickle_state_from_older_releaseunpickles state without the attributes this PR adds._zip.py, with no errors leaking into other tests. With this change,tests/test_storeandtests/test_api.pypass.Known leftovers, not changed here, and each also present on main:
"a"mode, writing a key again adds a second entry with that name, as overwriting within one session already did."a+b") is readable and seekable, but its writes always land at the end of the file, so reopening it afterclose()can corrupt the archive."w"or"x"store that was never opened applies the first-open mode when it is first used, so each copy that is used truncates the file or refuses it.zipfile.BadZipFile; other concurrent writers are not detected and can lose writes.Bookkeeping
ZipStoreis open before acquiring lock #3593 head (8688953ea) and merges upstreammain(0ba2ca24a). The merge conflicts in_zip.pywere resolved by taking main's version, and the fix is in its own commit on top. The test-file conflict was resolved by keeping both sides.adbed1886came out of several review rounds.🤖 Generated with Claude Code