Skip to content

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
zarr-developers:mainfrom
d-v-b:fix/zipstore-open-lifecycle
Open

d-v-b wants to merge 31 commits into
zarr-developers:mainfrom
d-v-b:fix/zipstore-open-lifecycle

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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 🤖

ZipStore now opens its archive on first use from every method, so calling get, get_partial_values, set_if_not_exists, or clear on a store that was never opened no longer raises AttributeError. A store created with mode="w" or mode="x" now keeps its entries when it is used again after close(), moved with move(), or unpickled, instead of truncating the archive or refusing to open it. Previously, even a read on a closed mode="w" store erased the archive, and such a store backed by a file object dropped its earlier entries when written to again after close(). A store backed by a readable, seekable file object now keeps them, and one backed by any other file object raises io.UnsupportedOperation instead, 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 raises RuntimeError, 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. A clear() 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 a ZipStore produced 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, raises zipfile.BadZipFile instead 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 the mode parameter 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 raises BadZipFile on main.

Root cause

Two design flaws caused these bugs:

  1. _lock and _zf were created in _sync_open(), so each method had to check _is_open before touching them. That check was copied into each method, and it ran outside any lock. On main, get, get_partial_values and set_if_not_exists take _lock before any check runs, and clear touches _zf directly, so all four raise AttributeError on a store that was never opened. fix: ensure ZipStore is open before acquiring lock #3593 moved the checks around but dropped the one in list_dir. Two threads could both see a closed store and both open it; with "w", the second open truncated the first one's writes.
  2. _zmode was 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 after close() followed by any use, after move(), or after a pickle round trip.

Change

  • The lock is created in __init__ and again in __setstate__.
  • A new _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_open outside the lock.
  • After the first open, _sync_open switches 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.
  • If closing the archive raises, the store records it and later opens raise RuntimeError. The archive may lack its central directory, and reopening it in append mode would make zipfile start 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.
  • Reusing a store backed by a file object after close() requires it to be readable and seekable, and raises io.UnsupportedOperation otherwise. On a write-only file, zipfile started a new archive after the old one and the earlier entries were lost. The first open is unchanged.
  • Unpickling no longer opens the archive; an unpickled store opens it on first use. On main, every copy dask made of a store that was still writing reopened the file with "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 raises zipfile.BadZipFile, because appending to it would start a new archive after the entries already written. It opens the file itself rather than relying on zipfile.is_zipfile, which swallows OSError, 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.
  • ZipStoreAccessModeLiteral includes "x", and the docstring says "w" and "x" apply to the first open only.

Tests

  • test_methods_open_store_on_first_use and test_listing_opens_store_on_first_use call 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 replace test_lock_present from fix: ensure ZipStore is open before acquiring lock #3593, which used an already-open fixture and could not catch the missing check in get.
  • test_reopen_keeps_entries covers modes w and x with a read after close(), move(), and a pickle round trip.
  • test_close_blocks_concurrent_reopen, test_move_blocks_concurrent_reopen, and test_first_use_waits_for_lock start work on another thread while this thread is writing the central directory in close(), is inside move(), or holds the lock, and check that the other thread waits.
  • test_list_does_not_hold_lock_while_iterating calls list_dir() from another thread partway through list().
  • test_failed_close_blocks_reopen makes writing the central directory fail, checks that the next write raises, and that clear() recovers the store.
  • test_failed_clear_leaves_store_closed, test_clear_exclusive_mode_keeps_existing_file, and test_move_exclusive_mode_keeps_existing_file cover clear() and move().
  • test_write_after_close_keeps_entries, test_write_only_reuse_after_close_raises, and test_unseekable_reuse_after_close_raises cover file objects.
  • test_unused_copy_of_open_writer_does_not_write and test_used_copy_of_open_writer_raises pickle 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_guard and test_clear_read_mode_writable_store cover clear() after a failed close and on a mode="r", read_only=False store.
  • test_clear_replaces_damaged_file, test_reopen_recreates_deleted_file, and test_first_open_append_on_empty_file cover 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_path checks that a directory at the path is not reported as an unfinished archive.
  • test_unpickle_state_from_older_release unpickles state without the attributes this PR adds.
  • The new tests fail 32 times against main's _zip.py, with no errors leaking into other tests. With this change, tests/test_store and tests/test_api.py pass.

Known leftovers, not changed here, and each also present on main:

  • After a reopen in "a" mode, writing a key again adds a second entry with that name, as overwriting within one session already did.
  • A file object opened in append mode ("a+b") is readable and seekable, but its writes always land at the end of the file, so reopening it after close() can corrupt the archive.
  • A copy of a "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.
  • Several stores writing to one archive at once, in threads or processes, is not supported. A copy that reopens the archive while the original is still in its first writing session raises zipfile.BadZipFile; other concurrent writers are not detected and can lose writes.
Bookkeeping
  • The branch starts at the fix: ensure ZipStore is open before acquiring lock #3593 head (8688953ea) and merges upstream main (0ba2ca24a). The merge conflicts in _zip.py were 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.
  • The commits after adbed1886 came out of several review rounds.
  • The changelog fragment is named after this PR.

🤖 Generated with Claude Code

oelhammouchi and others added 14 commits November 21, 2025 00:56
…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

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.45%. Comparing base (319bfa4) to head (5b340cd).
⚠️ Report is 1 commits behind head on main.

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     
Files with missing lines Coverage Δ
src/zarr/storage/_zip.py 98.76% <100.00%> (+0.60%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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
d-v-b marked this pull request as ready for review September 29, 2026 21:08
@d-v-b
d-v-b requested a review from mkitti September 29, 2026 21:21

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants