worldarray 0.8.0__tar.gz → 0.9.1__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.8.0 → worldarray-0.9.1}/CHANGELOG.md +27 -0
  2. {worldarray-0.8.0 → worldarray-0.9.1}/PKG-INFO +11 -9
  3. {worldarray-0.8.0 → worldarray-0.9.1}/README.md +10 -8
  4. {worldarray-0.8.0 → worldarray-0.9.1}/docs/spec.md +43 -16
  5. {worldarray-0.8.0 → worldarray-0.9.1}/pyproject.toml +1 -1
  6. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/__init__.py +1 -1
  7. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/context.py +76 -26
  8. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_access.py +69 -1
  9. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_write.py +17 -0
  10. {worldarray-0.8.0 → worldarray-0.9.1}/uv.lock +1 -1
  11. {worldarray-0.8.0 → worldarray-0.9.1}/.github/workflows/ci.yml +0 -0
  12. {worldarray-0.8.0 → worldarray-0.9.1}/.github/workflows/docs.yml +0 -0
  13. {worldarray-0.8.0 → worldarray-0.9.1}/.github/workflows/publish.yml +0 -0
  14. {worldarray-0.8.0 → worldarray-0.9.1}/.gitignore +0 -0
  15. {worldarray-0.8.0 → worldarray-0.9.1}/LICENSE +0 -0
  16. {worldarray-0.8.0 → worldarray-0.9.1}/docs/changelog.md +0 -0
  17. {worldarray-0.8.0 → worldarray-0.9.1}/docs/index.md +0 -0
  18. {worldarray-0.8.0 → worldarray-0.9.1}/mkdocs.yml +0 -0
  19. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/array.py +0 -0
  20. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/geometry.py +0 -0
  21. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/grid.py +0 -0
  22. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/levels.py +0 -0
  23. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/placement.py +0 -0
  24. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/resample.py +0 -0
  25. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/retry.py +0 -0
  26. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/store.py +0 -0
  27. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/units.py +0 -0
  28. {worldarray-0.8.0 → worldarray-0.9.1}/src/worldarray/write.py +0 -0
  29. {worldarray-0.8.0 → worldarray-0.9.1}/tests/conftest.py +0 -0
  30. {worldarray-0.8.0 → worldarray-0.9.1}/tests/fixtures/metadata.json +0 -0
  31. {worldarray-0.8.0 → worldarray-0.9.1}/tests/fixtures/write.json +0 -0
  32. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_geometry.py +0 -0
  33. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_grid.py +0 -0
  34. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_lost_wakeup.py +0 -0
  35. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_metadata.py +0 -0
  36. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_read.py +0 -0
  37. {worldarray-0.8.0 → worldarray-0.9.1}/tests/test_resample.py +0 -0
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 (2026-10-26)
4
+
5
+ A fix for exit. No placement change, no unit table change, and no default changes.
6
+
7
+ - The watchdog thread stops at exit, before the interpreter finalizes. On Python up to
8
+ 3.13 a process could abort once all its work was done ("terminate called without an
9
+ active exception", exit 134) when finalization began while the thread was inside a
10
+ tensorstore call; an LSF job that did this looked failed. Now an `atexit` handler,
11
+ which runs once the program's other threads are joined, ends the thread and waits
12
+ for it, and no new one starts after that.
13
+
14
+ ## 0.9.0 (2026-10-23)
15
+
16
+ A job script can size each worker's threads. No placement change, no unit table change, and
17
+ no default changes: with `WORLDARRAY_THREADS` unset and no `file_io_floor`, every context
18
+ is what 0.8 made.
19
+
20
+ - `thread_budget()` reads `WORLDARRAY_THREADS` ahead of `OMP_NUM_THREADS` and the job's
21
+ slots, for a job script to give each dask worker its share of the job's cores where
22
+ `OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole job (for
23
+ example `max(1, ncpus // processes)`). A handle opened on the driver on the shared
24
+ context is sized in each worker by that worker's value. Anything but a positive integer
25
+ raises `ValueError`; blank is unset.
26
+ - `shared_context(file_io_floor=)` sets the least the file I/O pool takes under the thread
27
+ budget (default `FILE_IO_FLOOR`, 8); `1` keeps file I/O at the budget. A pickle carries
28
+ it, and with `threads=N` or `threads=0` it is ignored.
29
+
3
30
  ## 0.8.0 (2026-10-23)
4
31
 
5
32
  Faster writes from the contexts the package makes, and the create open timed out. No
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.8.0
3
+ Version: 0.9.1
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/
@@ -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,10 +210,11 @@ 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, fsync=False, locking="none") ; wa.Retry(...)
213
+ wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
213
214
  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
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
216
218
  # contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
217
219
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
218
220
  wa.Box ; wa.units.TABLE
@@ -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,10 +151,11 @@ 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, fsync=False, locking="none") ; wa.Retry(...)
154
+ wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8) ; wa.Retry(...)
154
155
  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
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
157
159
  # contexts made here write without fsync or OS file locks; fsync=True, locking="os" restore them
