worldarray 0.7.1__tar.gz → 0.9.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.
Files changed (37) hide show
  1. {worldarray-0.7.1 → worldarray-0.9.0}/CHANGELOG.md +42 -0
  2. {worldarray-0.7.1 → worldarray-0.9.0}/PKG-INFO +14 -10
  3. {worldarray-0.7.1 → worldarray-0.9.0}/README.md +12 -8
  4. {worldarray-0.7.1 → worldarray-0.9.0}/docs/spec.md +75 -20
  5. {worldarray-0.7.1 → worldarray-0.9.0}/pyproject.toml +2 -2
  6. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/context.py +120 -31
  8. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/write.py +18 -2
  9. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_access.py +68 -4
  10. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_write.py +47 -0
  11. {worldarray-0.7.1 → worldarray-0.9.0}/uv.lock +2 -2
  12. {worldarray-0.7.1 → worldarray-0.9.0}/.github/workflows/ci.yml +0 -0
  13. {worldarray-0.7.1 → worldarray-0.9.0}/.github/workflows/docs.yml +0 -0
  14. {worldarray-0.7.1 → worldarray-0.9.0}/.github/workflows/publish.yml +0 -0
  15. {worldarray-0.7.1 → worldarray-0.9.0}/.gitignore +0 -0
  16. {worldarray-0.7.1 → worldarray-0.9.0}/LICENSE +0 -0
  17. {worldarray-0.7.1 → worldarray-0.9.0}/docs/changelog.md +0 -0
  18. {worldarray-0.7.1 → worldarray-0.9.0}/docs/index.md +0 -0
  19. {worldarray-0.7.1 → worldarray-0.9.0}/mkdocs.yml +0 -0
  20. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/array.py +0 -0
  21. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/geometry.py +0 -0
  22. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/grid.py +0 -0
  23. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/levels.py +0 -0
  24. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/placement.py +0 -0
  25. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/resample.py +0 -0
  26. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/retry.py +0 -0
  27. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/store.py +0 -0
  28. {worldarray-0.7.1 → worldarray-0.9.0}/src/worldarray/units.py +0 -0
  29. {worldarray-0.7.1 → worldarray-0.9.0}/tests/conftest.py +0 -0
  30. {worldarray-0.7.1 → worldarray-0.9.0}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.7.1 → worldarray-0.9.0}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_geometry.py +0 -0
  33. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_grid.py +0 -0
  34. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_metadata.py +0 -0
  36. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_read.py +0 -0
  37. {worldarray-0.7.1 → worldarray-0.9.0}/tests/test_resample.py +0 -0
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-10-23)
4
+
5
+ A job script can size each worker's threads. No placement change, no unit table change, and
6
+ no default changes: with `WORLDARRAY_THREADS` unset and no `file_io_floor`, every context
7
+ is what 0.8 made.
8
+
9
+ - `thread_budget()` reads `WORLDARRAY_THREADS` ahead of `OMP_NUM_THREADS` and the job's
10
+ slots, for a job script to give each dask worker its share of the job's cores where
11
+ `OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole job (for
12
+ example `max(1, ncpus // processes)`). A handle opened on the driver on the shared
13
+ context is sized in each worker by that worker's value. Anything but a positive integer
14
+ raises `ValueError`; blank is unset.
15
+ - `shared_context(file_io_floor=)` sets the least the file I/O pool takes under the thread
16
+ budget (default `FILE_IO_FLOOR`, 8); `1` keeps file I/O at the budget. A pickle carries
17
+ it, and with `threads=N` or `threads=0` it is ignored.
18
+
19
+ ## 0.8.0 (2026-10-23)
20
+
21
+ Faster writes from the contexts the package makes, and the create open timed out. No
22
+ placement change and no unit table change. **Requires tensorstore 0.1.68 or later** (was
23
+ 0.1.60), the first with the `file_io_locking` setting.
24
+
25
+ - `shared_context` and `capped_context` write without fsync (`file_io_sync: false`) and
26
+ without OS file locks (`file_io_locking` mode `"none"`); `fsync=True` and `locking="os"`
27
+ restore tensorstore's defaults. A whole-unit write stays atomic, a temporary file renamed
28
+ into place; only concurrent read-modify-writes of one unit (`partial=True`) lose their
29
+ guarantee, which was never given (Behaviour 13). fsync cost 6.5 ms a chunk on a local
30
+ disk and nothing on NFS; the locks cost about 10 ms a chunk on NFS.
31
+ - The default context's file I/O pool is `thread_budget()` or 8, whichever is larger;
32
+ decoding stays at `thread_budget()`. File I/O threads wait on storage rather than
33
+ compute, and one of them wrote a block's chunk files one NFS round trip after another.
34
+ `capped_context(file_io=)` sizes it apart from `concurrency` (default unchanged).
35
+ - From one-thread workers through the default context, against 0.7: a 64^3 label chunk to
36
+ NFS 23 to 12.2 ms (zarr-python 2: 13.1), an 8-chunk label block 159 to 66.5 ms
37
+ (zarr-python 2: 102), an 8-chunk EM block 330 to 202 ms; to a local disk a label block
38
+ 51.5 to 4.1 ms. Through `capped_context(1, network=False)` the NFS label block went 166
39
+ to 91 ms; `file_io=8` gives it the rest.
40
+ - The open that creates an array is timed out and retried under `retries` (Behaviour 6). A
41
+ retry that finds the array made by an attempt of its own that timed out adopts it after
42
+ the usual comparison, even under `exists="error"`. That open keeps OS locking, so
43
+ create-if-absent stays atomic.
44
+
3
45
  ## 0.7.1 (2026-10-23)
4
46
 
5
47
  Faster opens over http. No placement change, no unit table change, no API change.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.7.1
3
+ Version: 0.9.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.60
57
+ Requires-Dist: tensorstore>=0.1.68
58
58
  Description-Content-Type: text/markdown
59
59
 
60
60
  # worldarray
@@ -146,11 +146,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
146
146
  interpolation anchored where you say. Adjoining block reads agree with one read across
147
147
  both.
148
148
  - **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
149
- from `OMP_NUM_THREADS` or the LSF job's slots instead of the node's cores, or a capped one
150
- per worker; reads and writes retried with backoff and size-aware timeouts on transient
151
- store errors, a cancelled future on timeout, and every context's thread pools nudged once
152
- a second against a tensorstore bug that can leave a queued task with no thread, so a
153
- stuck operation recovers or fails loudly instead of hanging the job.
149
+ from `WORLDARRAY_THREADS` (a job script's share for each worker), `OMP_NUM_THREADS` or
150
+ the LSF job's slots instead of the node's cores, or a capped one per worker; reads and
151
+ writes retried with backoff and size-aware timeouts on transient store errors, a
152
+ cancelled future on timeout, and every context's thread pools nudged once a second
153
+ against a tensorstore bug that can leave a queued task with no thread, so a stuck
154
+ operation recovers or fails loudly instead of hanging the job.
154
155
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
155
156
  `snap` to a grid, and the voxel indices a world box selects.
156
157
  - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
@@ -209,9 +210,12 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
209
210
  wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
210
211
  wa.open_tensorstore(path, level=None, context=None, retries=None)
211
212
  # retries= times out and retries document reads and the open, as it does data reads
212
- wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
213
- wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
214
- # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
213
+ wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
214
+ wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
215
+ wa.thread_budget() # the shared context's decoding threads: WORLDARRAY_THREADS, OMP_NUM_THREADS,
216
+ # the job's slots, or CPUs; file I/O gets at least file_io_floor (8);
217
+ # threads=N limits both to N, threads=0 leaves tensorstore's sizes
218
+ # contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
215
219
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
216
220
  wa.Box ; wa.units.TABLE
217
221
 
@@ -87,11 +87,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
87
87
  interpolation anchored where you say. Adjoining block reads agree with one read across
88
88
  both.
89
89
  - **Behaves on a cluster**: one shared tensorstore context per process, its threads sized
90
- from `OMP_NUM_THREADS` or the LSF job's slots instead of the node's cores, or a capped one
91
- per worker; reads and writes retried with backoff and size-aware timeouts on transient
92
- store errors, a cancelled future on timeout, and every context's thread pools nudged once
93
- a second against a tensorstore bug that can leave a queued task with no thread, so a
94
- stuck operation recovers or fails loudly instead of hanging the job.
90
+ from `WORLDARRAY_THREADS` (a job script's share for each worker), `OMP_NUM_THREADS` or
91
+ the LSF job's slots instead of the node's cores, or a capped one per worker; reads and
92
+ writes retried with backoff and size-aware timeouts on transient store errors, a
93
+ cancelled future on timeout, and every context's thread pools nudged once a second
94
+ against a tensorstore bug that can leave a queued task with no thread, so a stuck
95
+ operation recovers or fails loudly instead of hanging the job.
95
96
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
96
97
  `snap` to a grid, and the voxel indices a world box selects.
97
98
  - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
@@ -150,9 +151,12 @@ wa.metadata(path, level=None, units=None, open=True, retries=None) -> ArrayMeta
150
151
  wa.detect(path, missing_ok=False, retries=None) -> Node # format, array or group, raw attrs
151
152
  wa.open_tensorstore(path, level=None, context=None, retries=None)
152
153
  # retries= times out and retries document reads and the open, as it does data reads
153
- wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
154
- wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
155
- # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
154
+ wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
155
+ wa.capped_context(cache_bytes=0, concurrency=1, network=True, file_io=None, fsync=False, locking="none")
156
+ wa.thread_budget() # the shared context's decoding threads: WORLDARRAY_THREADS, OMP_NUM_THREADS,
157
+ # the job's slots, or CPUs; file I/O gets at least file_io_floor (8);
158
+ # threads=N limits both to N, threads=0 leaves tensorstore's sizes
159
+ # contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
156
160
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
157
161
  wa.Box ; wa.units.TABLE
158
162
 
@@ -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>=0.1.60`,
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,12 @@ 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) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.capped_context_spec(...)
189
- # threads: None = thread_budget(), N = that many, 0 = tensorstore's own pool sizes (0.6)
188
+ wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8)
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 file_io_floor for file I/O (0.8; the
191
+ # argument 0.9), N = that many, 0 = tensorstore's own pool sizes (0.6); fsync and locking:
192
+ # tensorstore's file_io_sync and file_io_locking mode, off and "none" in every context the
193
+ # package makes (0.8, Behaviour 7)
190
194
  # network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
191
195
  wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
192
196
  # metadata document needed, so a directory of chunk files with no .zarray lists too
@@ -232,9 +236,9 @@ where it also chooses the integer scale factor that makes the corner whole.
232
236
 
233
237
  `shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
234
238
  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 and file I/O pools are sized by
236
- `thread_budget()` (0.5), or by `threads` when given, `0` leaving tensorstore's own sizes
237
- (0.6, Behaviour 7).
239
+ pool; the default budget is 0. Its decoding pool is sized by `thread_budget()` (0.5) and
240
+ its file I/O pool by that or `file_io_floor` (8), whichever is larger (0.8; the argument
241
+ 0.9), or both by `threads` when given, `0` leaving tensorstore's own sizes (0.6, Behaviour 7).
238
242
  `capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
239
243
  resource limited, cache off); it returns a **new** context per call, as those workers
240
244
  inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -363,8 +367,8 @@ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys
363
367
  vol.write_index(begin, end, data, partial=False, retries=None)
364
368
  vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
365
369
  # timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
366
- wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: OMP_NUM_THREADS, else
367
- # LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
370
+ wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: WORLDARRAY_THREADS (0.9),
371
+ # else OMP_NUM_THREADS, else LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
368
372
  # every context the package makes or opens a store on is kicked once a second, whoever
369
373
  # waits on it (Behaviour 6)
370
374
  ```
@@ -665,8 +669,13 @@ this document and that one differ, fix one of them.
665
669
  of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
666
670
  `detect` and `open_tensorstore`, which all take it; a document read later outside such
667
671
  a call (a zarr v2 `.zattrs` read on demand, a pyramid placing its levels) waits under
668
- `Retry()`. Listings are not timed out, their size being unknown, and neither is the
669
- open that creates an array, which a second attempt would find already made.
672
+ `Retry()`. Listings are not timed out, their size being unknown. The open that creates
673
+ an array is (0.8, cellmap-analyze's question): its second attempt may find the array its
674
+ first made before timing out, so after a timed-out attempt `ALREADY_EXISTS` is not an
675
+ error even under `exists="error"`, and `create` adopts the array only if it matches what
676
+ was asked for (the comparison an adopt makes), since an identical array that appeared
677
+ after our own attempt is ours. That open keeps tensorstore's `"os"` file locking whatever
678
+ the caller's context says, as create-if-absent is a conditional write.
670
679
 
671
680
  The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
672
681
  worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
@@ -687,8 +696,7 @@ this document and that one differ, fix one of them.
687
696
 
688
697
  7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
689
698
  tensorstore's cache is off and the levels of one pyramid share one context. Its
690
- `data_copy_concurrency` and `file_io_concurrency` limits are `thread_budget()`: the
691
- first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
699
+ `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
692
700
  sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
693
701
  integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
694
702
  in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
@@ -696,7 +704,25 @@ this document and that one differ, fix one of them.
696
704
  workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
697
705
  worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
698
706
  watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
699
- `LSB_DJOB_NUMPROC` elsewhere. The cap is the default because the process that passes
707
+ `LSB_DJOB_NUMPROC` elsewhere. Ahead of them all comes `WORLDARRAY_THREADS` (0.9), the
708
+ package's own variable, for a job script to give each worker its share of the job's
709
+ cores where `OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole
710
+ job: cellmap-analyze's prologue exports its CPUs over its worker processes, at least 1,
711
+ so a worker with spare cores uses them (in a two-slot job, pools of four against one
712
+ halved an 8-chunk block's time, below) and a worker per slot stays at one. A handle opened on
713
+ the driver before the cluster starts is then sized in each worker by that worker's
714
+ share, as a pickle carries `threads=None`, not a number (Behaviour 11). A value that is
715
+ not a positive integer raises `ValueError` rather than being skipped: nothing else sets
716
+ this variable, so a value worldarray cannot use is a mistake to report. Its
717
+ `file_io_concurrency` limit is that or `file_io_floor`, by default
718
+ `context.FILE_IO_FLOOR` (8), whichever is larger (0.8; the argument 0.9, `1` keeping
719
+ file I/O at the budget, cellmap-analyze's choice until eight is measured on /nrs at
720
+ scale): file I/O threads wait on storage
721
+ rather than compute, so they take no core from the job, and one of them serialises every
722
+ chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
723
+ chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
724
+ four to sixteen (zarr-python 2 took 102); an 8-chunk EM read from /nrs was the same
725
+ either way, decoding bound. The cap is the default because the process that passes
700
726
  no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
701
727
  job came to start a thousand threads on twelve slots. One thread per pool slows one
702
728
  task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
@@ -707,13 +733,35 @@ this document and that one differ, fix one of them.
707
733
  tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
708
734
  watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
709
735
  its server keeps one watched context per cache budget for every store it opens
710
- instead of building its own). Each `(cache_bytes, threads)` is one context for the
711
- process, made on first use and watched; `threads=None` and the number
712
- `thread_budget()` returns are two contexts, as a pickle carries `None` for the other
713
- process to size by its own job and a number as that number.
736
+ instead of building its own). Each set of arguments is one context for the process,
737
+ made on first use and watched; `threads=None` and the number `thread_budget()` returns
738
+ are two contexts, as a pickle carries `None` for the other process to size by its own
739
+ job and a number as that number. `file_io_floor` sizes only the budget's pool, so with
740
+ a thread count of its own it is ignored and no second context is made.
741
+
742
+ Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
743
+ sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
744
+ the arguments `fsync=True` and `locking="os"` restoring tensorstore's defaults. fsync
745
+ cost 6.5 ms a chunk on a local NVMe disk (8.2 against 1.5 ms for a 64^3 chunk) and
746
+ nothing on NFS, whose client commits a file when it closes it; zarr-python and funlib,
747
+ every writer the repos used before, never synced, so a machine crash on a local disk
748
+ can lose the last seconds of chunks as it always could, and a killed process loses
749
+ nothing. `"os"` locking takes an advisory lock on a `.__lock` file for every write,
750
+ about 10 ms of NFS round trips a chunk (21.7 against 12.0 ms for a 64^3 label chunk on
751
+ /groups; zarr-python 2 took 12.7). With `"none"` a whole-unit write is still atomic, a
752
+ temporary file renamed into place, and only concurrent read-modify-writes of one unit
753
+ lose their guarantee, which Behaviour 13 never gave (`partial=True` is unsafe while
754
+ anything else writes the unit); a write interrupted mid-way may leave a temporary
755
+ `.__lock` file that costs space only. tensorstore's `"lockfile"` mode is not the
756
+ default because such a file left by a killed worker blocks every later write of its
757
+ unit. Together, from one-thread workers (`OMP_NUM_THREADS=1`) through the default
758
+ context: a 64^3 label chunk to NFS took 23 ms in 0.7 and 12.2 in 0.8, an 8-chunk label
759
+ block 159 and 66.5, an 8-chunk EM block 330 and 202; to a local disk the label block
760
+ took 51.5 and 4.1 ms.
714
761
  `capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
715
762
  `gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
716
- `concurrency` and the cache to `cache_bytes`, and returns a new context each call, so two
763
+ `concurrency` (`file_io_concurrency` to `file_io` when given, 0.8) and the cache to
764
+ `cache_bytes`, and returns a new context each call, so two
717
765
  handles never share one capped pool unless the caller passes the same object twice; a
718
766
  `concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
719
767
  several chunks then handles them one at a time. On /nrs, from one process alternating
@@ -1035,7 +1083,14 @@ context's own pools; `thread_budget` from each variable, a bad value skipped, an
1035
1083
  shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
1036
1084
  context per pair, a negative count refused, and a pickled handle coming back on the same
1037
1085
  shared context. 0.7: `metadata`, `open` and `detect` against a server
1038
- that accepts and never answers raising `TimeoutError` after the attempts. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1086
+ that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
1087
+ and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
1088
+ through a pickle, and a create whose first attempt lands after its timeout adopting its own
1089
+ array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
1090
+ blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
1091
+ ignored with a thread count, and carried through a pickle; a handle pickled on the driver
1092
+ sized in a spawned worker by that worker's `WORLDARRAY_THREADS`.
1093
+ Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1039
1094
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1040
1095
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1041
1096
  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.7.1"
3
+ version = "0.9.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.60",
34
+ "tensorstore>=0.1.68",
35
35
  ]
36
36
 
37
37
  [project.urls]
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.7.1"
31
+ __version__ = "0.9.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -4,11 +4,14 @@
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 and file I/O pools are sized by ``thread_budget()``, from
8
- the variables a cluster job sets, where tensorstore alone would size them to every CPU on
9
- the node; ``threads=0`` leaves them at tensorstore's sizes (chunkmirage's server). Every
10
- context here, and every one a store is opened on, is watched for tensorstore's lost wakeup
11
- (``watch``).
7
+ itself. By default its decoding pool is sized by ``thread_budget()``, from the variables a
8
+ cluster job sets (``WORLDARRAY_THREADS`` first, a job script's share for each worker), where
9
+ tensorstore alone would size it to every CPU on the node, and its file I/O pool to at least
10
+ ``file_io_floor`` threads (``FILE_IO_FLOOR``), which wait on storage rather than compute;
11
+ ``threads=0`` leaves both at tensorstore's sizes (chunkmirage's server). Every
12
+ context made here writes without fsync and without OS file locks (``write_settings``).
13
+ Every context here, and every one a store is opened on, is watched for tensorstore's lost
14
+ wakeup (``watch``).
12
15
 
13
16
  ``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
14
17
  resource limited, so dozens of worker processes on one host do not multiply into thousands
@@ -28,7 +31,7 @@ from typing import Any
28
31
 
29
32
  import tensorstore as ts
30
33
 
31
- _shared: dict[tuple[int, int | None], ts.Context] = {}
34
+ _shared: dict[tuple[int, int | None, bool, str, int], ts.Context] = {}
32
35
  _rebuilt: dict[str, ts.Context] = {}
33
36
  _lock = threading.Lock()
34
37
 
@@ -117,14 +120,26 @@ if hasattr(os, "register_at_fork"):
117
120
  os.register_at_fork(after_in_child=_forget_watchdog)
118
121
 
119
122
 
123
+ THREADS_VAR = "WORLDARRAY_THREADS"
120
124
  _SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
121
125
 
122
126
 
123
127
  def thread_budget() -> int:
124
- """Threads for this process's tensorstore decoding and file I/O: ``OMP_NUM_THREADS``
128
+ """Threads for this process's tensorstore decoding and file I/O: ``WORLDARRAY_THREADS``
129
+ when set, a job script's share for each worker where ``OMP_NUM_THREADS`` must stay 1 for
130
+ numpy (a positive integer, anything else a ``ValueError``), else ``OMP_NUM_THREADS``
125
131
  when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
126
132
  job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
127
133
  ``NSLOTS``), else the CPUs this process may run on."""
134
+ own = os.environ.get(THREADS_VAR, "").strip()
135
+ if own:
136
+ try:
137
+ n = int(own)
138
+ except ValueError:
139
+ n = 0
140
+ if n < 1:
141
+ raise ValueError(f"{THREADS_VAR} must be a positive integer, not {own!r}")
142
+ return n
128
143
  for name in _SLOTS:
129
144
  try:
130
145
  n = int(os.environ.get(name, ""))
@@ -138,38 +153,91 @@ def thread_budget() -> int:
138
153
  return os.cpu_count() or 1
139
154
 
140
155
 
141
- def shared_context(cache_bytes: int = 0, *, threads: int | None = None) -> ts.Context:
142
- """The process's one context for ``cache_bytes`` and ``threads``, made on first use and
143
- watched. ``threads`` limits its decoding and file I/O pools: ``None`` to
144
- ``thread_budget()``, a positive number to that many, ``0`` not at all (tensorstore's own
145
- sizes, every CPU on the node)."""
146
- key = (int(cache_bytes), None if threads is None else int(threads))
147
- if key[1] is not None and key[1] < 0:
156
+ # File I/O threads mostly wait on storage, so they are not capped like decoding: on NFS,
157
+ # eight 64^3 label chunks written from one decoding thread took 166-176 ms with one file I/O
158
+ # thread and 83-89 ms with four to sixteen (2026-10-23; zarr-python 2 took 102)
159
+ FILE_IO_FLOOR = 8
160
+
161
+
162
+ def write_settings(fsync: bool = False, locking: str = "none") -> dict:
163
+ """tensorstore's ``file_io_sync`` and ``file_io_locking`` for a context made here. fsync
164
+ is off by default: zarr-python never synced, NFS commits a file on close anyway, and on
165
+ a local disk it cost 6 ms a chunk. Locking is ``"none"`` by default: a whole-unit write
166
+ stays atomic (a temporary file renamed into place), only concurrent read-modify-writes of
167
+ one unit lose the guarantee, which Behaviour 13 never gave, and tensorstore's ``"os"``
168
+ locks cost about 10 ms a chunk on NFS (spec, Behaviour 7)."""
169
+ return {"file_io_sync": bool(fsync), "file_io_locking": {"mode": str(locking)}}
170
+
171
+
172
+ def shared_context(
173
+ cache_bytes: int = 0,
174
+ *,
175
+ threads: int | None = None,
176
+ fsync: bool = False,
177
+ locking: str = "none",
178
+ file_io_floor: int = FILE_IO_FLOOR,
179
+ ) -> ts.Context:
180
+ """The process's one context for these arguments, made on first use and watched.
181
+ ``threads`` limits its decoding and file I/O pools: ``None`` decoding to
182
+ ``thread_budget()``, read when the context is made, and file I/O to that or
183
+ ``file_io_floor``, whichever is larger (``1`` keeps file I/O at the budget), a positive
184
+ number both to that many, ``0`` neither (tensorstore's own sizes, every CPU on the
185
+ node); ``file_io_floor`` applies to ``threads=None`` alone. ``fsync`` and ``locking`` go
186
+ to ``write_settings``."""
187
+ if threads is not None and int(threads) < 0:
148
188
  raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
189
+ if int(file_io_floor) < 0:
190
+ raise ValueError(f"file_io_floor must be 0 or positive, not {file_io_floor!r}")
191
+ floor = max(int(file_io_floor), 1) if threads is None else FILE_IO_FLOOR
192
+ key = (
193
+ int(cache_bytes),
194
+ None if threads is None else int(threads),
195
+ bool(fsync),
196
+ str(locking),
197
+ floor,
198
+ )
149
199
  with _lock:
150
200
  ctx = _shared.get(key)
151
201
  if ctx is None:
152
- spec: dict = {"cache_pool": {"total_bytes_limit": key[0]}}
153
- limit = thread_budget() if threads is None else key[1]
154
- if limit:
155
- spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": limit}
202
+ spec: dict = {
203
+ "cache_pool": {"total_bytes_limit": key[0]},
204
+ **write_settings(fsync, locking),
205
+ }
206
+ if threads is None:
207
+ budget = thread_budget()
208
+ spec["data_copy_concurrency"] = {"limit": budget}
209
+ spec["file_io_concurrency"] = {"limit": max(budget, floor)}
210
+ elif key[1]:
211
+ spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": key[1]}
156
212
  ctx = watch(ts.Context(spec))
157
213
  _shared[key] = ctx
158
214
  return ctx
159
215
 
160
216
 
161
- def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> dict:
217
+ def capped_context_spec(
218
+ cache_bytes: int = 0,
219
+ concurrency: int = 1,
220
+ network: bool = True,
221
+ *,
222
+ file_io: int | None = None,
223
+ fsync: bool = False,
224
+ locking: str = "none",
225
+ ) -> dict:
162
226
  """The spec dict behind ``capped_context``, for callers building their own opens.
163
227
  ``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
164
228
  pools alone (cellmap-analyze's choice for remote reads). A ``concurrency`` of 1 is safe:
165
229
  the context is watched (see ``KICK_EVERY``). It is not free: a read or write spanning
166
230
  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)."""
231
+ 8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7).
232
+ ``file_io`` sizes the file I/O pool apart from decoding (default ``concurrency``; on NFS
233
+ a few file I/O threads halve the time of a multi-chunk write, ``FILE_IO_FLOOR``).
234
+ ``fsync`` and ``locking`` go to ``write_settings``."""
168
235
  limit = {"limit": int(concurrency)}
169
236
  spec = {
170
237
  "data_copy_concurrency": limit,
171
- "file_io_concurrency": limit,
238
+ "file_io_concurrency": {"limit": int(file_io if file_io is not None else concurrency)},
172
239
  "cache_pool": {"total_bytes_limit": int(cache_bytes)},
240
+ **write_settings(fsync, locking),
173
241
  }
174
242
  if network:
175
243
  spec.update(
@@ -180,20 +248,35 @@ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: boo
180
248
  return spec
181
249
 
182
250
 
183
- def capped_context(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> ts.Context:
184
- return watch(ts.Context(capped_context_spec(cache_bytes, concurrency, network)))
251
+ def capped_context(
252
+ cache_bytes: int = 0,
253
+ concurrency: int = 1,
254
+ network: bool = True,
255
+ *,
256
+ file_io: int | None = None,
257
+ fsync: bool = False,
258
+ locking: str = "none",
259
+ ) -> ts.Context:
260
+ spec = capped_context_spec(
261
+ cache_bytes, concurrency, network, file_io=file_io, fsync=fsync, locking=locking
262
+ )
263
+ return watch(ts.Context(spec))
264
+
265
+
266
+ # shared_context's key with every argument at its default
267
+ _SHARED_DEFAULTS = (0, None, False, "none", FILE_IO_FLOOR)
185
268
 
186
269
 
187
270
  def recipe_of(context: ts.Context) -> tuple[str, Any]:
188
271
  """How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
189
- context at the default limit (the other process takes its own ``thread_budget()``),
190
- ``("shared", (cache_bytes, threads))`` for one with ``threads`` given, else
191
- ``("spec", json)`` from the context's own spec, so a capped context stays capped across
192
- a pickle (Behaviour 11, mesh-n-bone's workers)."""
272
+ context with every other argument at its default (the other process takes its own
273
+ ``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking, file_io_floor))``
274
+ otherwise, else ``("spec", json)`` from the context's own spec, so a capped context
275
+ stays capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
193
276
  with _lock:
194
- for (cache_bytes, threads), ctx in _shared.items():
277
+ for key, ctx in _shared.items():
195
278
  if ctx is context:
196
- return ("shared", cache_bytes if threads is None else (cache_bytes, threads))
279
+ return ("shared", key[0] if key[1:] == _SHARED_DEFAULTS[1:] else key)
197
280
  spec = context.spec
198
281
  return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
199
282
 
@@ -205,8 +288,14 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
205
288
  return None
206
289
  kind, value = recipe
207
290
  if kind == "shared":
208
- cache_bytes, threads = value if isinstance(value, (tuple, list)) else (value, None)
209
- return shared_context(int(cache_bytes), threads=threads)
291
+ given = tuple(value) if isinstance(value, (tuple, list)) else (value,)
292
+ cache_bytes, threads, fsync, locking, floor = (
293
+ *given,
294
+ *_SHARED_DEFAULTS[len(given) :],
295
+ )[:5] # a 0.6 or 0.7 pickle carries two, a 0.8 one four
296
+ return shared_context(
297
+ int(cache_bytes), threads=threads, fsync=fsync, locking=locking, file_io_floor=floor
298
+ )
210
299
  with _lock:
211
300
  ctx = _rebuilt.get(value)
212
301
  if ctx is None:
@@ -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
- return ts.open(spec, create=True, open=False, context=watch(context)).result()
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))
@@ -137,6 +137,7 @@ def test_contexts_and_retry_timeouts():
137
137
 
138
138
 
139
139
  def test_the_thread_budget_follows_the_job(monkeypatch):
140
+ monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
140
141
  monkeypatch.delenv("OMP_NUM_THREADS", raising=False)
141
142
  monkeypatch.delenv("LSB_DJOB_NUMPROC", raising=False)
142
143
  allowed = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
@@ -154,10 +155,26 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
154
155
  assert wa.thread_budget() == 6
155
156
  monkeypatch.setattr(wa.context, "_shared", {})
156
157
  spec = wa.shared_context(5).spec.to_json()
157
- assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 6}
158
+ assert spec["data_copy_concurrency"] == {"limit": 6}
159
+ assert spec["file_io_concurrency"] == {
160
+ "limit": wa.context.FILE_IO_FLOOR
161
+ } # it waits, not computes
158
162
  assert spec["cache_pool"] == {"total_bytes_limit": 5}
159
163
 
160
164
 
165
+ def test_a_job_script_gives_each_worker_its_threads(monkeypatch):
166
+ monkeypatch.setenv("OMP_NUM_THREADS", "1") # what dask and a job prologue set, for numpy
167
+ monkeypatch.setenv("LSB_DJOB_NUMPROC", "24") # the whole job's slots, not one worker's
168
+ monkeypatch.setenv("WORLDARRAY_THREADS", "3") # the job's cpus over its worker processes
169
+ assert wa.thread_budget() == 3
170
+ monkeypatch.setenv("WORLDARRAY_THREADS", " ")
171
+ assert wa.thread_budget() == 1 # empty is unset
172
+ for bad in ("0", "-2", "x", "2.5"): # worldarray's own variable: a bad value is an error
173
+ monkeypatch.setenv("WORLDARRAY_THREADS", bad)
174
+ with pytest.raises(ValueError, match="WORLDARRAY_THREADS"):
175
+ wa.thread_budget()
176
+
177
+
161
178
  def test_a_kick_runs_on_the_contexts_own_pools():
162
179
  ctx = wa.capped_context(concurrency=1)
163
180
  futures = wa.context.kick(ctx)
@@ -323,10 +340,15 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
323
340
 
324
341
 
325
342
  def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
343
+ monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
326
344
  monkeypatch.setenv("OMP_NUM_THREADS", "1")
327
345
  monkeypatch.setattr(wa.context, "_shared", {})
328
346
  default = wa.shared_context(7)
329
- assert default.spec.to_json()["data_copy_concurrency"] == {"limit": 1}
347
+ spec = default.spec.to_json()
348
+ assert spec["data_copy_concurrency"] == {"limit": 1} and spec["file_io_concurrency"] == {
349
+ "limit": 8
350
+ }
351
+ assert spec["file_io_sync"] is False and spec["file_io_locking"] == {"mode": "none"}
330
352
  four = wa.shared_context(7, threads=4).spec.to_json()
331
353
  assert four["data_copy_concurrency"] == four["file_io_concurrency"] == {"limit": 4}
332
354
  machine = wa.shared_context(7, threads=0)
@@ -335,13 +357,42 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
335
357
  assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
336
358
  assert spec["cache_pool"] == {"total_bytes_limit": 7}
337
359
  assert machine in wa.context._watched
338
- assert wa.context.recipe_of(machine) == ("shared", (7, 0))
339
- assert wa.context.from_recipe(("shared", (7, 0))) is machine
360
+ assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none", 8))
361
+ assert wa.context.from_recipe(("shared", (7, 0))) is machine # a 0.6 or 0.7 pickle
362
+ durable = wa.shared_context(7, fsync=True, locking="os")
363
+ spec = durable.spec.to_json()
364
+ assert durable is not default and spec["file_io_sync"] is True
365
+ assert spec["file_io_locking"] == {} # "os", tensorstore's default, which its spec leaves out
366
+ assert wa.context.from_recipe(wa.context.recipe_of(durable)) is durable
340
367
  assert wa.context.from_recipe(("shared", 7)) is default
341
368
  with pytest.raises(ValueError):
342
369
  wa.shared_context(threads=-1)
343
370
 
344
371
 
372
+ def test_file_io_can_follow_the_thread_budget(monkeypatch):
373
+ monkeypatch.setenv("WORLDARRAY_THREADS", "3")
374
+ monkeypatch.setattr(wa.context, "_shared", {})
375
+ default = wa.shared_context(0)
376
+ assert default.spec.to_json()["file_io_concurrency"] == {"limit": 8}
377
+ level = wa.shared_context(0, file_io_floor=1)
378
+ spec = level.spec.to_json()
379
+ assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 3}
380
+ assert level is not default and wa.shared_context(0, file_io_floor=0) is level
381
+ assert wa.shared_context(0, file_io_floor=wa.context.FILE_IO_FLOOR) is default
382
+ wide = wa.shared_context(0, file_io_floor=16).spec.to_json()
383
+ assert wide["data_copy_concurrency"] == {"limit": 3}
384
+ assert wide["file_io_concurrency"] == {"limit": 16}
385
+ # the floor sizes the budget's pools alone: a thread count of its own ignores it
386
+ assert wa.shared_context(0, threads=4, file_io_floor=1) is wa.shared_context(0, threads=4)
387
+ assert wa.shared_context(0, threads=0, file_io_floor=1) is wa.shared_context(0, threads=0)
388
+ assert wa.context.recipe_of(level) == ("shared", (0, None, False, "none", 1))
389
+ assert wa.context.from_recipe(wa.context.recipe_of(level)) is level
390
+ assert wa.context.recipe_of(default) == ("shared", 0)
391
+ assert wa.context.from_recipe(("shared", (0, None, False, "none"))) is default # 0.8
392
+ with pytest.raises(ValueError):
393
+ wa.shared_context(file_io_floor=-1)
394
+
395
+
345
396
  @pytest.fixture
346
397
  def silent_server():
347
398
  """An http address whose server accepts every connection and never answers."""
@@ -399,3 +450,16 @@ def test_an_http_open_probes_its_keys_at_once(tmp_path):
399
450
  assert time.monotonic() - start < 1.0 # in turn: 1.5 s
400
451
  finally:
401
452
  server.shutdown()
453
+
454
+
455
+ def test_capped_contexts_size_file_io_apart_and_write_without_sync_or_locks():
456
+ spec = wa.capped_context_spec(concurrency=1, network=False)
457
+ assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 1}
458
+ assert spec["file_io_sync"] is False and spec["file_io_locking"] == {"mode": "none"}
459
+ spec = wa.capped_context_spec(concurrency=1, file_io=8, fsync=True, locking="os")
460
+ assert spec["file_io_concurrency"] == {"limit": 8} and spec["data_copy_concurrency"] == {
461
+ "limit": 1
462
+ }
463
+ assert spec["file_io_sync"] is True and spec["file_io_locking"] == {"mode": "os"} # as written
464
+ capped = wa.capped_context(concurrency=1, file_io=8)
465
+ assert capped.spec.to_json()["file_io_concurrency"] == {"limit": 8}
@@ -830,6 +830,23 @@ def test_a_pickled_handle_keeps_its_context(tmp_path):
830
830
  assert pickle.loads(pickle.dumps(wa.open(path, context=machine))).context is machine
831
831
 
832
832
 
833
+ def _worker_limits(handle):
834
+ spec = handle.context.spec.to_json()
835
+ return spec["data_copy_concurrency"]["limit"], spec["file_io_concurrency"]["limit"]
836
+
837
+
838
+ def test_a_pickled_handle_takes_each_workers_thread_share(tmp_path, monkeypatch):
839
+ # a handle opened before the cluster starts is sized in each worker by that worker's job
840
+ # script, which exports WORLDARRAY_THREADS where OMP_NUM_THREADS stays 1 (cellmap-analyze)
841
+ path = str(tmp_path / "vol.zarr/s0")
842
+ wa.create(path, (8, 8, 8), "uint16", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4))
843
+ handles = [wa.open(path, context=wa.shared_context(0, file_io_floor=1)), wa.open(path)]
844
+ monkeypatch.setenv("OMP_NUM_THREADS", "1")
845
+ monkeypatch.setenv("WORLDARRAY_THREADS", "3")
846
+ with mp.get_context("spawn").Pool(1) as pool:
847
+ assert pool.map(_worker_limits, handles) == [(3, 3), (3, 8)]
848
+
849
+
833
850
  def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
834
851
  path = str(tmp_path / "vol.zarr/s0")
835
852
  vol = wa.create(
@@ -849,3 +866,33 @@ def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
849
866
  vol.write_index((0, 0, 0), (4, 4, 4), np.zeros((4, 4, 4), np.uint8))
850
867
  assert vol.list_chunks() == [] # the one key a write may remove: its own, now all fill
851
868
  assert int(vol.read_index((0, 0, 0), (4, 4, 4)).sum()) == 0
869
+
870
+
871
+ class _NeverAnswers:
872
+ def result(self, timeout=None):
873
+ raise TimeoutError()
874
+
875
+ def cancel(self):
876
+ pass
877
+
878
+
879
+ def test_a_create_that_lands_after_its_timeout_adopts_its_own_array(tmp_path, monkeypatch):
880
+ real_open = ts.open
881
+ creates = []
882
+
883
+ def open_landing_late(spec, *args, **kwargs):
884
+ if kwargs.get("create"):
885
+ creates.append(1)
886
+ if len(creates) == 1: # the array is made, but the answer never comes
887
+ real_open(spec, *args, **kwargs).result()
888
+ return _NeverAnswers()
889
+ return real_open(spec, *args, **kwargs)
890
+
891
+ monkeypatch.setattr(ts, "open", open_landing_late)
892
+ path = str(tmp_path / "vol.zarr/s0")
893
+ quick = wa.Retry(timeout=0.2, max_attempts=3, base_delay=0.0)
894
+ vol = wa.create(path, (8, 8, 8), "uint8", (4, 4, 4), (2, 2, 2), chunks=(4, 4, 4), retries=quick)
895
+ assert len(creates) == 2 and vol.shape == (8, 8, 8)
896
+ monkeypatch.setattr(ts, "open", real_open)
897
+ with pytest.raises(FileExistsError):
898
+ 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.7.1"
1250
+ version = "0.9.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.60" },
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