worldarray 0.4.0__tar.gz → 0.5.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 (38) hide show
  1. {worldarray-0.4.0 → worldarray-0.5.0}/CHANGELOG.md +40 -0
  2. {worldarray-0.4.0 → worldarray-0.5.0}/PKG-INFO +10 -5
  3. {worldarray-0.4.0 → worldarray-0.5.0}/README.md +9 -4
  4. {worldarray-0.4.0 → worldarray-0.5.0}/docs/spec.md +85 -11
  5. {worldarray-0.4.0 → worldarray-0.5.0}/pyproject.toml +1 -1
  6. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/__init__.py +3 -2
  7. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/array.py +24 -8
  8. worldarray-0.5.0/src/worldarray/context.py +205 -0
  9. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/retry.py +44 -13
  10. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/store.py +3 -1
  11. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/write.py +2 -1
  12. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_access.py +94 -1
  13. worldarray-0.5.0/tests/test_lost_wakeup.py +76 -0
  14. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_write.py +43 -0
  15. {worldarray-0.4.0 → worldarray-0.5.0}/uv.lock +1 -1
  16. worldarray-0.4.0/src/worldarray/context.py +0 -84
  17. {worldarray-0.4.0 → worldarray-0.5.0}/.github/workflows/ci.yml +0 -0
  18. {worldarray-0.4.0 → worldarray-0.5.0}/.github/workflows/docs.yml +0 -0
  19. {worldarray-0.4.0 → worldarray-0.5.0}/.github/workflows/publish.yml +0 -0
  20. {worldarray-0.4.0 → worldarray-0.5.0}/.gitignore +0 -0
  21. {worldarray-0.4.0 → worldarray-0.5.0}/LICENSE +0 -0
  22. {worldarray-0.4.0 → worldarray-0.5.0}/docs/changelog.md +0 -0
  23. {worldarray-0.4.0 → worldarray-0.5.0}/docs/index.md +0 -0
  24. {worldarray-0.4.0 → worldarray-0.5.0}/mkdocs.yml +0 -0
  25. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/geometry.py +0 -0
  26. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/grid.py +0 -0
  27. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/levels.py +0 -0
  28. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/placement.py +0 -0
  29. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/resample.py +0 -0
  30. {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/units.py +0 -0
  31. {worldarray-0.4.0 → worldarray-0.5.0}/tests/conftest.py +0 -0
  32. {worldarray-0.4.0 → worldarray-0.5.0}/tests/fixtures/metadata.json +0 -0
  33. {worldarray-0.4.0 → worldarray-0.5.0}/tests/fixtures/write.json +0 -0
  34. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_geometry.py +0 -0
  35. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_grid.py +0 -0
  36. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_metadata.py +0 -0
  37. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_read.py +0 -0
  38. {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_resample.py +0 -0
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-10-23)
4
+
5
+ Two bugs that hung or crashed cellmap-analyze 0.6 on /nrs (salamander's and c-elegans'
6
+ runs, 2026-10-22), and tensorstore's threads sized to the job. No placement change and no
7
+ unit table change.
8
+
9
+ - A read whose first attempt timed out raised `IndexError` instead of retrying: the
10
+ warning took the first line of the error's message, and tensorstore's `TimeoutError()`
11
+ has none. The warning now names the error's type when the message is empty, and the
12
+ error after the last attempt names the operation, the path and the attempts.
13
+ - Every context the package makes or opens a store on is watched: once a second a daemon
14
+ thread gives its decoding and file I/O pools one no-op task each (an in-memory copy and
15
+ a local read of a missing key; no store is touched). tensorstore up to at least 0.1.85
16
+ can strand a queued task: a pool worker idle for 20 ms exits as the task arrives, and
17
+ the task never runs (fixed upstream in google/tensorstore ed51718085, in no release
18
+ yet). With `data_copy` and `file_io` capped at 1, as cellmap-analyze's workers were,
19
+ dozens of workers per run waited forever in `write_index` with no tensorstore thread
20
+ alive, and reads timed out into the `IndexError` above. One more task on the same pool
21
+ wakes it, which salamander's and c-elegans' sessions proved on live stuck workers, so a
22
+ stranded task now runs within about a second, at any concurrency, whoever waits on it:
23
+ worldarray, `vol.ts` directly or `await`. Reproduced on purpose on tensorstore 0.1.75,
24
+ 0.1.78 and 0.1.85 (opt-in `tests/test_lost_wakeup.py`): cellmap-analyze's block loop
25
+ gave 29 `IndexError`s and 55 writes stuck past 20 s on 0.4.0, none on 0.5.0, slowest
26
+ 0.98 s. Spec open question 14 says when this goes.
27
+ - `write_index`, `write` and `write_chunk` are timed out and retried as reads are
28
+ (Behaviour 6), by a new `retries=` argument or the handle's `retries`. A write used to
29
+ wait on its future with no timeout at all.
30
+ - Without `context`, the shared context's decoding and file I/O pools are limited to the
31
+ new `wa.thread_budget()`: `OMP_NUM_THREADS` when set (dask sets 1 in each worker), else
32
+ the job's slots (`LSB_DJOB_NUMPROC`, `SLURM_CPUS_PER_TASK`, `NSLOTS`), else the CPUs the
33
+ process may run on. A server that wants more threads than its job's slots asks for more
34
+ slots or passes its own context.
35
+ tensorstore alone sizes both to every CPU on the node, whatever `bsub -n` asked for, so
36
+ a dask worker's tensorstore now takes the same one thread as its numpy (pinned to one
37
+ CPU, tensorstore's default context started 43 to 45 pool threads, the capped one 4 to 5).
38
+ Against cellmap-analyze 0.5.0's own block I/O on NFS (128^3 uint64 label blocks, each
39
+ one chunk), its 0.6.1 on this release reads a block in 26 ms instead of 40 and writes it
40
+ in 45 ms instead of 62 from one process at one thread per pool, and about 1.15 times
41
+ faster from 8. Speed gains grow with blocks of several chunks and more threads.
42
+
3
43
  ## 0.4.0 (2026-10-22)
4
44
 
5
45
  cellmap-analyze's three asks, so it can call worldarray directly with no wrapper, and two
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: worldarray
3
- Version: 0.4.0
3
+ Version: 0.5.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/
@@ -145,9 +145,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
145
145
  `transform` applied before padding (or `post_transform` after resampling), and resampling by nearest neighbour or linear
146
146
  interpolation anchored where you say. Adjoining block reads agree with one read across
147
147
  both.
148
- - **Behaves on a cluster**: one shared tensorstore context per process or a capped one per
149
- worker, retries with backoff and size-aware timeouts on transient store errors, and a
150
- cancelled future on timeout.
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.
151
154
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
152
155
  `snap` to a grid, and the voxel indices a world box selects.
153
156
  - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
@@ -206,6 +209,7 @@ wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False
206
209
  wa.detect(path, missing_ok=False) -> Node # format, array or group, raw attrs
207
210
  wa.open_tensorstore(path, level=None, context=None)