158
160
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
159
161
  wa.Box ; wa.units.TABLE
@@ -185,11 +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, fsync=False, locking="none")
188
+ wa.shared_context(cache_bytes=0, threads=None, fsync=False, locking="none", file_io_floor=8)
189
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
+ # 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)
193
194
  # network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
194
195
  wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
195
196
  # metadata document needed, so a directory of chunk files with no .zarray lists too
@@ -236,8 +237,8 @@ where it also chooses the integer scale factor that makes the corner whole.
236
237
  `shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
237
238
  thread limit for the process, so the levels of a pyramid and repeat opens share one cache
238
239
  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).
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).
241
242
  `capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
242
243
  resource limited, cache off); it returns a **new** context per call, as those workers
243
244
  inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -366,8 +367,8 @@ wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys
366
367
  vol.write_index(begin, end, data, partial=False, retries=None)
367
368
  vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
368
369
  # timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
369
- wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: OMP_NUM_THREADS, else
370
- # 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)
371
372
  # every context the package makes or opens a store on is kicked once a second, whoever
372
373
  # waits on it (Behaviour 6)
373
374
  ```
@@ -691,7 +692,15 @@ this document and that one differ, fix one of them.
691
692
  copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
692
693
  of CPU). A stranded task then runs within about a second whoever waits on it: the
693
694
  package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
694
- net. Open question 14 says when the watchdog goes.
695
+ net. The thread stops at exit (0.9.1): an `atexit` handler, which Python runs once the
696
+ program's other threads are joined and before it finalizes, ends it and waits for it
697
+ (at most the longest one kick takes), and no new one starts after that. Python up to
698
+ 3.13 ends a daemon thread that takes the GIL back during finalization with
699
+ `pthread_exit`, and inside a tensorstore call that aborts the process with "terminate
700
+ called without an active exception" and exit 134 after all the work is done (once in
701
+ six of mesh-n-bone's Linux CI runs on Python 3.12, 2026-10-26; every time when a
702
+ process exits while the thread kicks flat out). Open question 14 says when the
703
+ watchdog goes.
695
704
 
696
705
  7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
697
706
  tensorstore's cache is off and the levels of one pyramid share one context. Its
@@ -703,8 +712,20 @@ this document and that one differ, fix one of them.
703
712
  workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
704
713
  worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
705
714
  watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
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
715
+ `LSB_DJOB_NUMPROC` elsewhere. Ahead of them all comes `WORLDARRAY_THREADS` (0.9), the
716
+ package's own variable, for a job script to give each worker its share of the job's
717
+ cores where `OMP_NUM_THREADS` stays 1 for numpy and `LSB_DJOB_NUMPROC` counts the whole
718
+ job: cellmap-analyze's prologue exports its CPUs over its worker processes, at least 1,
719
+ so a worker with spare cores uses them (in a two-slot job, pools of four against one
720
+ halved an 8-chunk block's time, below) and a worker per slot stays at one. A handle opened on
721
+ the driver before the cluster starts is then sized in each worker by that worker's
722
+ share, as a pickle carries `threads=None`, not a number (Behaviour 11). A value that is
723
+ not a positive integer raises `ValueError` rather than being skipped: nothing else sets
724
+ this variable, so a value worldarray cannot use is a mistake to report. Its
725
+ `file_io_concurrency` limit is that or `file_io_floor`, by default
726
+ `context.FILE_IO_FLOOR` (8), whichever is larger (0.8; the argument 0.9, `1` keeping
727
+ file I/O at the budget, cellmap-analyze's choice until eight is measured on /nrs at
728
+ scale): file I/O threads wait on storage
708
729
  rather than compute, so they take no core from the job, and one of them serialises every
709
730
  chunk file of a block on NFS's round trips. From one decoding thread, eight 64^3 label
710
731
  chunks written to NFS took 166 to 176 ms with one file I/O thread and 83 to 89 ms with
@@ -720,10 +741,11 @@ this document and that one differ, fix one of them.
720
741
  tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
721
742
  watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
722
743
  its server keeps one watched context per cache budget for every store it opens
723
- instead of building its own). Each `(cache_bytes, threads)` is one context for the
724
- process, made on first use and watched; `threads=None` and the number
725
- `thread_budget()` returns are two contexts, as a pickle carries `None` for the other
726
- process to size by its own job and a number as that number.
744
+ instead of building its own). Each set of arguments is one context for the process,
745
+ made on first use and watched; `threads=None` and the number `thread_budget()` returns
746
+ are two contexts, as a pickle carries `None` for the other process to size by its own
747
+ job and a number as that number. `file_io_floor` sizes only the budget's pool, so with
748
+ a thread count of its own it is ignored and no second context is made.
727
749
 
728
750
  Writes (0.8). Every context the package makes (`shared_context`, `capped_context`)
729
751
  sets tensorstore's `file_io_sync` to false and its `file_io_locking` mode to `"none"`,
@@ -1072,7 +1094,12 @@ shared context. 0.7: `metadata`, `open` and `detect` against a server
1072
1094
  that accepts and never answers raising `TimeoutError` after the attempts. 0.8: the default
1073
1095
  and capped contexts' file I/O limit, sync and locking, `fsync` and `locking` carried
1074
1096
  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,
1097
+ array under `exists="error"`. 0.9: `WORLDARRAY_THREADS` ahead of every other variable,
1098
+ blank as unset and a bad value refused; `file_io_floor` sizing file I/O under the budget,
1099
+ ignored with a thread count, and carried through a pickle; a handle pickled on the driver
1100
+ sized in a spawned worker by that worker's `WORLDARRAY_THREADS`. 0.9.1: a process whose
1101
+ finalization takes a while, exiting while the watchdog kicks flat out, exits cleanly.
1102
+ Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1076
1103
  `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1077
