Repository navigation
Conversation
jeffseif
left a comment
There was a problem hiding this comment.
From the IRL discussion:
- we would need a push plan where all existing zarr's have the complete file touched.
For zarr files, set up a .complete marker file to check when data is fully written. This replaces relying on zarr.json -- that doesn't work because zarr.json is the first to be written. This might happen from a failed or aborted calculation, or because multiple processes are trying to write the same file (e.g. running 2 countries which share a tile) A partially-written zarr might be not cause any warning signs later, since it might be techncially valid in spite of missing data. Now an empty .complete marker is written beside the zarr after its last chunk, and the entry counts as cached only once the marker exists. A zarr found without its marker is a write that was killed part-way or is still running: serialize raises FileExistsError and leaves it for someone to resolve by hand, usually by deleting the zarr, rather than writing over it and risking a race with a live writer. Entries written before this change have no marker, so each raises once until deleted.
An autouse fixture in jdluc/__tests__/conftest.py stubs Config.from_dot_env for every test not marked integration. Each field gets its type's empty value, with two dask workers, so a unit test that reaches for a storage root fails instead of touching a bucket. Integration tests still read the real .env. This replaces the fixture storage_test carried for itself, so the next unit test that writes a zarr needs no copy of it, and a new Config field needs no edit to the tests.
…e zarr
Review feedback on the complete marker:
- The marker moves inside the zarr, to `{uri}/complete`, so deleting the
zarr deletes it too.
- exists_uri is zarr.json again, and a new complete_uri sits alongside it
(the same for parquet, whose write is atomic). The cache decorator checks
complete_uri for a hit.
- serialize raises ZarrExistsAndIncompleteException or
ZarrExistsAndCompleteException (both FileExistsError), so callers can
choose how to handle each state.
- mark_existing_zarrs_complete backfills the marker into zarrs written
before it existed, so they are not recomputed.
- Zarr's warning about the unrecognized `complete` object is filtered when
opening a store.
- The no-.env test setup moves from conftest.py into storage_test.py.
d033289 to
f9daffd
Compare
… migration to tools/ - The cache decorator suppresses ZarrExistsAndCompleteException: when another process finishes the same entry while this one computes, its result is used instead of crashing the run. ZarrExistsAndIncompleteException still raises. - A test drives the decorator through each state: absent, incomplete, completed by another process, and a hit. - mark_existing_zarrs_complete leaves the library for tools/mark-zarrs-complete.py, matching the other one-off scripts: dry run by default, --apply to write, root defaulting to SCRATCH_ROOT. It builds paths through ZarrCacher, so the marker name lives in one place. Its docstring covers what it trusts, that only zarrs directly under the root are checked, and that it must run while nothing is writing to the root.
|
@jeffseif all the changes are now in-place, re-review please:
|
jeffseif
left a comment
There was a problem hiding this comment.
Logic and testing look solid -- this is a welcome improvement!
I left some comments and gave you a proactive approve so that you can address them and then merge or merge immediately if you disagree with them.
|
|
||
|
|
||
| def assert_reads_back(cacher: storage.ZarrCacher) -> None: | ||
| xarray.testing.assert_equal( |
There was a problem hiding this comment.
TIL, I'll try to use it in future tests!
|
|
||
|
|
||
| def test_cache_to_zarr_handles_each_state(scratch_root: pathlib.Path) -> None: | ||
| calls = [] |
There was a problem hiding this comment.
nit: we should be able to access the cache_info properties of the compute function (docs) rather than keeping track of calls ourselves. up to you if you want to adopt that or keep this.
| logger.info(f"Loading {path_to_zarr=:s}") | ||
| dset = xarray.open_zarr(consolidated=False, store=path_to_zarr) | ||
| with warnings.catch_warnings(): | ||
| # ZarrCacher's `complete` marker sits in the store root, which zarr warns about |
There was a problem hiding this comment.
I feel like this comment doesn't add much beyond the line below -- thoughts on dropping it?
| # ZarrCacher's `complete` marker sits in the store root, which zarr warns about |
| # Another process may finish this entry first; its result is as good as ours | ||
| with contextlib.suppress(ZarrExistsAndCompleteException): | ||
| cacher.serialize(func(*args, **kwargs)) |
There was a problem hiding this comment.
It looks like we are immediately handling this scenario within the cacher. What do you think about starting with the simpler set of logic (= letting ZarrExistsAndCompleteException raise) initially and then adding in more logic if we find it is needed?
| # Another process may finish this entry first; its result is as good as ours | |
| with contextlib.suppress(ZarrExistsAndCompleteException): | |
| cacher.serialize(func(*args, **kwargs)) | |
| cacher.serialize(func(*args, **kwargs)) |
There was a problem hiding this comment.
This forces us to mark all existing zarrs as complete, even if they failed partway.
But since the result would match today's behavior (= reading those partial zarrs) this seems fine.
For zarr cache entries, write a
completemarker once the data is fully written, and count an entry as cached only when the marker exists.Relying on
zarr.jsondoesn't work, because it is the first object written. A failed or aborted run, or two processes writing the same entry (e.g. two countries that share a tile), can leave a zarr that looks cached, and it may be technically valid while missing data.What changes
ZarrCacherwrites an empty{uri}/completeafter the last chunk. It sits inside the zarr, so deleting the zarr deletes it too.Cachers get a
complete_urialongsideexists_uri. For parquet, whose write is atomic, they are the same. The cache decorator checkscomplete_urifor a hit.serializedistinguishes three states and raises on the two existing ones, so callers can choose how to handle them (poll, skip, raise):ZarrExistsAndIncompleteException(killed part-way, or still running; resolve by hand, usually by deleting the zarr once nothing is writing it)ZarrExistsAndCompleteException(another process finished first)Both subclass
FileExistsError.The cache decorator suppresses
ZarrExistsAndCompleteException: if another process finishes the same entry while this one computes, the decorator returns that result rather than crashing.ZarrExistsAndIncompleteExceptionstill propagates, so a leftover partial zarr stops the run until someone deletes it.tools/mark-zarrs-complete.pyadds the marker to existing zarrs (see the push plan below).The storage unit tests run without a
.env(fixture local tostorage_test.py), and cover eachserializestate and the decorator through each state.Push plan
Existing zarrs have no marker, so after this merges each one would raise
ZarrExistsAndIncompleteExceptioninstead of being reused. To keep them, mark them complete withtools/mark-zarrs-complete.py. It runs from the repo root and works onSCRATCH_ROOTfrom.envunless given--root. Without--applyit only lists what it would mark.Run it only while nothing is writing to the root. It cannot tell an old zarr from one being written right now, and would mark a zarr mid-write as complete.
--applyagain to catch zarrs the old code wrote in between. It is idempotent: entries already marked are skipped.Caveats
ZarrCacherwrites them.completeobject when opening a store;open_zarr_to_dask_datasetfilters that one warning.