worldarray 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {worldarray-0.7.0 → worldarray-0.8.0}/CHANGELOG.md +36 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/PKG-INFO +7 -5
- {worldarray-0.7.0 → worldarray-0.8.0}/README.md +5 -3
- {worldarray-0.7.0 → worldarray-0.8.0}/docs/spec.md +54 -15
- {worldarray-0.7.0 → worldarray-0.8.0}/pyproject.toml +2 -2
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/__init__.py +1 -1
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/context.py +87 -29
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/store.py +48 -17
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/write.py +18 -2
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_access.py +72 -6
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_write.py +30 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/uv.lock +2 -2
- {worldarray-0.7.0 → worldarray-0.8.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/.gitignore +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/LICENSE +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/docs/changelog.md +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/docs/index.md +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/mkdocs.yml +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/array.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/placement.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/retry.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/src/worldarray/units.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/conftest.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_geometry.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_grid.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_metadata.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_read.py +0 -0
- {worldarray-0.7.0 → worldarray-0.8.0}/tests/test_resample.py +0 -0
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-10-23)
|
|
4
|
+
|
|
5
|
+
Faster writes from the contexts the package makes, and the create open timed out. No
|
|
6
|
+
placement change and no unit table change. **Requires tensorstore 0.1.68 or later** (was
|
|
7
|
+
0.1.60), the first with the `file_io_locking` setting.
|
|
8
|
+
|
|
9
|
+
- `shared_context` and `capped_context` write without fsync (`file_io_sync: false`) and
|
|
10
|
+
without OS file locks (`file_io_locking` mode `"none"`); `fsync=True` and `locking="os"`
|
|
11
|
+
restore tensorstore's defaults. A whole-unit write stays atomic, a temporary file renamed
|
|
12
|
+
into place; only concurrent read-modify-writes of one unit (`partial=True`) lose their
|
|
13
|
+
guarantee, which was never given (Behaviour 13). fsync cost 6.5 ms a chunk on a local
|
|
14
|
+
disk and nothing on NFS; the locks cost about 10 ms a chunk on NFS.
|
|
15
|
+
- The default context's file I/O pool is `thread_budget()` or 8, whichever is larger;
|
|
16
|
+
decoding stays at `thread_budget()`. File I/O threads wait on storage rather than
|
|
17
|
+
compute, and one of them wrote a block's chunk files one NFS round trip after another.
|
|
18
|
+
`capped_context(file_io=)` sizes it apart from `concurrency` (default unchanged).
|
|
19
|
+
- From one-thread workers through the default context, against 0.7: a 64^3 label chunk to
|
|
20
|
+
NFS 23 to 12.2 ms (zarr-python 2: 13.1), an 8-chunk label block 159 to 66.5 ms
|
|
21
|
+
(zarr-python 2: 102), an 8-chunk EM block 330 to 202 ms; to a local disk a label block
|
|
22
|
+
51.5 to 4.1 ms. Through `capped_context(1, network=False)` the NFS label block went 166
|
|
23
|
+
to 91 ms; `file_io=8` gives it the rest.
|
|
24
|
+
- The open that creates an array is timed out and retried under `retries` (Behaviour 6). A
|
|
25
|
+
retry that finds the array made by an attempt of its own that timed out adopts it after
|
|
26
|
+
the usual comparison, even under `exists="error"`. That open keeps OS locking, so
|
|
27
|
+
create-if-absent stays atomic.
|
|
28
|
+
|
|
29
|
+
## 0.7.1 (2026-10-23)
|
|
30
|
+
|
|
31
|
+
Faster opens over http. No placement change, no unit table change, no API change.
|
|
32
|
+
|
|
33
|
+
- The documents a call needs from one directory are read at once instead of in turn. An
|
|
34
|
+
http open of a zarr v2 level made its eight requests one after another (five probe keys,
|
|
35
|
+
the level's `.zattrs`, the parent's `zarr.json` and `.zattrs`); now they go in three
|
|
36
|
+
rounds. At 10 ms an answer, `wa.open` took 98 ms and takes 52 (cellmap-flow's
|
|
37
|
+
measurement). A server that never answers still costs one key's attempts, not five.
|
|
38
|
+
|
|
3
39
|
## 0.7.0 (2026-10-23)
|
|
4
40
|
|
|
5
41
|
Opens time out. No placement change and no unit table change.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: worldarray
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only.
|
|
5
5
|
Project-URL: Homepage, https://janeliascicomp.github.io/worldarray/
|
|
6
6
|
Project-URL: Documentation, https://janeliascicomp.github.io/worldarray/
|
|
@@ -54,7 +54,7 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
|
54
54
|
Classifier: Typing :: Typed
|
|
55
55
|
Requires-Python: >=3.10
|
|
56
56
|
Requires-Dist: numpy>=1.26
|
|
57
|
-
Requires-Dist: tensorstore>=0.1.
|
|
57
|
+
Requires-Dist: tensorstore>=0.1.68
|
|
58
58
|
Description-Content-Type: text/markdown
|
|
59
59
|
|
|
60
60
|
# worldarray
|
|
@@ -209,9 +209,11 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
|
209
209
|
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
|
|
210
210
|
wa.open_tensorstore(path, level=None, context=None, retries=None)
|
|
211
211
|
# retries= times out and retries document reads and the open, as it does data reads
|
|
212
|
-
wa.shared_context(cache_bytes=0, threads=None
|
|
213
|
-
wa.
|
|
214
|
-
|
|
212
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none") ; wa.Retry(...)
|
|
213
|
+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
214
|
+
wa.thread_budget() # the shared context's decoding threads: OMP_NUM_THREADS, the job's slots, or CPUs;
|
|
215
|
+
# file I/O gets at least 8; threads=N limits both to N, threads=0 leaves tensorstore's sizes
|
|
216
|
+
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
215
217
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
216
218
|
wa.Box ; wa.units.TABLE
|
|
217
219
|
|
|
@@ -150,9 +150,11 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
|
|
|
150
150
|
wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
|
|
151
151
|
wa.open_tensorstore(path, level=None, context=None, retries=None)
|
|
152
152
|
# retries= times out and retries document reads and the open, as it does data reads
|
|
153
|
-
wa.shared_context(cache_bytes=0, threads=None
|
|
154
|
-
wa.
|
|
155
|
-
|
|
153
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none") ; wa.Retry(...)
|
|
154
|
+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
|
|
155
|
+
wa.thread_budget() # the shared context's decoding threads: OMP_NUM_THREADS, the job's slots, or CPUs;
|
|
156
|
+
# file I/O gets at least 8; threads=N limits both to N, threads=0 leaves tensorstore's sizes
|
|
157
|
+
# contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
|
|
156
158
|
wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
|
|
157
159
|
wa.Box ; wa.units.TABLE
|
|
158
160
|
|
|
@@ -85,8 +85,8 @@ is asked to convert.
|
|
|
85
85
|
the OME document builder and the level rule, chunk keys, `create`, opening for write,
|
|
86
86
|
aligned writes, `add_level`. 0.3 adds grids (Behaviours 17 to 19): a store-less `Grid`,
|
|
87
87
|
the block iterator every blockwise repo writes for itself, `write_chunk`, the factor
|
|
88
|
-
schedule and the public path helpers. Dependencies at every version: `tensorstore
|
|
89
|
-
`numpy`. **No `zarr`, no `dask`, no `pydantic`, no `funlib`.** The package is float-only; a
|
|
88
|
+
schedule and the public path helpers. Dependencies at every version: `tensorstore`
|
|
89
|
+
(`>=0.1.60`; `>=0.1.68` from 0.8, the first with `file_io_locking`), `numpy`. **No `zarr`, no `dask`, no `pydantic`, no `funlib`.** The package is float-only; a
|
|
90
90
|
consumer that works in integer Rois builds them in its own subclass (section 6). Python >=
|
|
91
91
|
3.10 with no 3.11-only syntax, no upper bounds on numpy or tensorstore.
|
|
92
92
|
|
|
@@ -185,8 +185,11 @@ wa.open_tensorstore(path, level=None, context=None, retries=None) -> tensorstore
|
|
|
185
185
|
wa.corner_to_centre(corner, voxel_size, axes=None) ; wa.default_axes(ndim) ; wa.index_range(begin, end, voxel_size, translation)
|
|
186
186
|
wa.units.TABLE # the unit tables, public: {"length": {...metres per unit}, "time": {...seconds per unit}}
|
|
187
187
|
wa.select_level(levels, voxel_size, mode) ; wa.Box ; wa.Retry(max_attempts=10, timeout=None, base_delay=1.0)
|
|
188
|
-
wa.shared_context(cache_bytes=0, threads=None
|
|
189
|
-
|
|
188
|
+
wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none")
|
|
189
|
+
wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none") ; wa.capped_context_spec(...)
|
|
190
|
+
# threads: None = thread_budget() for decoding and at least 8 for file I/O (0.8), N = that many,
|
|
191
|
+
# 0 = tensorstore's own pool sizes (0.6); fsync and locking: tensorstore's file_io_sync and
|
|
192
|
+
# file_io_locking mode, off and "none" in every context the package makes (0.8, Behaviour 7)
|
|
190
193
|
# network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
|
|
191
194
|
wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
|
|
192
195
|
# metadata document needed, so a directory of chunk files with no .zarray lists too
|
|
@@ -232,9 +235,9 @@ where it also chooses the integer scale factor that makes the corner whole.
|
|
|
232
235
|
|
|
233
236
|
`shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
|
|
234
237
|
thread limit for the process, so the levels of a pyramid and repeat opens share one cache
|
|
235
|
-
pool; the default budget is 0. Its decoding
|
|
236
|
-
|
|
237
|
-
(0.6, Behaviour 7).
|
|
238
|
+
pool; the default budget is 0. Its decoding pool is sized by `thread_budget()` (0.5) and
|
|
239
|
+
its file I/O pool by that or 8, whichever is larger (0.8), or both by `threads` when given,
|
|
240
|
+
`0` leaving tensorstore's own sizes (0.6, Behaviour 7).
|
|
238
241
|
`capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
|
|
239
242
|
resource limited, cache off); it returns a **new** context per call, as those workers
|
|
240
243
|
inline the spec into every open today, and `capped_context_spec` exports the dict for
|
|
@@ -490,7 +493,9 @@ this document and that one differ, fix one of them.
|
|
|
490
493
|
|
|
491
494
|
5. **Stores are read through tensorstore's kvstore only**, never zarr-python: `zarr.json`,
|
|
492
495
|
`.zarray`, `.zattrs`, `.zgroup`, `attributes.json`, `info`. Format detection probes keys
|
|
493
|
-
in this order: `zarr.json`, `.zarray`, `.zgroup`, `info`, `attributes.json
|
|
496
|
+
in this order: `zarr.json`, `.zarray`, `.zgroup`, `info`, `attributes.json`, the reads
|
|
497
|
+
begun all at once and the order deciding which document wins (0.7.1: one round trip
|
|
498
|
+
where an http open made five in a row, cellmap-flow's measurement); when none is
|
|
494
499
|
found, the parent is probed for `info` and the last path segment is taken as a
|
|
495
500
|
precomputed scale key (mesh-n-bone's case), then `s0` is probed so a group with no
|
|
496
501
|
document of its own (an N5 directory holding `s0, s1, ...`) is still a group. The
|
|
@@ -663,8 +668,13 @@ this document and that one differ, fix one of them.
|
|
|
663
668
|
of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
|
|
664
669
|
`detect` and `open_tensorstore`, which all take it; a document read later outside such
|
|
665
670
|
a call (a zarr v2 `.zattrs` read on demand, a pyramid placing its levels) waits under
|
|
666
|
-
`Retry()`. Listings are not timed out, their size being unknown
|
|
667
|
-
|
|
671
|
+
`Retry()`. Listings are not timed out, their size being unknown. The open that creates
|
|
672
|
+
an array is (0.8, cellmap-analyze's question): its second attempt may find the array its
|
|
673
|
+
first made before timing out, so after a timed-out attempt `ALREADY_EXISTS` is not an
|
|
674
|
+
error even under `exists="error"`, and `create` adopts the array only if it matches what
|
|
675
|
+
was asked for (the comparison an adopt makes), since an identical array that appeared
|
|
676
|
+
after our own attempt is ours. That open keeps tensorstore's `"os"` file locking whatever
|
|
677
|
+
the caller's context says, as create-if-absent is a conditional write.
|
|
668
678
|
|
|
669
679
|
The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
|
|
670
680
|
worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
|
|
@@ -685,8 +695,7 @@ this document and that one differ, fix one of them.
|
|
|
685
695
|
|
|
686
696
|
7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
|
|
687
697
|
tensorstore's cache is off and the levels of one pyramid share one context. Its
|
|
688
|
-
`data_copy_concurrency`
|
|
689
|
-
first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
|
|
698
|
+
`data_copy_concurrency` limit is `thread_budget()`: the first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
|
|
690
699
|
sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
|
|
691
700
|
integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
|
|
692
701
|
in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
|
|
@@ -694,7 +703,13 @@ this document and that one differ, fix one of them.
|
|
|
694
703
|
workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
|
|
695
704
|
worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
|
|
696
705
|
watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
|
|
697
|
-
`LSB_DJOB_NUMPROC` elsewhere.
|
|
706
|
+
`LSB_DJOB_NUMPROC` elsewhere. Its `file_io_concurrency` limit is that or
|
|
707
|
+
`context.FILE_IO_FLOOR` (8), whichever is larger (0.8): file I/O threads wait on storage
|
|
708
|
+
rather than compute, so they take no core from the job, and one of them serialises every
|
|
709
|
+
chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
|
|
710
|
+
chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
|
|
711
|
+
four to sixteen (zarr-python 2 took 102); an 8-chunk EM read from /nrs was the same
|
|
712
|
+
either way, decoding bound. The cap is the default because the process that passes
|
|
698
713
|
no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
|
|
699
714
|
job came to start a thousand threads on twelve slots. One thread per pool slows one
|
|
700
715
|
task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
|
|
@@ -709,9 +724,30 @@ this document and that one differ, fix one of them.
|
|
|
709
724
|
process, made on first use and watched; `threads=None` and the number
|
|
710
725
|
`thread_budget()` returns are two contexts, as a pickle carries `None` for the other
|
|
711
726
|
process to size by its own job and a number as that number.
|
|
727
|
+
|
|
728
|
+
Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
|
|
729
|
+
sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
|
|
730
|
+
the arguments `fsync=True` and `locking="os"` restoring tensorstore's defaults. fsync
|
|
731
|
+
cost 6.5 ms a chunk on a local NVMe disk (8.2 against 1.5 ms for a 64^3 chunk) and
|
|
732
|
+
nothing on NFS, whose client commits a file when it closes it; zarr-python and funlib,
|
|
733
|
+
every writer the repos used before, never synced, so a machine crash on a local disk
|
|
734
|
+
can lose the last seconds of chunks as it always could, and a killed process loses
|
|
735
|
+
nothing. `"os"` locking takes an advisory lock on a `.__lock` file for every write,
|
|
736
|
+
about 10 ms of NFS round trips a chunk (21.7 against 12.0 ms for a 64^3 label chunk on
|
|
737
|
+
/groups; zarr-python 2 took 12.7). With `"none"` a whole-unit write is still atomic, a
|
|
738
|
+
temporary file renamed into place, and only concurrent read-modify-writes of one unit
|
|
739
|
+
lose their guarantee, which Behaviour 13 never gave (`partial=True` is unsafe while
|
|
740
|
+
anything else writes the unit); a write interrupted mid-way may leave a temporary
|
|
741
|
+
`.__lock` file that costs space only. tensorstore's `"lockfile"` mode is not the
|
|
742
|
+
default because such a file left by a killed worker blocks every later write of its
|
|
743
|
+
unit. Together, from one-thread workers (`OMP_NUM_THREADS=1`) through the default
|
|
744
|
+
context: a 64^3 label chunk to NFS took 23 ms in 0.7 and 12.2 in 0.8, an 8-chunk label
|
|
745
|
+
block 159 and 66.5, an 8-chunk EM block 330 and 202; to a local disk the label block
|
|
746
|
+
took 51.5 and 4.1 ms.
|
|
712
747
|
`capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
|
|
713
748
|
`gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
|
|
714
|
-
`concurrency`
|
|
749
|
+
`concurrency` (`file_io_concurrency` to `file_io` when given, 0.8) and the cache to
|
|
750
|
+
`cache_bytes`, and returns a new context each call, so two
|
|
715
751
|
handles never share one capped pool unless the caller passes the same object twice; a
|
|
716
752
|
`concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
|
|
717
753
|
several chunks then handles them one at a time. On /nrs, from one process alternating
|
|
@@ -1033,7 +1069,10 @@ context's own pools; `thread_budget` from each variable, a bad value skipped, an
|
|
|
1033
1069
|
shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
|
|
1034
1070
|
context per pair, a negative count refused, and a pickled handle coming back on the same
|
|
1035
1071
|
shared context. 0.7: `metadata`, `open` and `detect` against a server
|
|
1036
|
-
that accepts and never answers raising `TimeoutError` after the attempts.
|
|
1072
|
+
that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
|
|
1073
|
+
and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
|
|
1074
|
+
through a pickle, and a create whose first attempt lands after its timeout adopting its own
|
|
1075
|
+
array under `exists="error"`. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
|
|
1037
1076
|
`tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
|
|
1038
1077
|
worker exits idle, so a plain context strands some for good (13 of about 7,000 on
|
|
1039
1078
|
tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "worldarray"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Read zarr, N5 and Neuroglancer precomputed volumes in world coordinates through tensorstore: correct voxel placement, pyramid levels, padded region reads, resampling. numpy + tensorstore only."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -31,7 +31,7 @@ classifiers = [
|
|
|
31
31
|
# no dask, no funlib (docs/spec.md, section 1).
|
|
32
32
|
dependencies = [
|
|
33
33
|
"numpy>=1.26",
|
|
34
|
-
"tensorstore>=0.1.
|
|
34
|
+
"tensorstore>=0.1.68",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
thread limit for the whole process, so the levels of a pyramid and repeat opens of an
|
|
5
5
|
array draw on one cache pool. The default budget is 0: tensorstore's decoded-chunk cache
|
|
6
6
|
is off, because every consumer either reads each region once or caches decoded chunks
|
|
7
|
-
itself. By default its decoding
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
(``
|
|
7
|
+
itself. By default its decoding pool is sized by ``thread_budget()``, from the variables a
|
|
8
|
+
cluster job sets, where tensorstore alone would size it to every CPU on the node, and its
|
|
9
|
+
file I/O pool to at least ``FILE_IO_FLOOR`` threads, which wait on storage rather than
|
|
10
|
+
compute; ``threads=0`` leaves both at tensorstore's sizes (chunkmirage's server). Every
|
|
11
|
+
context made here writes without fsync and without OS file locks (``write_settings``).
|
|
12
|
+
Every context here, and every one a store is opened on, is watched for tensorstore's lost
|
|
13
|
+
wakeup (``watch``).
|
|
12
14
|
|
|
13
15
|
``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
|
|
14
16
|
resource limited, so dozens of worker processes on one host do not multiply into thousands
|
|
@@ -28,7 +30,7 @@ from typing import Any
|
|
|
28
30
|
|
|
29
31
|
import tensorstore as ts
|
|
30
32
|
|
|
31
|
-
_shared: dict[tuple[int, int | None], ts.Context] = {}
|
|
33
|
+
_shared: dict[tuple[int, int | None, bool, str], ts.Context] = {}
|
|
32
34
|
_rebuilt: dict[str, ts.Context] = {}
|
|
33
35
|
_lock = threading.Lock()
|
|
34
36
|
|
|
@@ -138,38 +140,79 @@ def thread_budget() -> int:
|
|
|
138
140
|
return os.cpu_count() or 1
|
|
139
141
|
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
# File I/O threads mostly wait on storage, so they are not capped like decoding: on NFS,
|
|
144
|
+
# eight 64^3 label chunks written from one decoding thread took 166-176 ms with one file I/O
|
|
145
|
+
# thread and 83-89 ms with four to sixteen (2026-10-23; zarr-python 2 took 102)
|
|
146
|
+
FILE_IO_FLOOR = 8
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def write_settings(fsync: bool = False, locking: str = "none") -> dict:
|
|
150
|
+
"""tensorstore's ``file_io_sync`` and ``file_io_locking`` for a context made here. fsync
|
|
151
|
+
is off by default: zarr-python never synced, NFS commits a file on close anyway, and on
|
|
152
|
+
a local disk it cost 6 ms a chunk. Locking is ``"none"`` by default: a whole-unit write
|
|
153
|
+
stays atomic (a temporary file renamed into place), only concurrent read-modify-writes of
|
|
154
|
+
one unit lose the guarantee, which Behaviour 13 never gave, and tensorstore's ``"os"``
|
|
155
|
+
locks cost about 10 ms a chunk on NFS (spec, Behaviour 7)."""
|
|
156
|
+
return {"file_io_sync": bool(fsync), "file_io_locking": {"mode": str(locking)}}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def shared_context(
|
|
160
|
+
cache_bytes: int = 0,
|
|
161
|
+
*,
|
|
162
|
+
threads: int | None = None,
|
|
163
|
+
fsync: bool = False,
|
|
164
|
+
locking: str = "none",
|
|
165
|
+
) -> ts.Context:
|
|
166
|
+
"""The process's one context for these arguments, made on first use and watched.
|
|
167
|
+
``threads`` limits its decoding and file I/O pools: ``None`` decoding to
|
|
168
|
+
``thread_budget()`` and file I/O to that or ``FILE_IO_FLOOR``, whichever is larger, a
|
|
169
|
+
positive number both to that many, ``0`` neither (tensorstore's own sizes, every CPU on
|
|
170
|
+
the node). ``fsync`` and ``locking`` go to ``write_settings``."""
|
|
171
|
+
key = (int(cache_bytes), None if threads is None else int(threads), bool(fsync), str(locking))
|
|
147
172
|
if key[1] is not None and key[1] < 0:
|
|
148
173
|
raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
|
|
149
174
|
with _lock:
|
|
150
175
|
ctx = _shared.get(key)
|
|
151
176
|
if ctx is None:
|
|
152
|
-
spec: dict = {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
177
|
+
spec: dict = {
|
|
178
|
+
"cache_pool": {"total_bytes_limit": key[0]},
|
|
179
|
+
**write_settings(fsync, locking),
|
|
180
|
+
}
|
|
181
|
+
if threads is None:
|
|
182
|
+
budget = thread_budget()
|
|
183
|
+
spec["data_copy_concurrency"] = {"limit": budget}
|
|
184
|
+
spec["file_io_concurrency"] = {"limit": max(budget, FILE_IO_FLOOR)}
|
|
185
|
+
elif key[1]:
|
|
186
|
+
spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": key[1]}
|
|
156
187
|
ctx = watch(ts.Context(spec))
|
|
157
188
|
_shared[key] = ctx
|
|
158
189
|
return ctx
|
|
159
190
|
|
|
160
191
|
|
|
161
|
-
def capped_context_spec(
|
|
192
|
+
def capped_context_spec(
|
|
193
|
+
cache_bytes: int = 0,
|
|
194
|
+
concurrency: int = 1,
|
|
195
|
+
network: bool = True,
|
|
196
|
+
*,
|
|
197
|
+
file_io: int | None = None,
|
|
198
|
+
fsync: bool = False,
|
|
199
|
+
locking: str = "none",
|
|
200
|
+
) -> dict:
|
|
162
201
|
"""The spec dict behind ``capped_context``, for callers building their own opens.
|
|
163
202
|
``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
|
|
164
203
|
pools alone (cellmap-analyze's choice for remote reads). A ``concurrency`` of 1 is safe:
|
|
165
204
|
the context is watched (see ``KICK_EVERY``). It is not free: a read or write spanning
|
|
166
205
|
several chunks then decodes and stores them one at a time, half the speed of 4 on
|
|
167
|
-
8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7).
|
|
206
|
+
8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7).
|
|
207
|
+
``file_io`` sizes the file I/O pool apart from decoding (default ``concurrency``; on NFS
|
|
208
|
+
a few file I/O threads halve the time of a multi-chunk write, ``FILE_IO_FLOOR``).
|
|
209
|
+
``fsync`` and ``locking`` go to ``write_settings``."""
|
|
168
210
|
limit = {"limit": int(concurrency)}
|
|
169
211
|
spec = {
|
|
170
212
|
"data_copy_concurrency": limit,
|
|
171
|
-
"file_io_concurrency": limit,
|
|
213
|
+
"file_io_concurrency": {"limit": int(file_io if file_io is not None else concurrency)},
|
|
172
214
|
"cache_pool": {"total_bytes_limit": int(cache_bytes)},
|
|
215
|
+
**write_settings(fsync, locking),
|
|
173
216
|
}
|
|
174
217
|
if network:
|
|
175
218
|
spec.update(
|
|
@@ -180,20 +223,31 @@ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: boo
|
|
|
180
223
|
return spec
|
|
181
224
|
|
|
182
225
|
|
|
183
|
-
def capped_context(
|
|
184
|
-
|
|
226
|
+
def capped_context(
|
|
227
|
+
cache_bytes: int = 0,
|
|
228
|
+
concurrency: int = 1,
|
|
229
|
+
network: bool = True,
|
|
230
|
+
*,
|
|
231
|
+
file_io: int | None = None,
|
|
232
|
+
fsync: bool = False,
|
|
233
|
+
locking: str = "none",
|
|
234
|
+
) -> ts.Context:
|
|
235
|
+
spec = capped_context_spec(
|
|
236
|
+
cache_bytes, concurrency, network, file_io=file_io, fsync=fsync, locking=locking
|
|
237
|
+
)
|
|
238
|
+
return watch(ts.Context(spec))
|
|
185
239
|
|
|
186
240
|
|
|
187
241
|
def recipe_of(context: ts.Context) -> tuple[str, Any]:
|
|
188
242
|
"""How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
|
|
189
|
-
context at
|
|
190
|
-
``("shared", (cache_bytes, threads))``
|
|
191
|
-
``("spec", json)`` from the context's own spec, so a capped context stays capped
|
|
192
|
-
a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
243
|
+
context with every other argument at its default (the other process takes its own
|
|
244
|
+
``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking))`` otherwise,
|
|
245
|
+
else ``("spec", json)`` from the context's own spec, so a capped context stays capped
|
|
246
|
+
across a pickle (Behaviour 11, mesh-n-bone's workers)."""
|
|
193
247
|
with _lock:
|
|
194
|
-
for
|
|
248
|
+
for key, ctx in _shared.items():
|
|
195
249
|
if ctx is context:
|
|
196
|
-
return ("shared",
|
|
250
|
+
return ("shared", key[0] if key[1:] == (None, False, "none") else key)
|
|
197
251
|
spec = context.spec
|
|
198
252
|
return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
|
|
199
253
|
|
|
@@ -205,8 +259,12 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
|
|
|
205
259
|
return None
|
|
206
260
|
kind, value = recipe
|
|
207
261
|
if kind == "shared":
|
|
208
|
-
cache_bytes, threads
|
|
209
|
-
|
|
262
|
+
cache_bytes, threads, fsync, locking = (
|
|
263
|
+
(*value, False, "none")[:4]
|
|
264
|
+
if isinstance(value, (tuple, list))
|
|
265
|
+
else (value, None, False, "none")
|
|
266
|
+
)
|
|
267
|
+
return shared_context(int(cache_bytes), threads=threads, fsync=fsync, locking=locking)
|
|
210
268
|
with _lock:
|
|
211
269
|
ctx = _rebuilt.get(value)
|
|
212
270
|
if ctx is None:
|
|
@@ -254,18 +254,48 @@ def _metadata_kvstore(spec_json: str, gcs_url: str | None) -> ts.KvStore:
|
|
|
254
254
|
return _wait(lambda: ts.KvStore.open(metadata_spec(spec)), spec, _where(spec), "open")
|
|
255
255
|
|
|
256
256
|
|
|
257
|
-
def
|
|
258
|
-
|
|
257
|
+
def _metadata_kv(spec: dict) -> ts.KvStore:
|
|
258
|
+
return _metadata_kvstore(
|
|
259
259
|
json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
|
|
260
260
|
)
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_keys(spec: dict, keys: Sequence[str]) -> dict[str, bytes | None | BaseException]:
|
|
264
|
+
"""``keys`` under one way in, their reads begun at once: one round trip, not one per key
|
|
265
|
+
(an http open probes five). Each value is the document, None when it is missing, or the
|
|
266
|
+
error its read raised; after an error other than a refusal the rest are cancelled and
|
|
267
|
+
left out, so a server that never answers costs one key's attempts, not five."""
|
|
268
|
+
kv = _metadata_kv(spec)
|
|
269
|
+
started = {key: kv.read(key) for key in keys}
|
|
270
|
+
out: dict[str, bytes | None | BaseException] = {}
|
|
271
|
+
for key in keys:
|
|
272
|
+
first = [started[key]]
|
|
273
|
+
|
|
274
|
+
def start(key=key, first=first):
|
|
275
|
+
return first.pop() if first else kv.read(key)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
result = _wait(start, spec, _where(spec, key), "read")
|
|
279
|
+
except Exception as e: # noqa: BLE001 the caller decides what a failed key means
|
|
280
|
+
out[key] = e
|
|
281
|
+
if not is_refused(e):
|
|
282
|
+
for future in started.values():
|
|
283
|
+
future.cancel()
|
|
284
|
+
return out
|
|
285
|
+
continue
|
|
286
|
+
out[key] = None if result.state == "missing" else bytes(result.value)
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _read_key(spec: dict, key: str) -> bytes | None:
|
|
291
|
+
value = _read_keys(spec, [key])[key]
|
|
292
|
+
if isinstance(value, BaseException):
|
|
293
|
+
raise value
|
|
294
|
+
return value
|
|
263
295
|
|
|
264
296
|
|
|
265
297
|
def _write_key(spec: dict, key: str, data: bytes) -> None:
|
|
266
|
-
kv =
|
|
267
|
-
json.dumps(spec, sort_keys=True), os.environ.get("TENSORSTORE_GCS_HTTP_URL")
|
|
268
|
-
)
|
|
298
|
+
kv = _metadata_kv(spec)
|
|
269
299
|
_wait(lambda: kv.write(key, data), spec, _where(spec, key), "write")
|
|
270
300
|
|
|
271
301
|
|
|
@@ -292,20 +322,21 @@ class Location:
|
|
|
292
322
|
self.write(key, (json.dumps(doc, indent=2) + "\n").encode())
|
|
293
323
|
|
|
294
324
|
def read_many(self, keys: Sequence[str]) -> dict[str, bytes | None]:
|
|
295
|
-
"""All of ``keys`` under one way in. A bucket with
|
|
296
|
-
missing key, so a refusal on one key counts as
|
|
297
|
-
under the same way; only when every key is refused
|
|
325
|
+
"""All of ``keys`` under one way in, read at once (``_read_keys``). A bucket with
|
|
326
|
+
listing off answers a refusal for a missing key, so a refusal on one key counts as
|
|
327
|
+
missing when another key was read under the same way; only when every key is refused
|
|
328
|
+
is the next way tried."""
|
|
298
329
|
|
|
299
330
|
def attempt(spec: dict) -> dict[str, bytes | None]:
|
|
300
331
|
found: dict[str, bytes | None] = {}
|
|
301
332
|
refusals: dict[str, BaseException] = {}
|
|
302
|
-
for key in keys:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
333
|
+
for key, value in _read_keys(spec, keys).items():
|
|
334
|
+
if isinstance(value, BaseException):
|
|
335
|
+
if not is_refused(value):
|
|
336
|
+
raise value
|
|
337
|
+
refusals[key] = value
|
|
338
|
+
else:
|
|
339
|
+
found[key] = value
|
|
309
340
|
if refusals and len(refusals) == len(keys):
|
|
310
341
|
raise Refused(next(iter(refusals.values())).args[0] if refusals else "refused")
|
|
311
342
|
for key in refusals:
|
|
@@ -34,6 +34,7 @@ from .store import (
|
|
|
34
34
|
_DRIVER,
|
|
35
35
|
Location,
|
|
36
36
|
Node,
|
|
37
|
+
_wait,
|
|
37
38
|
basename,
|
|
38
39
|
detect,
|
|
39
40
|
is_refused,
|
|
@@ -753,6 +754,11 @@ def create_array(
|
|
|
753
754
|
# the group documents first (Behaviour 11), then the array
|
|
754
755
|
ensure_group_entry(group_path, name, vs, tr, axes, units, axis_types, group_attrs, format=fmt)
|
|
755
756
|
|
|
757
|
+
# create-if-absent is a conditional write, atomic only under tensorstore's OS locks,
|
|
758
|
+
# whatever locking the caller's context chose for its data (write_settings)
|
|
759
|
+
creating = watch(ts.Context(ts.Context.Spec({"file_io_locking": {"mode": "os"}}), context))
|
|
760
|
+
starts = 0
|
|
761
|
+
|
|
756
762
|
def attempt(kvstore: dict) -> ts.TensorStore:
|
|
757
763
|
spec = array_spec(
|
|
758
764
|
kvstore,
|
|
@@ -768,16 +774,26 @@ def create_array(
|
|
|
768
774
|
separator,
|
|
769
775
|
store_fill,
|
|
770
776
|
)
|
|
771
|
-
|
|
777
|
+
|
|
778
|
+
def start():
|
|
779
|
+
nonlocal starts
|
|
780
|
+
starts += 1
|
|
781
|
+
return ts.open(spec, create=True, open=False, context=creating)
|
|
782
|
+
|
|
783
|
+
return _wait(start, kvstore, path, "create")
|
|
772
784
|
|
|
773
785
|
try:
|
|
774
786
|
with_access(kvstore_specs(path), attempt)
|
|
775
787
|
except Exception as e: # noqa: BLE001
|
|
776
788
|
if "ALREADY_EXISTS" not in str(e) or is_refused(e):
|
|
777
789
|
raise
|
|
778
|
-
if exists == "error":
|
|
790
|
+
if exists == "error" and starts == 1:
|
|
779
791
|
raise FileExistsError(f"{path} was created by another process meanwhile") from e
|
|
792
|
+
# another process made it, or an attempt of ours that timed out did: the caller
|
|
793
|
+
# adopts it only if it matches what was asked for (Behaviour 6)
|
|
780
794
|
_wait_for(path)
|
|
795
|
+
if starts > 1 and fmt == "zarr2" and attrs and Location(path).read(".zattrs") is None:
|
|
796
|
+
Location(path).write_json(".zattrs", dict(attrs))
|
|
781
797
|
return path, fmt, True
|
|
782
798
|
if fmt == "zarr2" and attrs:
|
|
783
799
|
Location(path).write_json(".zattrs", dict(attrs))
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import functools
|
|
5
6
|
import gc
|
|
7
|
+
import http.server
|
|
6
8
|
import logging
|
|
7
9
|
import os
|
|
8
10
|
import socket
|
|
@@ -56,6 +58,21 @@ def test_other_errors_raise_at_once():
|
|
|
56
58
|
store.with_access(specs, attempt)
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
def _per_key(read_key):
|
|
62
|
+
"""A ``store._read_keys`` stand-in built from a one-key fake."""
|
|
63
|
+
|
|
64
|
+
def read_keys(spec, keys):
|
|
65
|
+
out = {}
|
|
66
|
+
for key in keys:
|
|
67
|
+
try:
|
|
68
|
+
out[key] = read_key(spec, key)
|
|
69
|
+
except Exception as e: # noqa: BLE001
|
|
70
|
+
out[key] = e
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
return read_keys
|
|
74
|
+
|
|
75
|
+
|
|
59
76
|
def test_a_refused_probe_key_counts_as_missing_when_another_key_was_read(monkeypatch):
|
|
60
77
|
present = {"zarr.json": b'{"node_type": "array"}'}
|
|
61
78
|
|
|
@@ -66,7 +83,7 @@ def test_a_refused_probe_key_counts_as_missing_when_another_key_was_read(monkeyp
|
|
|
66
83
|
return present[key]
|
|
67
84
|
raise RuntimeError("PERMISSION_DENIED: listing is off, so a missing key is a 403")
|
|
68
85
|
|
|
69
|
-
monkeypatch.setattr(store, "
|
|
86
|
+
monkeypatch.setattr(store, "_read_keys", _per_key(fake_read_key))
|
|
70
87
|
loc = store.Location("s3://bucket/vol.zarr/s0")
|
|
71
88
|
found = loc.read_many(store.PROBE_KEYS)
|
|
72
89
|
assert found["zarr.json"] == present["zarr.json"]
|
|
@@ -79,7 +96,7 @@ def test_all_probe_keys_refused_falls_back(monkeypatch):
|
|
|
79
96
|
raise RuntimeError("PERMISSION_DENIED")
|
|
80
97
|
return b"{}" if key == ".zarray" else None
|
|
81
98
|
|
|
82
|
-
monkeypatch.setattr(store, "
|
|
99
|
+
monkeypatch.setattr(store, "_read_keys", _per_key(fake_read_key))
|
|
83
100
|
found = store.Location("s3://bucket/vol.zarr/s0").read_many(store.PROBE_KEYS)
|
|
84
101
|
assert found[".zarray"] == b"{}" and found["zarr.json"] is None
|
|
85
102
|
|
|
@@ -137,7 +154,10 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
|
|
|
137
154
|
assert wa.thread_budget() == 6
|
|
138
155
|
monkeypatch.setattr(wa.context, "_shared", {})
|
|
139
156
|
spec = wa.shared_context(5).spec.to_json()
|
|
140
|
-
assert spec["data_copy_concurrency"] ==
|
|
157
|
+
assert spec["data_copy_concurrency"] == {"limit": 6}
|
|
158
|
+
assert spec["file_io_concurrency"] == {
|
|
159
|
+
"limit": wa.context.FILE_IO_FLOOR
|
|
160
|
+
} # it waits, not computes
|
|
141
161
|
assert spec["cache_pool"] == {"total_bytes_limit": 5}
|
|
142
162
|
|
|
143
163
|
|
|
@@ -309,7 +329,11 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
309
329
|
monkeypatch.setenv("OMP_NUM_THREADS", "1")
|
|
310
330
|
monkeypatch.setattr(wa.context, "_shared", {})
|
|
311
331
|
default = wa.shared_context(7)
|
|
312
|
-
|
|
332
|
+
spec = default.spec.to_json()
|
|
333
|
+
assert spec["data_copy_concurrency"] == {"limit": 1} and spec["file_io_concurrency"] == {
|
|
334
|
+
"limit": 8
|
|
335
|
+
}
|
|
336
|
+
assert spec["file_io_sync"] is False and spec["file_io_locking"] == {"mode": "none"}
|
|
313
337
|
four = wa.shared_context(7, threads=4).spec.to_json()
|
|
314
338
|
assert four["data_copy_concurrency"] == four["file_io_concurrency"] == {"limit": 4}
|
|
315
339
|
machine = wa.shared_context(7, threads=0)
|
|
@@ -318,8 +342,13 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
|
|
|
318
342
|
assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
|
|
319
343
|
assert spec["cache_pool"] == {"total_bytes_limit": 7}
|
|
320
344
|
assert machine in wa.context._watched
|
|
321
|
-
assert wa.context.recipe_of(machine) == ("shared", (7, 0))
|
|
322
|
-
assert wa.context.from_recipe(("shared", (7, 0))) is machine
|
|
345
|
+
assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none"))
|
|
346
|
+
assert wa.context.from_recipe(("shared", (7, 0))) is machine # a 0.6 or 0.7 pickle
|
|
347
|
+
durable = wa.shared_context(7, fsync=True, locking="os")
|
|
348
|
+
spec = durable.spec.to_json()
|
|
349
|
+
assert durable is not default and spec["file_io_sync"] is True
|
|
350
|
+
assert spec["file_io_locking"] == {} # "os", tensorstore's default, which its spec leaves out
|
|
351
|
+
assert wa.context.from_recipe(wa.context.recipe_of(durable)) is durable
|
|
323
352
|
assert wa.context.from_recipe(("shared", 7)) is default
|
|
324
353
|
with pytest.raises(ValueError):
|
|
325
354
|
wa.shared_context(threads=-1)
|
|
@@ -358,3 +387,40 @@ def test_a_server_that_never_answers_times_out_the_open(silent_server):
|
|
|
358
387
|
with pytest.raises(TimeoutError, match="127.0.0.1.*2 attempts"):
|
|
359
388
|
open_it(f"{silent_server}/vol.zarr/s0")
|
|
360
389
|
assert time.monotonic() - start < 10
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_an_http_open_probes_its_keys_at_once(tmp_path):
|
|
393
|
+
"""Five probe keys, one round trip: each answer here takes 0.3 s."""
|
|
394
|
+
write_zarr2(tmp_path / "vol.zarr" / "s0", pattern((4, 4, 4)))
|
|
395
|
+
|
|
396
|
+
class Slow(http.server.SimpleHTTPRequestHandler):
|
|
397
|
+
def do_GET(self):
|
|
398
|
+
time.sleep(0.3)
|
|
399
|
+
super().do_GET()
|
|
400
|
+
|
|
401
|
+
def log_message(self, *args):
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
handler = functools.partial(Slow, directory=str(tmp_path))
|
|
405
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
406
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
407
|
+
try:
|
|
408
|
+
start = time.monotonic()
|
|
409
|
+
node = wa.detect(f"http://127.0.0.1:{server.server_address[1]}/vol.zarr/s0")
|
|
410
|
+
assert node.format == "zarr2" and node.kind == "array"
|
|
411
|
+
assert time.monotonic() - start < 1.0 # in turn: 1.5 s
|
|
412
|
+
finally:
|
|
413
|
+
server.shutdown()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_capped_contexts_size_file_io_apart_and_write_without_sync_or_locks():
|
|
417
|
+
spec = wa.capped_context_spec(concurrency=1, network=False)
|
|
418
|
+
assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 1}
|
|
419
|
+
assert spec["file_io_sync"] is False and spec["file_io_locking"] == {"mode": "none"}
|
|
420
|
+
spec = wa.capped_context_spec(concurrency=1, file_io=8, fsync=True, locking="os")
|
|
421
|
+
assert spec["file_io_concurrency"] == {"limit": 8} and spec["data_copy_concurrency"] == {
|
|
422
|
+
"limit": 1
|
|
423
|
+
}
|
|
424
|
+
assert spec["file_io_sync"] is True and spec["file_io_locking"] == {"mode": "os"} # as written
|
|
425
|
+
capped = wa.capped_context(concurrency=1, file_io=8)
|
|
426
|
+
assert capped.spec.to_json()["file_io_concurrency"] == {"limit": 8}
|
|
@@ -849,3 +849,33 @@ def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
|
|
|
849
849
|
vol.write_index((0, 0, 0), (4, 4, 4), np.zeros((4, 4, 4), np.uint8))
|
|
850
850
|
assert vol.list_chunks() == [] # the one key a write may remove: its own, now all fill
|
|
851
851
|
assert int(vol.read_index((0, 0, 0), (4, 4, 4)).sum()) == 0
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
class _NeverAnswers:
|
|
855
|
+
def result(self, timeout=None):
|
|
856
|
+
raise TimeoutError()
|
|
857
|
+
|
|
858
|
+
def cancel(self):
|
|
859
|
+
pass
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def test_a_create_that_lands_after_its_timeout_adopts_its_own_array(tmp_path, monkeypatch):
|
|
863
|
+
real_open = ts.open
|
|
864
|
+
creates = []
|
|
865
|
+
|
|
866
|
+
def open_landing_late(spec, *args, **kwargs):
|
|
867
|
+
if kwargs.get("create"):
|
|
868
|
+
creates.append(1)
|
|
869
|
+
if len(creates) == 1: # the array is made, but the answer never comes
|
|
870
|
+
real_open(spec, *args, **kwargs).result()
|
|
871
|
+
return _NeverAnswers()
|
|
872
|
+
return real_open(spec, *args, **kwargs)
|
|
873
|
+
|
|
874
|
+
monkeypatch.setattr(ts, "open", open_landing_late)
|
|
875
|
+
path = str(tmp_path / "vol.zarr/s0")
|
|
876
|
+
quick = wa.Retry(timeout=0.2, max_attempts=3, base_delay=0.0)
|
|
877
|
+
vol = wa.create(path, (8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4), retries=quick)
|
|
878
|
+
assert len(creates) == 2 and vol.shape == (8, 8, 8)
|
|
879
|
+
monkeypatch.setattr(ts, "open", real_open)
|
|
880
|
+
with pytest.raises(FileExistsError):
|
|
881
|
+
wa.create(path, (8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
|
|
@@ -1247,7 +1247,7 @@ wheels = [
|
|
|
1247
1247
|
|
|
1248
1248
|
[[package]]
|
|
1249
1249
|
name = "worldarray"
|
|
1250
|
-
version = "0.
|
|
1250
|
+
version = "0.8.0"
|
|
1251
1251
|
source = { editable = "." }
|
|
1252
1252
|
dependencies = [
|
|
1253
1253
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
@@ -1270,7 +1270,7 @@ docs = [
|
|
|
1270
1270
|
[package.metadata]
|
|
1271
1271
|
requires-dist = [
|
|
1272
1272
|
{ name = "numpy", specifier = ">=1.26" },
|
|
1273
|
-
{ name = "tensorstore", specifier = ">=0.1.
|
|
1273
|
+
{ name = "tensorstore", specifier = ">=0.1.68" },
|
|
1274
1274
|
]
|
|
1275
1275
|
|
|
1276
1276
|
[package.metadata.requires-dev]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|