1104
  worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1078
1105
  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.8.0"
3
+ version = "0.9.1"
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" }
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.8.0"
31
+ __version__ = "0.9.1"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -5,9 +5,10 @@ thread limit for the whole process, so the levels of a pyramid and repeat opens
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
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
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
11
12
  context made here writes without fsync and without OS file locks (``write_settings``).
12
13
  Every context here, and every one a store is opened on, is watched for tensorstore's lost
13
14
  wakeup (``watch``).
@@ -20,17 +21,17 @@ every open today; pass the same object twice to share pools (spec, Behaviour 7).
20
21
 
21
22
  from __future__ import annotations
22
23
 
24
+ import atexit
23
25
  import json
24
26
  import os
25
27
  import tempfile
26
28
  import threading
27
- import time
28
29
  import weakref
29
30
  from typing import Any
30
31
 
31
32
  import tensorstore as ts
32
33
 
33
- _shared: dict[tuple[int, int | None, bool, str], ts.Context] = {}
34
+ _shared: dict[tuple[int, int | None, bool, str, int], ts.Context] = {}
34
35
  _rebuilt: dict[str, ts.Context] = {}
35
36
  _lock = threading.Lock()
36
37
 
@@ -49,12 +50,19 @@ _lock = threading.Lock()
49
50
  # `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