208
211
  wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
212
+ wa.thread_budget() # the shared context's thread limit: OMP_NUM_THREADS, the job's slots, or CPUs
209
213
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
210
214
  wa.Box ; wa.units.TABLE
211
215
 
@@ -233,7 +237,8 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
233
237
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
234
238
  `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
235
239
  `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
236
- `write_index(begin, end, data, partial=)` and `write(box, data, partial=)` (`data` an array or a scalar), `reopen(context=)`.
240
+ `write_index(begin, end, data, partial=, retries=)` and `write(box, data, partial=, retries=)` (`data` an array or a
241
+ scalar), `reopen(context=)`.
237
242
 
238
243
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
239
244
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -86,9 +86,12 @@ s1 = wa.open_pyramid("out.zarr", mode="r+").add_level("s1", factors=2) # place
86
86
  `transform` applied before padding (or `post_transform` after resampling), and resampling by nearest neighbour or linear
87
87
  interpolation anchored where you say. Adjoining block reads agree with one read across
88
88
  both.
89
- - **Behaves on a cluster**: one shared tensorstore context per process or a capped one per
90
- worker, retries with backoff and size-aware timeouts on transient store errors, and a
91
- cancelled future on timeout.
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.
92
95
  - **Carries the geometry**: `Box` with `intersect`, `union`, `contains`, `grow`, `shift`,
93
96
  `snap` to a grid, and the voxel indices a world box selects.
94
97
  - **Writes placed arrays** (0.2): the OME-NGFF 0.4 or 0.5 document from voxel size, centre
@@ -147,6 +150,7 @@ wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False
147
150
  wa.detect(path, missing_ok=False) -> Node # format, array or group, raw attrs
148
151
  wa.open_tensorstore(path, level=None, context=None)
149
152
  wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
153
+ wa.thread_budget() # the shared context's thread limit: OMP_NUM_THREADS, the job's slots, or CPUs
150
154
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
151
155
  wa.Box ; wa.units.TABLE
152
156
 
@@ -174,7 +178,8 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
174
178
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
175
179
  `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
176
180
  `chunks_covering(begin, end, write=)`, `list_chunks()`, `chunk_key(index)`, `parse_chunk_key(key)`,
177
- `write_index(begin, end, data, partial=)` and `write(box, data, partial=)` (`data` an array or a scalar), `reopen(context=)`.
181
+ `write_index(begin, end, data, partial=, retries=)` and `write(box, data, partial=, retries=)` (`data` an array or a
182
+ scalar), `reopen(context=)`.
178
183
 
179
184
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
180
185
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -230,7 +230,8 @@ in integer Rois builds them from `corner`, `voxel_size` and `shape` in its own s
230
230
  where it also chooses the integer scale factor that makes the corner whole.
231
231
 
232
232
  `shared_context(cache_bytes)` is one `tensorstore.Context` per budget for the process, so
233
- the levels of a pyramid and repeat opens share one cache pool; the default budget is 0.
233
+ the levels of a pyramid and repeat opens share one cache pool; the default budget is 0. Its
234
+ decoding and file I/O pools are sized by `thread_budget()` (0.5, Behaviour 7).
234
235
  `capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
235
236
  resource limited, cache off); it returns a **new** context per call, as those workers
236
237
  inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -353,6 +354,18 @@ vol.source_translation # the stored level's translation in `units`, beside so
353
354
  wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys of `attrs` (Behaviour 11)
354
355
  ```
355
356
 
357
+ ### 0.5: writes that cannot hang
358
+
359
+ ```python
360
+ vol.write_index(begin, end, data, partial=False, retries=None)
361
+ vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
362
+ # timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
363
+ wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: OMP_NUM_THREADS, else
364
+ # LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
365
+ # every context the package makes or opens a store on is kicked once a second, whoever
366
+ # waits on it (Behaviour 6)
367
+ ```
368
+
356
369
  ## 3. Conventions
357
370
 
358
371
  These are the rules the package exists to hold in one place. They match chunkmirage's
@@ -620,25 +633,64 @@ this document and that one differ, fix one of them.
620
633
  `on_mismatch="relabel"` and picks `method` with its own `is_label_dtype` rule;
621
634
  cellmap-analyze passes `method` from its `interpolation_order`.
622
635
 
623
- 6. **Retries** apply to data reads only, with mesh-n-bone's formula, which its tests
624
- already pin: attempt `k` (from 1) waits `timeout * k` seconds on `.result()`, where
636
+ 6. **Retries** apply to data reads and data writes, with mesh-n-bone's formula, which its
637
+ tests already pin: attempt `k` (from 1) waits `timeout * k` seconds on `.result()`, where
625
638
  `timeout` is the given value or, when `None`, `clamp(MiB / 16, 5, 120)` for a remote
626
639
  store (`gs://`, `s3://`, `http(s)://`) and `clamp(MiB / 256, 5, 30)` for a local one
627
640
  (`file`, `memory`), `MiB` being the size of the in-bounds part of the read after
628
- clipping; between attempts the sleep is `base_delay * 1.3 ** (k - 1)` plus `U(0, 1)`
641
+ clipping, or of the box written; between attempts the sleep is `base_delay * 1.3 ** (k - 1)` plus `U(0, 1)`
629
642
  seconds; at most `max_attempts` (default 10). Retried: `TimeoutError`, and a tensorstore
630
643
  error whose message starts with `UNAVAILABLE`, `DEADLINE_EXCEEDED` or
631
644
  `RESOURCE_EXHAUSTED` or carries an HTTP 5xx or 429. Any other error is raised at once. A
632
- timed-out tensorstore future is cancelled before the next attempt starts. The policy is a
633
- per-handle attribute and a per-read argument, never only constructor state.
645
+ timed-out tensorstore future is cancelled before the next attempt starts; a write may
646
+ still land after its cancel, with the same bytes the next attempt writes. Each retry is
647
+ logged as a warning naming the error's first line, or its type when the message is
648
+ empty (tensorstore's timeout is `TimeoutError()`); when every attempt times out the
649
+ `TimeoutError` names the operation, the path and the attempts. Metadata documents are not
650
+ retried this way (Convention 5). The policy is a per-handle attribute and a per-read and
651
+ per-write argument, never only constructor state.
652
+
653
+ The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
654
+ worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
655
+ counts itself full (fixed upstream in google/tensorstore ed51718085, in no release yet).
656
+ The future then waits forever with no tensorstore thread alive, on a read, a write or an
657
+ open. A pool limited to one thread meets it most; cellmap-analyze 0.6 hung on /nrs with
658
+ `data_copy` and `file_io` at 1, and one more task on the same context woke the pool
659
+ within seconds (salamander's and c-elegans' live tests, 2026-10-22, with un-kicked
660
+ workers staying stuck). cellmap-analyze's read retries of June 2025 ("it sometimes
661
+ hangs") worked for the same reason: each attempt is a new task. So every context the
662
+ package makes (`shared_context`, `capped_context`, a rebuilt one) or opens a store on is
663
+ watched, weakly: one daemon thread, started on first use, gives each live watched
664
+ context's decoding and file I/O pools one no-op task every second (an in-memory array
665
+ copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
666
+ of CPU). A stranded task then runs within about a second whoever waits on it: the
667
+ package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
668
+ net. Open question 14 says when the watchdog goes.
634
669
 
