worldarray 0.7.1__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.
Files changed (37) hide show
  1. {worldarray-0.7.1 → worldarray-0.8.0}/CHANGELOG.md +26 -0
  2. {worldarray-0.7.1 → worldarray-0.8.0}/PKG-INFO +7 -5
  3. {worldarray-0.7.1 → worldarray-0.8.0}/README.md +5 -3
  4. {worldarray-0.7.1 → worldarray-0.8.0}/docs/spec.md +51 -14
  5. {worldarray-0.7.1 → worldarray-0.8.0}/pyproject.toml +2 -2
  6. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/context.py +87 -29
  8. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/write.py +18 -2
  9. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_access.py +29 -4
  10. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_write.py +30 -0
  11. {worldarray-0.7.1 → worldarray-0.8.0}/uv.lock +2 -2
  12. {worldarray-0.7.1 → worldarray-0.8.0}/.github/workflows/ci.yml +0 -0
  13. {worldarray-0.7.1 → worldarray-0.8.0}/.github/workflows/docs.yml +0 -0
  14. {worldarray-0.7.1 → worldarray-0.8.0}/.github/workflows/publish.yml +0 -0
  15. {worldarray-0.7.1 → worldarray-0.8.0}/.gitignore +0 -0
  16. {worldarray-0.7.1 → worldarray-0.8.0}/LICENSE +0 -0
  17. {worldarray-0.7.1 → worldarray-0.8.0}/docs/changelog.md +0 -0
  18. {worldarray-0.7.1 → worldarray-0.8.0}/docs/index.md +0 -0
  19. {worldarray-0.7.1 → worldarray-0.8.0}/mkdocs.yml +0 -0
  20. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/array.py +0 -0
  21. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/geometry.py +0 -0
  22. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/grid.py +0 -0
  23. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/levels.py +0 -0
  24. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/placement.py +0 -0
  25. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/resample.py +0 -0
  26. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/retry.py +0 -0
  27. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/store.py +0 -0
  28. {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/units.py +0 -0
  29. {worldarray-0.7.1 → worldarray-0.8.0}/tests/conftest.py +0 -0
  30. {worldarray-0.7.1 → worldarray-0.8.0}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.7.1 → worldarray-0.8.0}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_geometry.py +0 -0
  33. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_grid.py +0 -0
  34. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_metadata.py +0 -0
  36. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_read.py +0 -0
  37. {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_resample.py +0 -0
@@ -1,5 +1,31 @@
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
+
3
29
  ## 0.7.1 (2026-10-23)
4
30
 
5
31
  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.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.60
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) ; 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
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) ; 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
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>=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,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) ; 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")
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 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).
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
@@ -665,8 +668,13 @@ this document and that one differ, fix one of them.
665
668
  of the public call doing the reading: `open`, `open_pyramid`, `create`, `metadata`,
666
669
  `detect` and `open_tensorstore`, which all take it; a document read later outside such
667
670
  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.
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.
670
678
 
671
679
  The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
672
680
  worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
@@ -687,8 +695,7 @@ this document and that one differ, fix one of them.
687
695
 
688
696
  7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
689
697
  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
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
692
699
  sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
693
700
  integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
694
701
  in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
@@ -696,7 +703,13 @@ this document and that one differ, fix one of them.
696
703
  workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
697
704
  worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
698
705
  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
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
700
713
  no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
701
714
  job came to start a thousand threads on twelve slots. One thread per pool slows one
702
715
  task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
@@ -711,9 +724,30 @@ this document and that one differ, fix one of them.
711
724
  process, made on first use and watched; `threads=None` and the number
712
725
  `thread_budget()` returns are two contexts, as a pickle carries `None` for the other
713
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.
714
747
  `capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
715
748
  `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
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
717
751
  handles never share one capped pool unless the caller passes the same object twice; a
718
752
  `concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
719
753
  several chunks then handles them one at a time. On /nrs, from one process alternating
@@ -1035,7 +1069,10 @@ context's own pools; `thread_budget` from each variable, a bad value skipped, an
1035
1069
  shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
1036
1070
  context per pair, a negative count refused, and a pickled handle coming back on the same
1037
1071
  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,
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,
1039
1076
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1040
1077
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1041
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.7.1"
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.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.8.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -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 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, 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
- 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))
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 = {"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}
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(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> dict:
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(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> ts.Context:
184
- return watch(ts.Context(capped_context_spec(cache_bytes, concurrency, network)))
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 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)."""
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 (cache_bytes, threads), ctx in _shared.items():
248
+ for key, ctx in _shared.items():
195
249
  if ctx is context:
196
- return ("shared", cache_bytes if threads is None else (cache_bytes, threads))
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 = value if isinstance(value, (tuple, list)) else (value, None)
209
- return shared_context(int(cache_bytes), threads=threads)
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:
@@ -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))
@@ -154,7 +154,10 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
154
154
  assert wa.thread_budget() == 6
155
155
  monkeypatch.setattr(wa.context, "_shared", {})
156
156
  spec = wa.shared_context(5).spec.to_json()
157
- assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 6}
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
158
161
  assert spec["cache_pool"] == {"total_bytes_limit": 5}
159
162
 
160
163
 
@@ -326,7 +329,11 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
326
329
  monkeypatch.setenv("OMP_NUM_THREADS", "1")
327
330
  monkeypatch.setattr(wa.context, "_shared", {})
328
331
  default = wa.shared_context(7)
329
- assert default.spec.to_json()["data_copy_concurrency"] == {"limit": 1}
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"}
330
337
  four = wa.shared_context(7, threads=4).spec.to_json()
331
338
  assert four["data_copy_concurrency"] == four["file_io_concurrency"] == {"limit": 4}
332
339
  machine = wa.shared_context(7, threads=0)
@@ -335,8 +342,13 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
335
342
  assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
336
343
  assert spec["cache_pool"] == {"total_bytes_limit": 7}
337
344
  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
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
340
352
  assert wa.context.from_recipe(("shared", 7)) is default
341
353
  with pytest.raises(ValueError):
342
354
  wa.shared_context(threads=-1)
@@ -399,3 +411,16 @@ def test_an_http_open_probes_its_keys_at_once(tmp_path):
399
411
  assert time.monotonic() - start < 1.0 # in turn: 1.5 s
400
412
  finally:
401
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.7.1"
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.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