50
51
  # for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
51
52
  # then strands nothing on a plain context.
53
+ #
54
+ # The thread stops at exit, before the interpreter finalizes. Python up to 3.13 ends a
55
+ # daemon thread that takes the GIL back during finalization with pthread_exit, and inside
56
+ # a tensorstore call that aborts the process: "terminate called without an active
57
+ # exception", exit 134, once all the work is done (mesh-n-bone's CI, 2026-10-26).
52
58
  KICK_EVERY = 1.0
59
+ KICK_TIMEOUT = 10.0
53
60
  _KICK_DIR = tempfile.gettempdir()
54
61
  _KICK_KEY = "worldarray-kick-no-such-key"
55
62
  _watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
56
63
  _watch_lock = threading.Lock()
57
64
  _watchdog: threading.Thread | None = None
65
+ _stop = threading.Event()
58
66
 
59
67
 
60
68
  def _kickers(context: ts.Context) -> tuple:
@@ -62,7 +70,7 @@ def _kickers(context: ts.Context) -> tuple:
62
70
  decoding pool, reading a missing key from the other runs on its file I/O pool."""
63
71
  array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
64
72
  kv = ts.KvStore.open({"driver": "file", "path": _KICK_DIR + "/"}, context=context)
65
- return array.result(timeout=10), kv.result(timeout=10)
73
+ return array.result(timeout=KICK_TIMEOUT), kv.result(timeout=KICK_TIMEOUT)
66
74
 
67
75
 
68
76
  def kick(context: ts.Context) -> list:
@@ -85,14 +93,15 @@ def kick(context: ts.Context) -> list:
85
93
 
86
94
  def watch(context: ts.Context | None) -> ts.Context | None:
87
95
  """Kick ``context``'s pools every ``KICK_EVERY`` seconds for as long as it lives, starting
88
- the watchdog thread on first use. Returns ``context``."""
96
+ the watchdog thread on first use (not once it has stopped at exit). Returns
97
+ ``context``."""
89
98
  global _watchdog
90
99
  if context is None:
91
100
  return None
92
101
  with _watch_lock:
93
102
  if context not in _watched:
94
103
  _watched[context] = None
95
- if _watchdog is None or not _watchdog.is_alive():
104
+ if not _stop.is_set() and (_watchdog is None or not _watchdog.is_alive()):
96
105
  _watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
97
106
  _watchdog.start()
98
107
  return context
@@ -100,16 +109,26 @@ def watch(context: ts.Context | None) -> ts.Context | None:
100
109
 
101
110
  def _watch() -> None:
102
111
  held: list = []
103
- while True:
104
- time.sleep(KICK_EVERY)
112
+ while not _stop.wait(KICK_EVERY):
105
113
  with _watch_lock:
106
114
  contexts = list(_watched.keys())
107
115
  held = [f for f in held if not f.done()]
108
116
  for context in contexts:
117
+ if _stop.is_set():
118
+ break
109
119
  held += kick(context)
110
120
  del contexts
111
121
 
112
122
 
123
+ def _stop_watchdog() -> None:
124
+ """End every watchdog thread and wait for it, at exit: atexit runs once the program's
125
+ other threads are joined, so they are kicked to the end, and before finalization."""
126
+ _stop.set()
127
+ for thread in threading.enumerate():
128
+ if thread.name == "worldarray-kick" and thread is not threading.current_thread():
129
+ thread.join(2 * KICK_TIMEOUT) # the longest one kick takes: two opens
130
+
131
+
113
132
  def _forget_watchdog() -> None:
114
133
  global _watchdog
115
134
  _watchdog = None # a forked child has no such thread; the next watch() starts one
@@ -117,16 +136,29 @@ def _forget_watchdog() -> None:
117
136
 
118
137
  if hasattr(os, "register_at_fork"):
119
138
  os.register_at_fork(after_in_child=_forget_watchdog)