635
670
  7. **Contexts.** Without `context`, every open in the process shares `shared_context(0)`:
636
- tensorstore's cache is off and the levels of one pyramid share one context.
671
+ tensorstore's cache is off and the levels of one pyramid share one context. Its
672
+ `data_copy_concurrency` and `file_io_concurrency` limits are `thread_budget()`: the
673
+ first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
674
+ sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
675
+ integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
676
+ in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
677
+ both pools to every CPU of the node, whatever the job's slots, so each of twelve dask
678
+ workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
679
+ worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
680
+ watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
681
+ `LSB_DJOB_NUMPROC` elsewhere. The cap is the default because the process that passes
682
+ no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
683
+ job came to start a thousand threads on twelve slots. One thread per pool slows one
684
+ task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
685
+ workers, whose throughput its cores bound either way (four writers: 1.8 s against
686
+ 1.9 s). A process that wants more threads than its job has slots, a server under
687
+ `bsub -n 1` say, asks for more slots or passes its own context (decided 2026-10-23,
688
+ over chunkmirage's objection, once the watchdog had removed the hang risk).
637
689
  `capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
638
690
  `gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
639
691
  `concurrency` and the cache to `cache_bytes`, and returns a new context each call, so two
640
- handles never share one capped pool unless the caller passes the same object twice. Both
641
- are plain tensorstore contexts; a caller may pass its own, at construction or through
692
+ handles never share one capped pool unless the caller passes the same object twice; a
693
+ `concurrency` of 1 is safe under Behaviour 6's watchdog. Both are plain tensorstore contexts; a caller may pass its own, at construction or through
642
694
  `reopen(context=)` when a long-lived worker changes its concurrency (cellmap-analyze
643
695
  reopens tensorstore on every read today for that reason).
644
696
 
@@ -755,7 +807,8 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
755
807
  construction. `partial=True` lifts the check and does tensorstore's read-modify-write
756
808
  of the touched units, documented as unsafe while anything else writes the same unit
757
809
  (cellmap-flow's paint slabs, which it serialises itself). A write commits before the
758
- call returns and is not retried by the package; `retries` applies to reads. `write(box,
810
+ call returns, timed out and retried by Behaviour 6 (0.5; before, it was not, and a
811
+ stranded write waited forever). `write(box,
759
812
  data)` maps a world box by Behaviour 2 and then calls `write_index`; a box reaching
760
813
  outside the array raises (nothing is padded on write) and `voxel_size` is not an
761
814
  argument (nothing is resampled on write).
@@ -942,7 +995,18 @@ and the error after the attempts; `ensure_group_entry` on a foreign array, idemp
942
995
  pickled handle keeping a capped context's spec and sharing the shared one, and a relative
943
996
  path reopening after a `chdir`; `codecs_of` by path; an all-fill write removing its own key;
944
997
  a scalar filling a box through `write_index`, `write` and `write_chunk`, refused when it
945
- does not fit the dtype.
998
+ does not fit the dtype. 0.5: a read and a write whose first attempts never finish (a future
999
+ nothing resolves) cancelled and retried, the warning naming `TimeoutError`, and the error
1000
+ after the last attempt; a capped context and a caller's context passed to an open both
1001
+ kicked by the watchdog, a dead context no longer watched, and a kick landing on a capped
1002
+ context's own pools; `thread_budget` from each variable, a bad value skipped, and the
1003
+ shared context's limits. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1004
+ `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1005
+ worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1006
+ tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
1007
+ and a watched one strands none, its slowest task under a second. Run against cellmap-analyze's
1008
+ block loop (a chunk read, then a whole-chunk `write_index`, on its 0.6.1 context), 0.4.0
1009
+ gave 29 `IndexError`s and 55 writes stuck past 20 s, and 0.5.0 none, slowest 0.98 s.
946
1010
 
947
1011
  Grids (0.3): `blocks` partitioning with and without a box, `context` as an int, per axis
948
1012
  and as before/after pairs, clipping on and off, `len`, `blocks[id]` and `index_of`;
@@ -1126,6 +1190,16 @@ over funlib geometry, by this spec's own assignment.
1126
1190
  boxes on one grid. `Grid.blocks` could carry world boxes too (`b.box`, `b.read_box` as
1127
1191
  `Box`, a context in world units). Two lines on cellmap-flow's side today. Not planned
1128
1192
  (2026-10-22): daisy does cellmap-flow's tiling and stays; reopen if cellmap-flow drops it.
1193
+ 14. **When the watchdog goes.** Behaviour 6's watchdog works around tensorstore's lost
1194
+ wakeup, fixed upstream in google/tensorstore ed51718085 (2026-09-30, "Fix lost wakeups
1195
+ in TaskGroup when workers block or exit") and in no release up to 0.1.85. When a release
1196
+ contains it (`gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` reports
1197
+ "ahead" or "identical"), confirm it with `WORLDARRAY_STRESS=1 pytest
1198
+ tests/test_lost_wakeup.py -s` (a plain context should strand nothing), require that
1199
+ release, remove `watch`, `kick` and the watchdog thread from `context.py`, and keep the
1200
+ write retries. That release
1201
+ needs Python 3.11 or later, as tensorstore has since 0.1.81, so it also ends support for
1202
+ 3.10.
1129
1203
 
1130
1204
  ## 8. Constraints for whoever builds on this
1131
1205
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.4.0"
3
+ version = "0.5.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" }
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  from . import grid, store, units, write
14
14
  from .array import Pyramid, WorldArray, create, metadata, open, open_pyramid, open_tensorstore
15
- from .context import capped_context, capped_context_spec, shared_context
15
+ from .context import capped_context, capped_context_spec, shared_context, thread_budget
16
16
  from .geometry import Box, index_range
17
17
  from .grid import Block, Grid, blocks, level_factors
18
18
  from .levels import select_level
@@ -28,7 +28,7 @@ from .write import (
28
28
  parse_chunk_key,
29
29
  )
30
30
 
31
- __version__ = "0.4.0"
31
+ __version__ = "0.5.0"
32
32
 
33
33
  __all__ = [
34
34
  "ArrayMeta",
@@ -62,6 +62,7 @@ __all__ = [
62
62
  "open_tensorstore",
63
63
  "select_level",
64
64
  "shared_context",
65
+ "thread_budget",
65
66
  "store",
66
67
  "units",
67
68
  "write",
@@ -35,7 +35,7 @@ from .placement import (
35
35
  read_meta,
36
36
  read_meta_light,
37
37
  )
38
- from .retry import Retry, read_with_retries
38
+ from .retry import Retry, read_with_retries, write_with_retries
39
39
  from .store import (
40
40
  Node,
41
41
  detect,
@@ -533,7 +533,12 @@ class WorldArray:
533
533
  # --- writing (Behaviour 13) ----------------------------------------------------
534
534
 
535
535
  def write_index(
536
- self, begin: Sequence[int], end: Sequence[int], data: np.ndarray, partial: bool = False
536
+ self,
537
+ begin: Sequence[int],
538
+ end: Sequence[int],
539
+ data: np.ndarray,
540
+ partial: bool = False,
541
+ retries: Retry | None = None,
537
542
  ) -> None:
538
543
  """Write ``data`` over the voxel box ``[begin, end)`` of the stored grid. The box must
539
544
  be a union of whole write units (every bound a multiple of ``write_shape`` on its axis,
@@ -542,7 +547,8 @@ class WorldArray:
542
547
  read-modify-write of the touched units, unsafe while anything else writes the same
543
548
  unit. ``data`` may be a scalar, which fills the box: it is converted to the array's
544
549
  dtype only when the value survives (``0`` into ``uint8``, not ``300`` or ``0.5``).
545
- No other cast, no padding, no resampling; the write commits before returning."""
550
+ No other cast, no padding, no resampling; the write commits before returning, timed
551
+ out and retried as reads are (``retries``, else the handle's)."""
546
552
  if not self.writable:
547
553
  raise ValueError(
548
554
  f"{self.path} was opened read-only; open it with mode='r+' or create it"
@@ -577,7 +583,13 @@ class WorldArray:
577
583
  f"{self.path}: write [{b}, {e}) on axis {axis} ({self.axes[axis]}) is not whole write "
578
584
  f"units of {u}; align the box to write_shape {self.write_shape} or pass partial=True"
579
585
  )
580
- self._store[tuple(slice(b, e) for b, e in zip(begin, end))].write(data).result()
586
+ write_with_retries(
587
+ self._store[tuple(slice(b, e) for b, e in zip(begin, end))],
588
+ data,
589
+ retries or self.retries,
590
+ remote=self.remote,
591
+ what=self.path,
592
+ )
581
593
 
582
594
  def _scalar(self, value: np.ndarray) -> np.ndarray:
583
595
  """A 0-d ``value`` in the array's dtype, or ``ValueError`` when converting changes it:
@@ -595,7 +607,9 @@ class WorldArray:
595
607
  raise ValueError(f"{self.path}: {value.item()!r} does not fit {dtype}")
596
608
  return out
597
609
 
598
- def write(self, box, data: np.ndarray, partial: bool = False) -> None:
610
+ def write(
611
+ self, box, data: np.ndarray, partial: bool = False, retries: Retry | None = None
612
+ ) -> None:
599
613
  """Write ``data`` over a world box: the voxels Behaviour 2's centre rule selects, then
600
614
  ``write_index``. A box reaching outside the array raises; nothing is padded or
601
615
  resampled on write."""
@@ -606,7 +620,7 @@ class WorldArray:
606
620
  f"{self.path}: the box reaches outside the array on axis {axis} ({self.axes[axis]}): "
607
621
  f"voxels [{b}, {e}) of {n}; nothing is padded on write"
608
622
  )
609
- self.write_index(begin, end, data, partial=partial)
623
+ self.write_index(begin, end, data, partial=partial, retries=retries)
610
624
 
611
625
  def blocks(self, block_shape=None, context=0, box=None, clip: bool = True):
612
626
  """The array cut into blocks (Behaviour 17): write boxes tiling the stored grid (or a
@@ -623,7 +637,9 @@ class WorldArray:
623
637
  clip=clip,
624
638
  )
625
639
 
626
- def write_chunk(self, index: Sequence[int], data: np.ndarray) -> None:
640
+ def write_chunk(
641
+ self, index: Sequence[int], data: np.ndarray, retries: Retry | None = None
642
+ ) -> None:
627
643
  """Write the whole write unit at ``index`` on the storage grid (the shard of a sharded
628
644
  array); ``data`` has that unit's shape, clipped at the array's end."""
629
645
  unit, shape = self.write_shape, self._grid.shape
@@ -634,7 +650,7 @@ class WorldArray:
634
650
  if any(i < 0 or b >= n for i, b, n in zip(index, begin, shape)):
635
651
  raise ValueError(f"{self.path}: no write unit at {index}")
636
652
  end = tuple(min(b + u, n) for b, u, n in zip(begin, unit, shape))
637
- self.write_index(begin, end, data)
653
+ self.write_index(begin, end, data, retries=retries)
638
654
 
639
655
  def read_index(
640
656
  self,
@@ -0,0 +1,205 @@
1
+ """Tensorstore contexts: one shared per cache budget, or a capped one per call.
2
+
3
+ ``shared_context(cache_bytes)`` is one ``tensorstore.Context`` per budget for the whole
4
+ process, so the levels of a pyramid and repeat opens of an array draw on one cache pool.
5
+ The default budget is 0: tensorstore's decoded-chunk cache is off, because every consumer
6
+ either reads each region once or caches decoded chunks itself. Its decoding and file I/O
7
+ pools are sized by ``thread_budget()``, from the variables a cluster job sets, where
8
+ tensorstore alone would size them to every CPU on the node. Every context here, and every
9
+ one a store is opened on, is watched for tensorstore's lost wakeup (``watch``).
10
+
11
+ ``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
12
+ resource limited, so dozens of worker processes on one host do not multiply into thousands
13
+ of threads. It returns a **new** context each call, as those workers inline the spec into
14
+ every open today; pass the same object twice to share pools (spec, Behaviour 7).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ import threading
23
+ import time
24
+ import weakref
25
+ from typing import Any
26
+
27
+ import tensorstore as ts
28
+
29
+ _shared: dict[int, ts.Context] = {}
30
+ _rebuilt: dict[str, ts.Context] = {}
31
+ _lock = threading.Lock()
32
+
33
+
34
+ # tensorstore's lost wakeup. In every release up to at least 0.1.85, a pool worker idle for
35
+ # 20 ms exits, and a task queued as it exits gets no thread while the pool counts itself
36
+ # full: the task never runs and its future waits forever, with no tensorstore thread alive.
37
+ # A pool limited to one thread meets it most (cellmap-analyze's workers on /nrs,
38
+ # 2026-10-22; its read retries of June 2025 were working around the same thing). Any later
39
+ # task on the same pool wakes it. So every context the package makes or opens a store on is
40
+ # watched: a daemon thread gives each one's decoding and file I/O pools a no-op task every
41
+ # KICK_EVERY seconds, and a stranded task, whoever waits on it (worldarray, ``vol.ts``,
42
+ # ``await``), runs within that. Fixed upstream in google/tensorstore ed51718085 (2026-09-30,
43
+ # "Fix lost wakeups in TaskGroup when workers block or exit"). Once a release contains it,
44
+ # require that release and remove the watchdog (spec, open question 14):
45
+ # `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
46
+ # for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
47
+ # then strands nothing on a plain context.
48
+ KICK_EVERY = 1.0
49
+ _KICK_DIR = tempfile.gettempdir()
50
+ _KICK_KEY = "worldarray-kick-no-such-key"
51
+ _watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
52
+ _watch_lock = threading.Lock()
53
+ _watchdog: threading.Thread | None = None
54
+
55
+
56
+ def _kickers(context: ts.Context) -> tuple:
57
+ """An in-memory array and a local kvstore on ``context``: reading the one copies on its
58
+ decoding pool, reading a missing key from the other runs on its file I/O pool."""
59
+ array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
60
+ kv = ts.KvStore.open({"driver": "file", "path": _KICK_DIR + "/"}, context=context)
61
+ return array.result(timeout=10), kv.result(timeout=10)
62
+
63
+
64
+ def kick(context: ts.Context) -> list:
65
+ """One no-op task on each of ``context``'s decoding and file I/O pools; no store is
66
+ touched. Returns the futures, which the caller holds until done (a dropped future may
67
+ be cancelled before it is queued)."""
68
+ try:
69
+ with _watch_lock:
70
+ kickers = _watched.get(context)
71
+ if kickers is None:
72
+ kickers = _kickers(context)
73
+ with _watch_lock:
74
+ if context in _watched:
75
+ _watched[context] = kickers
76
+ array, kv = kickers
77
+ return [array.read(), kv.read(_KICK_KEY)]
78
+ except Exception: # noqa: BLE001 a kick that cannot start changes nothing
79
+ return []
80
+
81
+
82
+ def watch(context: ts.Context | None) -> ts.Context | None:
83
+ """Kick ``context``'s pools every ``KICK_EVERY`` seconds for as long as it lives, starting
84
+ the watchdog thread on first use. Returns ``context``."""
85
+ global _watchdog
86
+ if context is None:
87
+ return None
88
+ with _watch_lock:
89
+ if context not in _watched:
90
+ _watched[context] = None
91
+ if _watchdog is None or not _watchdog.is_alive():
92
+ _watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
93
+ _watchdog.start()
94
+ return context
95
+
96
+
97
+ def _watch() -> None:
98
+ held: list = []
99
+ while True:
100
+ time.sleep(KICK_EVERY)
101
+ with _watch_lock:
102
+ contexts = list(_watched.keys())
103
+ held = [f for f in held if not f.done()]
104
+ for context in contexts:
105
+ held += kick(context)
106
+ del contexts
107
+
108
+
109
+ def _forget_watchdog() -> None:
110
+ global _watchdog
111
+ _watchdog = None # a forked child has no such thread; the next watch() starts one
112
+
113
+
114
+ if hasattr(os, "register_at_fork"):
115
+ os.register_at_fork(after_in_child=_forget_watchdog)
116
+
117
+
118
+ _SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
119
+
120
+
121
+ def thread_budget() -> int:
122
+ """Threads for this process's tensorstore decoding and file I/O: ``OMP_NUM_THREADS``
123
+ when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
124
+ job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
125
+ ``NSLOTS``), else the CPUs this process may run on."""
126
+ for name in _SLOTS:
127
+ try:
128
+ n = int(os.environ.get(name, ""))
129
+ except ValueError:
130
+ continue
131
+ if n > 0:
132
+ return n
133
+ try:
134
+ return len(os.sched_getaffinity(0))
135
+ except (AttributeError, OSError):
136
+ return os.cpu_count() or 1
137
+
138
+
139
+ def shared_context(cache_bytes: int = 0) -> ts.Context:
140
+ with _lock:
141
+ ctx = _shared.get(int(cache_bytes))
142
+ if ctx is None:
143
+ limit = {"limit": thread_budget()}
144
+ ctx = ts.Context(
145
+ {
146
+ "cache_pool": {"total_bytes_limit": int(cache_bytes)},
147
+ "data_copy_concurrency": limit,
148
+ "file_io_concurrency": limit,
149
+ }
150
+ )
151
+ watch(ctx)
152
+ _shared[int(cache_bytes)] = ctx
153
+ return ctx
154
+
155
+
156
+ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> dict:
157
+ """The spec dict behind ``capped_context``, for callers building their own opens.
158
+ ``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
159
+ pools alone (cellmap-analyze's choice for remote reads). A ``concurrency`` of 1 is safe:
160
+ the context is watched (see ``KICK_EVERY``)."""
161
+ limit = {"limit": int(concurrency)}
162
+ spec = {
163
+ "data_copy_concurrency": limit,
164
+ "file_io_concurrency": limit,
165
+ "cache_pool": {"total_bytes_limit": int(cache_bytes)},
166
+ }
167
+ if network:
168
+ spec.update(
169
+ gcs_request_concurrency=limit,
170
+ s3_request_concurrency=limit,
171
+ http_request_concurrency=limit,
172
+ )
173
+ return spec
174
+
175
+
176
+ def capped_context(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> ts.Context:
177
+ return watch(ts.Context(capped_context_spec(cache_bytes, concurrency, network)))
178
+
179
+
180
+ def recipe_of(context: ts.Context) -> tuple[str, Any]:
181
+ """How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
182
+ context, else ``("spec", json)`` from the context's own spec, so a capped context stays
183
+ capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
184
+ with _lock:
185
+ for cache_bytes, ctx in _shared.items():
186
+ if ctx is context:
187
+ return ("shared", cache_bytes)
188
+ spec = context.spec
189
+ return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
190
+
191
+
192
+ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
193
+ """``recipe_of`` inverted; one rebuilt context per spec per process, so handles that
194
+ shared a context keep sharing one."""
195
+ if recipe is None:
196
+ return None
197
+ kind, value = recipe
198
+ if kind == "shared":
199
+ return shared_context(int(value))
200
+ with _lock:
201
+ ctx = _rebuilt.get(value)
202
+ if ctx is None:
203
+ ctx = watch(ts.Context(ts.Context.Spec(json.loads(value))))
204
+ _rebuilt[value] = ctx
205
+ return ctx
@@ -1,7 +1,7 @@
1
- """Retrying a data read on a transient failure.
1
+ """Retrying a data read or write on a transient failure.
2
2
 
3
3
  mesh-n-bone's policy, which its tests pin: attempt ``k`` (from 1) waits ``timeout * k``
4
- seconds; ``timeout`` is given, or sized from the read and where it comes from:
4
+ seconds; ``timeout`` is given, or sized from the bytes moved and where they go:
5
5
  ``clamp(MiB / 16, 5, 120)`` for a remote store and ``clamp(MiB / 256, 5, 30)`` for a local
6
6
  one. Between attempts the sleep is ``base_delay * 1.3 ** (k - 1)`` plus up to one second of
7
7
  jitter. A timed-out future is cancelled before the next attempt (spec, Behaviour 6).
@@ -43,15 +43,22 @@ def is_transient(error: BaseException) -> bool:
43
43
  return message.startswith(_TRANSIENT_PREFIXES) or bool(_HTTP_TRANSIENT.search(message))
44
44
 
45
45
 
46
- def read_with_retries(view, retry: Retry, *, remote: bool, what: str = "") -> np.ndarray:
47
- """``view.read()`` as a numpy array, retried per ``retry``. ``view`` is a tensorstore
48
- (or any object whose ``read()`` returns a future with ``result(timeout)`` and
49
- ``cancel()``)."""
50
- nbytes = int(np.prod(view.shape, dtype=np.int64)) * np.dtype(view.dtype.numpy_dtype).itemsize
46
+ def describe(error: BaseException) -> str:
47
+ """The first line of ``error``'s message, or its type's name when the message is empty
48
+ (a timed-out tensorstore future raises ``TimeoutError()``)."""
49
+ lines = str(error).splitlines()
50
+ return lines[0][:120] if lines else type(error).__name__
51
+
52
+
53
+ def with_retries(start, nbytes: int, retry: Retry, *, remote: bool, what: str, verb: str):
54
+ """``start().result()``, retried per ``retry``. ``start`` begins the operation and
55
+ returns a future with ``result(timeout)`` and ``cancel()``; ``nbytes`` sizes the timeout
56
+ and ``verb`` ("read", "write") names the operation in the warning and the error."""
51
57
  timeout = retry.timeout_for(nbytes, remote)
58
+ attempts = max(1, retry.max_attempts)
52
59
  last: BaseException | None = None
53
- for attempt in range(1, max(1, retry.max_attempts) + 1):
54
- future = view.read()
60
+ for attempt in range(1, attempts + 1):
61
+ future = start()
55
62
  try:
56
63
  return future.result(timeout=timeout * attempt)
57
64
  except TimeoutError as e:
@@ -61,16 +68,40 @@ def read_with_retries(view, retry: Retry, *, remote: bool, what: str = "") -> np
61
68
  if not is_transient(e):
62
69
  raise
63
70
  last = e
64
- if attempt < retry.max_attempts:
71
+ if attempt < attempts:
65
72
  delay = retry.base_delay * 1.3 ** (attempt - 1) + random.random()
66
73
  logger.warning(
67
- "read %s failed on attempt %d/%d (%s); retrying in %.1fs",
74
+ "%s %s failed on attempt %d/%d (%s); retrying in %.1fs",
75
+ verb,
68
76
  what,
69
77
  attempt,
70
- retry.max_attempts,
71
- str(last).splitlines()[0][:120],
78
+ attempts,
79
+ describe(last),
72
80
  delay,
73
81
  )
74
82
  time.sleep(delay)
75
83
  assert last is not None
84
+ if isinstance(last, TimeoutError):
85
+ raise TimeoutError(
86
+ f"{verb} {what} timed out on all {attempts} attempts, the last after "
87
+ f"{timeout * attempts:g}s"
88
+ ) from last
76
89
  raise last
90
+
91
+
92
+ def read_with_retries(view, retry: Retry, *, remote: bool, what: str = "") -> np.ndarray:
93
+ """``view.read()`` as a numpy array, retried per ``retry``. ``view`` is a tensorstore
94
+ (or any object whose ``read()`` returns a future with ``result(timeout)`` and
95
+ ``cancel()``)."""
96
+ nbytes = int(np.prod(view.shape, dtype=np.int64)) * np.dtype(view.dtype.numpy_dtype).itemsize
97
+ return with_retries(view.read, nbytes, retry, remote=remote, what=what, verb="read")
98
+
99
+
100
+ def write_with_retries(
101
+ view, data: np.ndarray, retry: Retry, *, remote: bool, what: str = ""
102
+ ) -> None:
103
+ """``view.write(data)`` committed, retried per ``retry`` like a read. A timed-out write
104
+ is cancelled, but may still land; it writes the same bytes, so the next attempt is
105
+ safe."""
106
+ nbytes = int(np.prod(view.shape, dtype=np.int64)) * np.dtype(view.dtype.numpy_dtype).itemsize
107
+ with_retries(lambda: view.write(data), nbytes, retry, remote=remote, what=what, verb="write")
@@ -20,6 +20,8 @@ from typing import Any, TypeVar
20
20
 
21
21
  import tensorstore as ts
22
22
 
23
+ from .context import watch
24
+
23
25
  logger = logging.getLogger(__name__)
24
26
  T = TypeVar("T")
25
27
 
@@ -438,7 +440,7 @@ def open_array(
438
440
  spec["assume_metadata"] = True
439
441
  if node.format == "precomputed" and scale_index is not None:
440
442
  spec["scale_index"] = scale_index
441
- return ts.open(spec, read=True, write=write, context=context).result()
443
+ return ts.open(spec, read=True, write=write, context=watch(context)).result()
442
444
 
443
445
  store = with_access(kvstore_specs(node.path), attempt)
444
446
  if node.format in ("n5", "precomputed"):
@@ -27,6 +27,7 @@ from typing import Any
27
27
  import numpy as np
28
28
  import tensorstore as ts
29
29
 
30
+ from .context import watch
30
31
  from .geometry import snap_integral
31
32
  from .placement import spatial_axes
32
33
  from .store import (
@@ -767,7 +768,7 @@ def create_array(
767
768
  separator,
768
769
  store_fill,
769
770
  )
770
- return ts.open(spec, create=True, open=False, context=context).result()
771
+ return ts.open(spec, create=True, open=False, context=watch(context)).result()
771
772
 
772
773
  try:
773
774
  with_access(kvstore_specs(path), attempt)
@@ -2,14 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import gc
6
+ import logging
5
7
  import os
8
+ import time
9
+ import weakref
6
10
 
7
11
  import numpy as np
8
12
  import pytest
13
+ import tensorstore as ts
9
14
 
10
15
  import worldarray as wa
11
16
  from conftest import pattern, write_zarr2, write_zarr2_group
12
- from worldarray import store
17
+ from worldarray import retry, store
13
18
 
14
19
 
15
20
  @pytest.fixture(autouse=True)
@@ -112,6 +117,94 @@ def test_contexts_and_retry_timeouts():
112
117
  assert wa.Retry(timeout=7).timeout_for(10**12, remote=True) == 7.0
113
118
 
114
119
 
120
+ def test_the_thread_budget_follows_the_job(monkeypatch):
121
+ monkeypatch.delenv("OMP_NUM_THREADS", raising=False)
122
+ monkeypatch.delenv("LSB_DJOB_NUMPROC", raising=False)
123
+ allowed = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count()
124
+ assert wa.thread_budget() == allowed # macOS has no affinity call
125
+ monkeypatch.setenv("LSB_DJOB_NUMPROC", "12")
126
+ assert wa.thread_budget() == 12
127
+ monkeypatch.setenv("OMP_NUM_THREADS", "24") # a job script's 2 * slots
128
+ assert wa.thread_budget() == 24
129
+ monkeypatch.setenv("OMP_NUM_THREADS", "1") # what dask sets in each worker
130
+ assert wa.thread_budget() == 1
131
+ monkeypatch.setenv("OMP_NUM_THREADS", "x")
132
+ assert wa.thread_budget() == 12
133
+ monkeypatch.delenv("LSB_DJOB_NUMPROC")
134
+ monkeypatch.setenv("SLURM_CPUS_PER_TASK", "6")
135
+ assert wa.thread_budget() == 6
136
+ monkeypatch.setattr(wa.context, "_shared", {})
137
+ spec = wa.shared_context(5).spec.to_json()
138
+ assert spec["data_copy_concurrency"] == spec["file_io_concurrency"] == {"limit": 6}
139
+ assert spec["cache_pool"] == {"total_bytes_limit": 5}
140
+
141
+
142
+ def test_a_kick_runs_on_the_contexts_own_pools():
143
+ ctx = wa.capped_context(concurrency=1)
144
+ futures = wa.context.kick(ctx)
145
+ assert len(futures) == 2
146
+ assert [type(f.result(timeout=10)).__name__ for f in futures] == ["ndarray", "ReadResult"]
147
+
148
+
149
+ def test_every_context_the_package_touches_is_kicked(monkeypatch, tmp_path):
150
+ # tensorstore's lost wakeup: a stranded task runs once anything else uses its pool, so
151
+ # a watched context's pools get a no-op task every KICK_EVERY seconds, whoever waits
152
+ kicked = []
153
+ monkeypatch.setattr(wa.context, "_watched", weakref.WeakKeyDictionary())
154
+ monkeypatch.setattr(wa.context, "kick", lambda c: kicked.append(id(c)) or [])
155
+ monkeypatch.setattr(wa.context, "KICK_EVERY", 0.01)
156
+ monkeypatch.setattr(wa.context, "_watchdog", None)
157
+ capped = wa.capped_context(concurrency=1)
158
+ own = ts.Context({"data_copy_concurrency": {"limit": 1}})
159
+ write_zarr2(tmp_path / "a.zarr", pattern((4, 4)), chunks=(2, 2))
160
+ wa.open_tensorstore(str(tmp_path / "a.zarr"), context=own)
161
+ deadline = time.monotonic() + 5
162
+ while not {id(capped), id(own)} <= set(kicked) and time.monotonic() < deadline:
163
+ time.sleep(0.01)
164
+ assert {id(capped), id(own)} <= set(kicked)
165
+ assert wa.context._watchdog.daemon and wa.context._watchdog.name == "worldarray-kick"
166
+ n = len(wa.context._watched)
167
+ del capped
168
+ gc.collect()
169
+ assert len(wa.context._watched) == n - 1 # a context that died is no longer watched
170
+
171
+
172
+ class _StalledView:
173
+ """A view whose first ``stalls`` reads never finish (a future nothing resolves, as a
174
+ read stuck on NFS looks from Python); later reads return ``0, 1, 2, 3``."""
175
+
176
+ shape, dtype = (4,), ts.uint8
177
+
178
+ def __init__(self, stalls):
179
+ self.stalls, self.held = stalls, []
180
+
181
+ def read(self):
182
+ promise, future = ts.Promise.new()
183
+ if len(self.held) < self.stalls:
184
+ self.held.append((promise, future))
185
+ else:
186
+ promise.set_result(np.arange(4, dtype=np.uint8))
187
+ return future
188
+
189
+
190
+ def test_a_timed_out_read_is_cancelled_and_retried(monkeypatch, caplog):
191
+ # tensorstore's TimeoutError has an empty message; the warning names its type
192
+ # (salamander's IndexError on /nrs under 0.4.0)
193
+ monkeypatch.setattr(retry.time, "sleep", lambda s: None)
194
+ view = _StalledView(stalls=2)
195
+ with caplog.at_level(logging.WARNING, logger="worldarray.retry"):
196
+ got = retry.read_with_retries(
197
+ view, wa.Retry(timeout=0.01, max_attempts=3), remote=False, what="v"
198
+ )
199
+ assert got.tolist() == [0, 1, 2, 3]
200
+ assert [f.cancelled() for _, f in view.held] == [True, True]
201
+ assert "read v failed on attempt 1/3 (TimeoutError)" in caplog.text
202
+ with pytest.raises(TimeoutError, match="read v timed out on all 2 attempts"):
203
+ retry.read_with_retries(
204
+ _StalledView(stalls=9), wa.Retry(timeout=0.01, max_attempts=2), remote=False, what="v"
205
+ )
206
+
207
+
115
208
  def test_metadata_reads_carry_capped_retries():
116
209
  spec = store.metadata_spec({"driver": "s3", "bucket": "b", "path": "p/"})
117
210
  assert spec["context"]["s3_request_retries"] == store.METADATA_RETRIES
@@ -0,0 +1,76 @@
1
+ """tensorstore's lost wakeup, reproduced on purpose (spec, Behaviour 6 and open question 14).
2
+
3
+ Opt-in, about a minute: ``WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s``. Worker
4
+ processes give a pool limited to one thread its next task 19.5-20.5 ms after the last one
5
+ finished, when the pool's only worker exits idle. On an affected tensorstore (every release
6
+ up to at least 0.1.85) about 0.3% of those tasks are stranded on a plain context and never
7
+ run; on a context the package watches, every one finishes within about a second. When a
8
+ release says it carries google/tensorstore ed51718085, run this: no strands on the plain
9
+ context means the watchdog can go.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import multiprocessing as mp
15
+ import os
16
+ import random
17
+ import tempfile
18
+ import time
19
+
20
+ import pytest
21
+
22
+ pytestmark = pytest.mark.skipif(
23
+ not os.environ.get("WORLDARRAY_STRESS"), reason="set WORLDARRAY_STRESS=1 to run"
24
+ )
25
+
26
+ PROCS, SECONDS, GIVE_UP = 8, 20.0, 10.0
27
+
28
+
29
+ def _aim(args):
30
+ worker, watched = args
31
+ import tensorstore as ts
32
+
33
+ import worldarray as wa
34
+
35
+ limits = {"data_copy_concurrency": {"limit": 1}, "file_io_concurrency": {"limit": 1}}
36
+ ctx = wa.capped_context(concurrency=1, network=False) if watched else ts.Context(limits)
37
+ kv = ts.KvStore.open({"driver": "file", "path": tempfile.mkdtemp() + "/"}, context=ctx)
38
+ kv = kv.result()
39
+ rng = random.Random(worker)
40
+ slowest, stranded = 0.0, 0
41
+ end = time.monotonic() + SECONDS
42
+ while time.monotonic() < end:
43
+ time.sleep(rng.uniform(0.0195, 0.0205))
44
+ future, start = kv.read("missing"), time.monotonic()
45
+ try:
46
+ future.result(timeout=GIVE_UP)
47
+ except TimeoutError:
48
+ stranded += 1
49
+ kv.read("other").result(timeout=GIVE_UP) # any task on the pool frees it
50
+ future.result(timeout=GIVE_UP)
51
+ slowest = max(slowest, time.monotonic() - start)
52
+ return stranded, slowest
53
+
54
+
55
+ def _run(watched: bool) -> tuple[int, float]:
56
+ with mp.get_context("spawn").Pool(PROCS) as pool:
57
+ results = pool.map(_aim, [(w, watched) for w in range(PROCS)])
58
+ return sum(r[0] for r in results), max(r[1] for r in results)
59
+
60
+
61
+ def test_a_watched_context_never_strands_a_task():
62
+ import importlib.metadata
63
+
64
+ version = importlib.metadata.version("tensorstore")
65
+ stranded, _ = _run(watched=False)
66
+ watched_stranded, slowest = _run(watched=True)
67
+ print(
68
+ f"\ntensorstore {version}: plain context stranded {stranded} tasks for {GIVE_UP:g}s+; "
69
+ f"watched context stranded {watched_stranded}, slowest {slowest:.2f}s"
70
+ )
71
+ assert watched_stranded == 0 and slowest < 2.0
72
+ if stranded == 0:
73
+ pytest.skip(
74
+ f"tensorstore {version} stranded nothing: if it carries ed51718085, "
75
+ "the watchdog can go (open question 14)"
76
+ )
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import multiprocessing as mp
7
8
  import pickle
9
+ from types import SimpleNamespace
8
10
 
9
11
  import numpy as np
10
12
  import pytest
@@ -748,6 +750,47 @@ def test_a_pickled_relative_path_reopens_after_a_chdir(tmp_path, monkeypatch):
748
750
  assert vol.reopen().level_path == again.level_path
749
751
 
750
752
 
753
+ class _StallingStore:
754
+ """A tensorstore whose first ``stalls`` writes never finish, as a write stuck on NFS
755
+ looks from Python; later writes go through."""
756
+
757
+ def __init__(self, store, stalls):
758
+ self.store, self.stalls, self.held = store, stalls, []
759
+
760
+ def __getitem__(self, index):
761
+ view = self.store[index]
762
+
763
+ def write(data):
764
+ if len(self.held) >= self.stalls:
765
+ return view.write(data)
766
+ promise, future = ts.Promise.new()
767
+ self.held.append((promise, future))
768
+ return future
769
+
770
+ return SimpleNamespace(shape=view.shape, dtype=view.dtype, write=write)
771
+
772
+
773
+ def test_a_stalled_write_is_cancelled_and_retried(tmp_path, monkeypatch, caplog):
774
+ # salamander's 0.4.0 runs on /nrs: workers parked forever in write().result()
775
+ monkeypatch.setattr(wa.retry.time, "sleep", lambda s: None)
776
+ vol = wa.create(
777
+ str(tmp_path / "vol.zarr/s0"), (8, 8, 8), "uint8", (1, 1, 1), (0.5,) * 3, chunks=(4, 4, 4)
778
+ )
779
+ store = vol._store
780
+ vol._store = _StallingStore(store, stalls=2)
781
+ with caplog.at_level(logging.WARNING, logger="worldarray.retry"):
782
+ vol.write_index((0, 0, 0), (4, 4, 4), 9, retries=wa.Retry(timeout=0.01, max_attempts=3))
783
+ assert [f.cancelled() for _, f in vol._store.held] == [True, True]
784
+ assert "write " + vol.path + " failed on attempt 1/3 (TimeoutError)" in caplog.text
785
+ vol.retries = wa.Retry(timeout=0.01, max_attempts=2)
786
+ vol._store = _StallingStore(store, stalls=9)
787
+ with pytest.raises(TimeoutError, match="timed out on all 2 attempts"):
788
+ vol.write_chunk((1, 1, 1), 5)
789
+ vol._store = store
790
+ got = vol.read_index((0, 0, 0), (8, 8, 8))
791
+ assert np.all(got[:4, :4, :4] == 9) and np.all(got[4:, 4:, 4:] == 0)
792
+
793
+
751
794
  def test_a_scalar_fills_the_box(tmp_path):
752
795
  vol = wa.create(
753
796
  str(tmp_path / "vol.zarr/s0"), (8, 8, 8), "uint8", (1, 1, 1), (0.5,) * 3, chunks=(4, 4, 4)
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.4.0"
1250
+ version = "0.5.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'" },
@@ -1,84 +0,0 @@
1
- """Tensorstore contexts: one shared per cache budget, or a capped one per call.
2
-
3
- ``shared_context(cache_bytes)`` is one ``tensorstore.Context`` per budget for the whole
4
- process, so the levels of a pyramid and repeat opens of an array draw on one cache pool.
5
- The default budget is 0: tensorstore's decoded-chunk cache is off, because every consumer
6
- either reads each region once or caches decoded chunks itself.
7
-
8
- ``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
9
- resource limited, so dozens of worker processes on one host do not multiply into thousands
10
- of threads. It returns a **new** context each call, as those workers inline the spec into
11
- every open today; pass the same object twice to share pools (spec, Behaviour 7).
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import json
17
- import threading
18
- from typing import Any
19
-
20
- import tensorstore as ts
21
-
22
- _shared: dict[int, ts.Context] = {}
23
- _rebuilt: dict[str, ts.Context] = {}
24
- _lock = threading.Lock()
25
-
26
-
27
- def shared_context(cache_bytes: int = 0) -> ts.Context:
28
- with _lock:
29
- ctx = _shared.get(int(cache_bytes))
30
- if ctx is None:
31
- ctx = ts.Context({"cache_pool": {"total_bytes_limit": int(cache_bytes)}})
32
- _shared[int(cache_bytes)] = ctx
33
- return ctx
34
-
35
-
36
- def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> dict:
37
- """The spec dict behind ``capped_context``, for callers building their own opens.
38
- ``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
39
- pools alone (cellmap-analyze's choice for remote reads)."""
40
- limit = {"limit": int(concurrency)}
41
- spec = {
42
- "data_copy_concurrency": limit,
43
- "file_io_concurrency": limit,
44
- "cache_pool": {"total_bytes_limit": int(cache_bytes)},
45
- }
46
- if network:
47
- spec.update(
48
- gcs_request_concurrency=limit,
49
- s3_request_concurrency=limit,
50
- http_request_concurrency=limit,
51
- )
52
- return spec
53
-
54
-
55
- def capped_context(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> ts.Context:
56
- return ts.Context(capped_context_spec(cache_bytes, concurrency, network))
57
-
58
-
59
- def recipe_of(context: ts.Context) -> tuple[str, Any]:
60
- """How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
61
- context, else ``("spec", json)`` from the context's own spec, so a capped context stays
62
- capped across a pickle (Behaviour 11, mesh-n-bone's workers)."""
63
- with _lock:
64
- for cache_bytes, ctx in _shared.items():
65
- if ctx is context:
66
- return ("shared", cache_bytes)
67
- spec = context.spec
68
- return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
69
-
70
-
71
- def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
72
- """``recipe_of`` inverted; one rebuilt context per spec per process, so handles that
73
- shared a context keep sharing one."""
74
- if recipe is None:
75
- return None
76
- kind, value = recipe
77
- if kind == "shared":
78
- return shared_context(int(value))
79
- with _lock:
80
- ctx = _rebuilt.get(value)
81
- if ctx is None:
82
- ctx = ts.Context(ts.Context.Spec(json.loads(value)))
83
- _rebuilt[value] = ctx
84
- return ctx
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes