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.
- {worldarray-0.7.1 → worldarray-0.8.0}/CHANGELOG.md +26 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/PKG-INFO +7 -5
- {worldarray-0.7.1 → worldarray-0.8.0}/README.md +5 -3
- {worldarray-0.7.1 → worldarray-0.8.0}/docs/spec.md +51 -14
- {worldarray-0.7.1 → worldarray-0.8.0}/pyproject.toml +2 -2
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/__init__.py +1 -1
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/context.py +87 -29
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/write.py +18 -2
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_access.py +29 -4
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_write.py +30 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/uv.lock +2 -2
- {worldarray-0.7.1 → worldarray-0.8.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/.gitignore +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/LICENSE +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/docs/changelog.md +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/docs/index.md +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/mkdocs.yml +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/array.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/placement.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/retry.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/store.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/src/worldarray/units.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/conftest.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_geometry.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_grid.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_lost_wakeup.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_metadata.py +0 -0
- {worldarray-0.7.1 → worldarray-0.8.0}/tests/test_read.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
669
|
-
|
|
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`
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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:
|
|
@@ -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))
|
|
@@ -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"] ==
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|