139
+ atexit.register(_stop_watchdog)
120
140
 
121
141
 
142
+ THREADS_VAR = "WORLDARRAY_THREADS"
122
143
  _SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
123
144
 
124
145
 
125
146
  def thread_budget() -> int:
126
- """Threads for this process's tensorstore decoding and file I/O: ``OMP_NUM_THREADS``
147
+ """Threads for this process's tensorstore decoding and file I/O: ``WORLDARRAY_THREADS``
148
+ when set, a job script's share for each worker where ``OMP_NUM_THREADS`` must stay 1 for
149
+ numpy (a positive integer, anything else a ``ValueError``), else ``OMP_NUM_THREADS``
127
150
  when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
128
151
  job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
129
152
  ``NSLOTS``), else the CPUs this process may run on."""
153
+ own = os.environ.get(THREADS_VAR, "").strip()
154
+ if own:
155
+ try:
156
+ n = int(own)
157
+ except ValueError:
158
+ n = 0
159
+ if n < 1:
160
+ raise ValueError(f"{THREADS_VAR} must be a positive integer, not {own!r}")
161
+ return n
130
162
  for name in _SLOTS:
131
163
  try:
132
164
  n = int(os.environ.get(name, ""))
@@ -162,15 +194,27 @@ def shared_context(
162
194
  threads: int | None = None,
163
195
  fsync: bool = False,
164
196
  locking: str = "none",
197
+ file_io_floor: int = FILE_IO_FLOOR,
165
198
  ) -> ts.Context:
166
199
  """The process's one context for these arguments, made on first use and watched.
167
200
  ``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))
172
- if key[1] is not None and key[1] < 0:
201
+ ``thread_budget()``, read when the context is made, and file I/O to that or
202
+ ``file_io_floor``, whichever is larger (``1`` keeps file I/O at the budget), a positive
203
+ number both to that many, ``0`` neither (tensorstore's own sizes, every CPU on the
204
+ node); ``file_io_floor`` applies to ``threads=None`` alone. ``fsync`` and ``locking`` go
205
+ to ``write_settings``."""
206
+ if threads is not None and int(threads) < 0:
173
207
  raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
208
+ if int(file_io_floor) < 0:
209
+ raise ValueError(f"file_io_floor must be 0 or positive, not {file_io_floor!r}")
210
+ floor = max(int(file_io_floor), 1) if threads is None else FILE_IO_FLOOR
211
+ key = (
212
+ int(cache_bytes),
213
+ None if threads is None else int(threads),
214
+ bool(fsync),
215
+ str(locking),
216
+ floor,
217
+ )
174
218
  with _lock:
175
219
  ctx = _shared.get(key)
176
220
  if ctx is None:
@@ -181,7 +225,7 @@ def shared_context(
181
225
  if threads is None:
182
226
  budget = thread_budget()
183
227
  spec["data_copy_concurrency"] = {"limit": budget}
184
- spec["file_io_concurrency"] = {"limit": max(budget, FILE_IO_FLOOR)}
228
+ spec["file_io_concurrency"] = {"limit": max(budget, floor)}
185
229
  elif key[1]:
186
230
  spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": key[1]}
187
231
  ctx = watch(ts.Context(spec))
@@ -238,16 +282,20 @@ def capped_context(
238
282
  return watch(ts.Context(spec))
239
283
 
240
284
 
285
+ # shared_context's key with every argument at its default
286
+ _SHARED_DEFAULTS = (0, None, False, "none", FILE_IO_FLOOR)
287
+
288
+
241
289
  def recipe_of(context: ts.Context) -> tuple[str, Any]:
242
290
  """How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
243
291
  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)."""
292
+ ``thread_budget()``), ``("shared", (cache_bytes, threads, fsync, locking, file_io_floor))``
293
+ otherwise, else ``("spec", json)`` from the context's own spec, so a capped context
294
+ stays capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
247
295
  with _lock:
248
296
  for key, ctx in _shared.items():
249
297
  if ctx is context:
250
- return ("shared", key[0] if key[1:] == (None, False, "none") else key)
298
+ return ("shared", key[0] if key[1:] == _SHARED_DEFAULTS[1:] else key)
251
299
  spec = context.spec
252
300
  return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
253
301
 
@@ -259,12 +307,14 @@ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
259
307
  return None
260
308
  kind, value = recipe
261
309
  if kind == "shared":
262
- cache_bytes, threads, fsync, locking = (
263
- (*value, False, "none")[:4]
264
- if isinstance(value, (tuple, list))
265
- else (value, None, False, "none")
310
+ given = tuple(value) if isinstance(value, (tuple, list)) else (value,)
311
+ cache_bytes, threads, fsync, locking, floor = (
312
+ *given,
313
+ *_SHARED_DEFAULTS[len(given) :],
314
+ )[:5] # a 0.6 or 0.7 pickle carries two, a 0.8 one four
315
+ return shared_context(
316
+ int(cache_bytes), threads=threads, fsync=fsync, locking=locking, file_io_floor=floor
266
317
  )
267
- return shared_context(int(cache_bytes), threads=threads, fsync=fsync, locking=locking)
268
318
  with _lock:
269
319
  ctx = _rebuilt.get(value)
270
320
  if ctx is None:
@@ -8,6 +8,9 @@ import http.server
8
8
  import logging
9
9
  import os
10
10
  import socket
11
+ import subprocess
12
+ import sys
13
+ import textwrap
11
14
  import threading
12
15
  import time
13
16
  import weakref
@@ -137,6 +140,7 @@ def test_contexts_and_retry_timeouts():
137
140
 
138
141
 
139
142
  def test_the_thread_budget_follows_the_job(monkeypatch):
143
+ monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
140
144
  monkeypatch.delenv("OMP_NUM_THREADS", raising=False)
141
145
  monkeypatch.delenv("LSB_DJOB_NUMPROC", raising=False)
142
146
  allowed = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
@@ -161,6 +165,19 @@ def test_the_thread_budget_follows_the_job(monkeypatch):
161
165
  assert spec["cache_pool"] == {"total_bytes_limit": 5}
162
166
 
163
167
 
168
+ def test_a_job_script_gives_each_worker_its_threads(monkeypatch):
169
+ monkeypatch.setenv("OMP_NUM_THREADS", "1") # what dask and a job prologue set, for numpy
170
+ monkeypatch.setenv("LSB_DJOB_NUMPROC", "24") # the whole job's slots, not one worker's
171
+ monkeypatch.setenv("WORLDARRAY_THREADS", "3") # the job's cpus over its worker processes
172
+ assert wa.thread_budget() == 3
173
+ monkeypatch.setenv("WORLDARRAY_THREADS", " ")
174
+ assert wa.thread_budget() == 1 # empty is unset
175
+ for bad in ("0", "-2", "x", "2.5"): # worldarray's own variable: a bad value is an error
176
+ monkeypatch.setenv("WORLDARRAY_THREADS", bad)
177
+ with pytest.raises(ValueError, match="WORLDARRAY_THREADS"):
178
+ wa.thread_budget()
179
+
180
+
164
181
  def test_a_kick_runs_on_the_contexts_own_pools():
165
182
  ctx = wa.capped_context(concurrency=1)
166
183
  futures = wa.context.kick(ctx)
@@ -191,6 +208,32 @@ def test_every_context_the_package_touches_is_kicked(monkeypatch, tmp_path):
191
208
  assert len(wa.context._watched) == n - 1 # a context that died is no longer watched
192
209
 
193
210
 
211
+ def test_a_process_exits_cleanly_while_the_watchdog_kicks(tmp_path):
212
+ # Python up to 3.13 ends a daemon thread that takes the GIL back during finalization
213
+ # with pthread_exit, which aborts inside a tensorstore call; the watchdog stops at exit
214
+ script = tmp_path / "exit.py"
215
+ script.write_text(
216
+ textwrap.dedent("""
217
+ import time
218
+ import tensorstore as ts
219
+ from worldarray import context
220
+ context.KICK_EVERY = 0.0 # kick flat out
221
+ limits = [{"cache_pool": {"total_bytes_limit": i}} for i in range(20)]
222
+ contexts = [context.watch(ts.Context(spec)) for spec in limits]
223
+ class SlowExit: # finalization takes a while, with the GIL free
224
+ def __del__(self):
225
+ time.sleep(0.5)
226
+ slow = SlowExit()
227
+ time.sleep(0.2)
228
+ """)
229
+ )
230
+ done = subprocess.run(
231
+ [sys.executable, str(script)], capture_output=True, text=True, timeout=120
232
+ )
233
+ assert done.returncode == 0, done.stderr
234
+ assert "terminate" not in done.stderr
235
+
236
+
194
237
  class _StalledView:
195
238
  """A view whose first ``stalls`` reads never finish (a future nothing resolves, as a
196
239
  read stuck on NFS looks from Python); later reads return ``0, 1, 2, 3``."""
@@ -326,6 +369,7 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
326
369
 
327
370
 
328
371
  def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
372
+ monkeypatch.delenv("WORLDARRAY_THREADS", raising=False)
329
373
  monkeypatch.setenv("OMP_NUM_THREADS", "1")
330
374
  monkeypatch.setattr(wa.context, "_shared", {})
331
375
  default = wa.shared_context(7)
@@ -342,7 +386,7 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
342
386
  assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
343
387
  assert spec["cache_pool"] == {"total_bytes_limit": 7}
344
388
  assert machine in wa.context._watched
345
- assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none"))
389
+ assert wa.context.recipe_of(machine) == ("shared", (7, 0, False, "none", 8))
346
390
  assert wa.context.from_recipe(("shared", (7, 0))) is machine # a 0.6 or 0.7 pickle
347
391
  durable = wa.shared_context(7, fsync=True, locking="os")
348
392
  spec = durable.spec.to_json()
@@ -354,6 +398,30 @@ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
354
398
  wa.shared_context(threads=-1)
355
399
 
356
400
 
401
+ def test_file_io_can_follow_the_thread_budget(monkeypatch):
402
+ monkeypatch.setenv("WORLDARRAY_THREADS", "3")
403
+ monkeypatch.setattr(wa.context, "_shared", {})
404
+ default = wa.shared_context(0)
405
+ assert default.spec.to_json()["file_io_concurrency"] == {"limit": 8}
406
+ level = wa.shared_context(0, file_io_floor=1)
407
+ spec = level.spec.to_json()
408
+ assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 3}
409
+ assert level is not default and wa.shared_context(0, file_io_floor=0) is level
410
+ assert wa.shared_context(0, file_io_floor=wa.context.FILE_IO_FLOOR) is default
411
+ wide = wa.shared_context(0, file_io_floor=16).spec.to_json()
412
+ assert wide["data_copy_concurrency"] == {"limit": 3}
413
+ assert wide["file_io_concurrency"] == {"limit": 16}
414
+ # the floor sizes the budget's pools alone: a thread count of its own ignores it
415
+ assert wa.shared_context(0, threads=4, file_io_floor=1) is wa.shared_context(0, threads=4)
416
+ assert wa.shared_context(0, threads=0, file_io_floor=1) is wa.shared_context(0, threads=0)
417
+ assert wa.context.recipe_of(level) == ("shared", (0, None, False, "none", 1))
418
+ assert wa.context.from_recipe(wa.context.recipe_of(level)) is level
419
+ assert wa.context.recipe_of(default) == ("shared", 0)
420
+ assert wa.context.from_recipe(("shared", (0, None, False, "none"))) is default # 0.8
421
+ with pytest.raises(ValueError):
422
+ wa.shared_context(file_io_floor=-1)
423
+
424
+
357
425
  @pytest.fixture
358
426
  def silent_server():
359
427
  """An http address whose server accepts every connection and never answers."""
@@ -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(
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.8.0"
1250
+ version = "0.9.1"
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'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes