worldarray 0.4.0__tar.gz → 0.6.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.6.0}/CHANGELOG.md +57 -0
  2. {worldarray-0.4.0 → worldarray-0.6.0}/PKG-INFO +12 -6
  3. {worldarray-0.4.0 → worldarray-0.6.0}/README.md +11 -5
  4. {worldarray-0.4.0 → worldarray-0.6.0}/docs/spec.md +108 -15
  5. {worldarray-0.4.0 → worldarray-0.6.0}/pyproject.toml +1 -1
  6. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/__init__.py +3 -2
  7. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/array.py +24 -8
  8. worldarray-0.6.0/src/worldarray/context.py +215 -0
  9. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/retry.py +44 -13
  10. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/store.py +3 -1
  11. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/write.py +2 -1
  12. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_access.py +114 -1
  13. worldarray-0.6.0/tests/test_lost_wakeup.py +76 -0
  14. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_write.py +45 -0
  15. {worldarray-0.4.0 → worldarray-0.6.0}/uv.lock +1 -1
  16. worldarray-0.4.0/src/worldarray/context.py +0 -84
  17. {worldarray-0.4.0 → worldarray-0.6.0}/.github/workflows/ci.yml +0 -0
  18. {worldarray-0.4.0 → worldarray-0.6.0}/.github/workflows/docs.yml +0 -0
  19. {worldarray-0.4.0 → worldarray-0.6.0}/.github/workflows/publish.yml +0 -0
  20. {worldarray-0.4.0 → worldarray-0.6.0}/.gitignore +0 -0
  21. {worldarray-0.4.0 → worldarray-0.6.0}/LICENSE +0 -0
  22. {worldarray-0.4.0 → worldarray-0.6.0}/docs/changelog.md +0 -0
  23. {worldarray-0.4.0 → worldarray-0.6.0}/docs/index.md +0 -0
  24. {worldarray-0.4.0 → worldarray-0.6.0}/mkdocs.yml +0 -0
  25. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/geometry.py +0 -0
  26. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/grid.py +0 -0
  27. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/levels.py +0 -0
  28. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/placement.py +0 -0
  29. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/resample.py +0 -0
  30. {worldarray-0.4.0 → worldarray-0.6.0}/src/worldarray/units.py +0 -0
  31. {worldarray-0.4.0 → worldarray-0.6.0}/tests/conftest.py +0 -0
  32. {worldarray-0.4.0 → worldarray-0.6.0}/tests/fixtures/metadata.json +0 -0
  33. {worldarray-0.4.0 → worldarray-0.6.0}/tests/fixtures/write.json +0 -0
  34. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_geometry.py +0 -0
  35. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_grid.py +0 -0
  36. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_metadata.py +0 -0
  37. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_read.py +0 -0
  38. {worldarray-0.4.0 → worldarray-0.6.0}/tests/test_resample.py +0 -0
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (2026-10-23)
4
+
5
+ One new argument, at chunkmirage's request. No placement change and no unit table change;
6
+ nothing changes for a caller that does not pass it.
7
+
8
+ - `wa.shared_context(cache_bytes, threads=)`: `None` (the default) limits the decoding and
9
+ file I/O pools to `thread_budget()` as in 0.5, a positive number limits them to that many,
10
+ and `0` writes no limit, so tensorstore sizes them itself (every CPU on the node). Each
11
+ `(cache_bytes, threads)` is one watched context for the process, so a server can share
12
+ one cache pool across every store it opens at the thread count it chose, without building
13
+ and watching its own context. A pickled handle on such a context comes back on the same
14
+ one; `threads=None` still lets the other process size by its own job.
15
+ - Documented what a pool of one thread costs (spec Behaviour 7, `capped_context_spec`):
16
+ nothing on one-chunk blocks; on 8-chunk blocks, half the speed of 4 in a two-slot job
17
+ but only 7 to 19% slower in a one-slot one (c-elegans on /nrs), so a worker per slot
18
+ loses little at the default.
19
+
20
+ ## 0.5.0 (2026-10-23)
21
+
22
+ Two bugs that hung or crashed cellmap-analyze 0.6 on /nrs (salamander's and c-elegans'
23
+ runs, 2026-10-22), and tensorstore's threads sized to the job. No placement change and no
24
+ unit table change.
25
+
26
+ - A read whose first attempt timed out raised `IndexError` instead of retrying: the
27
+ warning took the first line of the error's message, and tensorstore's `TimeoutError()`
28
+ has none. The warning now names the error's type when the message is empty, and the
29
+ error after the last attempt names the operation, the path and the attempts.
30
+ - Every context the package makes or opens a store on is watched: once a second a daemon
31
+ thread gives its decoding and file I/O pools one no-op task each (an in-memory copy and
32
+ a local read of a missing key; no store is touched). tensorstore up to at least 0.1.85
33
+ can strand a queued task: a pool worker idle for 20 ms exits as the task arrives, and
34
+ the task never runs (fixed upstream in google/tensorstore ed51718085, in no release
35
+ yet). With `data_copy` and `file_io` capped at 1, as cellmap-analyze's workers were,
36
+ dozens of workers per run waited forever in `write_index` with no tensorstore thread
37
+ alive, and reads timed out into the `IndexError` above. One more task on the same pool
38
+ wakes it, which salamander's and c-elegans' sessions proved on live stuck workers, so a
39
+ stranded task now runs within about a second, at any concurrency, whoever waits on it:
40
+ worldarray, `vol.ts` directly or `await`. Reproduced on purpose on tensorstore 0.1.75,
41
+ 0.1.78 and 0.1.85 (opt-in `tests/test_lost_wakeup.py`): cellmap-analyze's block loop
42
+ gave 29 `IndexError`s and 55 writes stuck past 20 s on 0.4.0, none on 0.5.0, slowest
43
+ 0.98 s. Spec open question 14 says when this goes.
44
+ - `write_index`, `write` and `write_chunk` are timed out and retried as reads are
45
+ (Behaviour 6), by a new `retries=` argument or the handle's `retries`. A write used to
46
+ wait on its future with no timeout at all.
47
+ - Without `context`, the shared context's decoding and file I/O pools are limited to the
48
+ new `wa.thread_budget()`: `OMP_NUM_THREADS` when set (dask sets 1 in each worker), else
49
+ the job's slots (`LSB_DJOB_NUMPROC`, `SLURM_CPUS_PER_TASK`, `NSLOTS`), else the CPUs the
50
+ process may run on. A server that wants more threads than its job's slots asks for more
51
+ slots or passes its own context.
52
+ tensorstore alone sizes both to every CPU on the node, whatever `bsub -n` asked for, so
53
+ a dask worker's tensorstore now takes the same one thread as its numpy (pinned to one
54
+ CPU, tensorstore's default context started 43 to 45 pool threads, the capped one 4 to 5).
55
+ Against cellmap-analyze 0.5.0's own block I/O on NFS (128^3 uint64 label blocks, each
56
+ one chunk), its 0.6.1 on this release reads a block in 26 ms instead of 40 and writes it
57
+ in 45 ms instead of 62 from one process at one thread per pool, and about 1.15 times
58
+ faster from 8. Speed gains grow with blocks of several chunks and more threads.
59
+
3
60
  ## 0.4.0 (2026-10-22)
4
61
 
5
62
  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.6.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
@@ -205,7 +208,9 @@ wa.open_pyramid(path, ...) -> Pyramid # .names .levels .by_name() .sele
205
208
  wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False: from documents alone
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
- wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
211
+ wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
212
+ wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
213
+ # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
209
214
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
210
215
  wa.Box ; wa.units.TABLE
211
216
 
@@ -233,7 +238,8 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
233
238
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
234
239
  `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
235
240
  `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=)`.
241
+ `write_index(begin, end, data, partial=, retries=)` and `write(box, data, partial=, retries=)` (`data` an array or a
242
+ scalar), `reopen(context=)`.
237
243
 
238
244
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
239
245
  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
@@ -146,7 +149,9 @@ wa.open_pyramid(path, ...) -> Pyramid # .names .levels .by_name() .sele
146
149
  wa.metadata(path, level=None, units=None, open=True) -> ArrayMeta # open=False: from documents alone
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
- wa.shared_context(cache_bytes=0) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
152
+ wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.Retry(...)
153
+ wa.thread_budget() # the shared context's default thread limit: OMP_NUM_THREADS, the job's slots, or CPUs;
154
+ # threads=N limits it to N, threads=0 leaves tensorstore's own pool sizes
150
155
  wa.list_chunk_keys(path, separator=".") # chunk indices from keys alone, no metadata needed
151
156
  wa.Box ; wa.units.TABLE
152
157
 
@@ -174,7 +179,8 @@ wa.units.abbreviation("nanometer") ; wa.store.join(path, "s0") ; wa.store.parent
174
179
  `read(box, voxel_size=, padding=, method=, mask=, anchor=, transform=, post_transform=, select=, strict=)`,
175
180
  `read_index(begin, end, padding=, mask=, select=, transform=)`, `read_onto(grid, begin=, end=, ...)`, `index_box(box)`,
176
181
  `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=)`.
182
+ `write_index(begin, end, data, partial=, retries=)` and `write(box, data, partial=, retries=)` (`data` an array or a
183
+ scalar), `reopen(context=)`.
178
184
 
179
185
  A box is a `(begin, end)` pair of floats in your units, half-open per axis, over the
180
186
  spatial axes or every axis; any object with `.begin`/`.end` or `.offset`/`.shape` also
@@ -185,7 +185,8 @@ wa.open_tensorstore(path, level=None, context=None) -> tensorstore.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) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.capped_context_spec(...)
188
+ wa.shared_context(cache_bytes=0, threads=None) ; wa.capped_context(cache_bytes=0, concurrency=1, network=True) ; wa.capped_context_spec(...)
189
+ # threads: None = thread_budget(), N = that many, 0 = tensorstore's own pool sizes (0.6)
189
190
  # network=False caps decoding and local file I/O only, leaving tensorstore's request pools alone
190
191
  wa.list_chunk_keys(path, separator=".", ndim=None) # chunk indices present under a path, from its keys alone: no
191
192
  # metadata document needed, so a directory of chunk files with no .zarray lists too
@@ -229,8 +230,11 @@ returned one would raise on inputs cellmap-analyze reads every day. A consumer t
229
230
  in integer Rois builds them from `corner`, `voxel_size` and `shape` in its own subclass,
230
231
  where it also chooses the integer scale factor that makes the corner whole.
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
+ `shared_context(cache_bytes, threads=None)` is one `tensorstore.Context` per budget and
234
+ thread limit for the process, so the levels of a pyramid and repeat opens share one cache
235
+ pool; the default budget is 0. Its decoding and file I/O pools are sized by
236
+ `thread_budget()` (0.5), or by `threads` when given, `0` leaving tensorstore's own sizes
237
+ (0.6, Behaviour 7).
234
238
  `capped_context` is mesh-n-bone's worker-process context (every tensorstore concurrency
235
239
  resource limited, cache off); it returns a **new** context per call, as those workers
236
240
  inline the spec into every open today, and `capped_context_spec` exports the dict for
@@ -353,6 +357,18 @@ vol.source_translation # the stored level's translation in `units`, beside so
353
357
  wa.create(..., attrs={...}, exists="adopt") # an adopt also compares the keys of `attrs` (Behaviour 11)
354
358
  ```
355
359
 
360
+ ### 0.5: writes that cannot hang
361
+
362
+ ```python
363
+ vol.write_index(begin, end, data, partial=False, retries=None)
364
+ vol.write(box, data, partial=False, retries=None) ; vol.write_chunk(index, data, retries=None)
365
+ # timed out and retried as reads are: `retries`, else the handle's (Behaviour 6)
366
+ wa.thread_budget() -> int # threads for tensorstore's decoding and file I/O in this process: OMP_NUM_THREADS, else
367
+ # LSB_DJOB_NUMPROC, else the CPUs it may run on (Behaviour 7)
368
+ # every context the package makes or opens a store on is kicked once a second, whoever
369
+ # waits on it (Behaviour 6)
370
+ ```
371
+
356
372
  ## 3. Conventions
357
373
 
358
374
  These are the rules the package exists to hold in one place. They match chunkmirage's
@@ -620,25 +636,78 @@ this document and that one differ, fix one of them.
620
636
  `on_mismatch="relabel"` and picks `method` with its own `is_label_dtype` rule;
621
637
  cellmap-analyze passes `method` from its `interpolation_order`.
622
638
 
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
639
+ 6. **Retries** apply to data reads and data writes, with mesh-n-bone's formula, which its
640
+ tests already pin: attempt `k` (from 1) waits `timeout * k` seconds on `.result()`, where
625
641
  `timeout` is the given value or, when `None`, `clamp(MiB / 16, 5, 120)` for a remote
626
642
  store (`gs://`, `s3://`, `http(s)://`) and `clamp(MiB / 256, 5, 30)` for a local one
627
643
  (`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)`
644
+ clipping, or of the box written; between attempts the sleep is `base_delay * 1.3 ** (k - 1)` plus `U(0, 1)`
629
645
  seconds; at most `max_attempts` (default 10). Retried: `TimeoutError`, and a tensorstore
630
646
  error whose message starts with `UNAVAILABLE`, `DEADLINE_EXCEEDED` or
631
647
  `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.
648
+ timed-out tensorstore future is cancelled before the next attempt starts; a write may
649
+ still land after its cancel, with the same bytes the next attempt writes. Each retry is
650
+ logged as a warning naming the error's first line, or its type when the message is
651
+ empty (tensorstore's timeout is `TimeoutError()`); when every attempt times out the
652
+ `TimeoutError` names the operation, the path and the attempts. Metadata documents are not
653
+ retried this way (Convention 5). The policy is a per-handle attribute and a per-read and
654
+ per-write argument, never only constructor state.
655
+
656
+ The watchdog (0.5): tensorstore up to at least 0.1.85 can strand a queued task. A pool
657
+ worker idle for 20 ms exits, and a task queued as it exits gets no thread while the pool
658
+ counts itself full (fixed upstream in google/tensorstore ed51718085, in no release yet).
659
+ The future then waits forever with no tensorstore thread alive, on a read, a write or an
660
+ open. A pool limited to one thread meets it most; cellmap-analyze 0.6 hung on /nrs with
661
+ `data_copy` and `file_io` at 1, and one more task on the same context woke the pool
662
+ within seconds (salamander's and c-elegans' live tests, 2026-10-22, with un-kicked
663
+ workers staying stuck). cellmap-analyze's read retries of June 2025 ("it sometimes
664
+ hangs") worked for the same reason: each attempt is a new task. So every context the
665
+ package makes (`shared_context`, `capped_context`, a rebuilt one) or opens a store on is
666
+ watched, weakly: one daemon thread, started on first use, gives each live watched
667
+ context's decoding and file I/O pools one no-op task every second (an in-memory array
668
+ copy and a local read of a key that does not exist; no store is touched, about 0.3 ms
669
+ of CPU). A stranded task then runs within about a second whoever waits on it: the
670
+ package, `vol.ts` directly, or `await`, at any concurrency. The retry stays the outer
671
+ net. Open question 14 says when the watchdog goes.
634
672
 
635
673
  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.
674
+ tensorstore's cache is off and the levels of one pyramid share one context. Its
675
+ `data_copy_concurrency` and `file_io_concurrency` limits are `thread_budget()`: the
676
+ first of `OMP_NUM_THREADS` (dask sets 1 in every worker it spawns; a cluster job script
677
+ sets its slots) and `LSB_DJOB_NUMPROC` (an LSF job's slots) that holds a positive
678
+ integer, else the CPUs `sched_getaffinity` allows: the cap every other threaded library
679
+ in the job already takes, as Janelia's cluster guidance asks. tensorstore alone sizes
680
+ both pools to every CPU of the node, whatever the job's slots, so each of twelve dask
681
+ workers in a twelve-slot job on a 96-core node could start 96 threads (0.5). A dask
682
+ worker therefore runs tensorstore on one thread of each pool, which Behaviour 6's
683
+ watchdog makes safe. SLURM's `SLURM_CPUS_PER_TASK` and SGE's `NSLOTS` stand in for
684
+ `LSB_DJOB_NUMPROC` elsewhere. The cap is the default because the process that passes
685
+ no context is the one nobody tuned: a quick dask script over `wa.open(path)` is how a
686
+ job came to start a thousand threads on twelve slots. One thread per pool slows one
687
+ task (a 256^3 block write took 2.1 s against 1.3 s uncapped) but not a job of many
688
+ workers, whose throughput its cores bound either way (four writers: 1.8 s against
689
+ 1.9 s). A process that wants more threads than its job has slots, a server under
690
+ `bsub -n 1` say, asks for more slots or says so: `shared_context(cache_bytes,
691
+ threads=N)` limits both pools to `N`, and `threads=0` writes no limit, leaving
692
+ tensorstore's own sizes (decided 2026-10-23, over chunkmirage's objection, once the
693
+ watchdog had removed the hang risk; the argument is 0.6, at chunkmirage's request, so
694
+ its server keeps one watched context per cache budget for every store it opens
695
+ instead of building its own). Each `(cache_bytes, threads)` is one context for the
696
+ process, made on first use and watched; `threads=None` and the number
697
+ `thread_budget()` returns are two contexts, as a pickle carries `None` for the other
698
+ process to size by its own job and a number as that number.
637
699
  `capped_context` sets `data_copy_concurrency`, `file_io_concurrency`,
638
700
  `gcs_request_concurrency`, `s3_request_concurrency` and `http_request_concurrency` to
639
701
  `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
702
+ handles never share one capped pool unless the caller passes the same object twice; a
703
+ `concurrency` of 1 is safe under Behaviour 6's watchdog, but a read or write spanning
704
+ several chunks then handles them one at a time. On /nrs, from one process alternating
705
+ limits op by op (c-elegans, 2026-10-23): a one-chunk block (128^3 uint64) read in 18 to
706
+ 19 ms and wrote in 51 ms at both 1 and 4. An 8-chunk block (256^3, what a downsampling
707
+ step reads) read in 157 ms against 73 and wrote in 312 ms against 158 in a two-slot
708
+ job, but in a one-slot job read in 147 ms against 119 and wrote in 325 ms against 302
709
+ (and that job still used 1.3 cores, LSF not pinning it). Most of the gain is a core the
710
+ worker was not given, so a worker per slot, as a dask job runs, loses little at 1. Both are plain tensorstore contexts; a caller may pass its own, at construction or through
642
711
  `reopen(context=)` when a long-lived worker changes its concurrency (cellmap-analyze
643
712
  reopens tensorstore on every read today for that reason).
644
713
 
@@ -717,8 +786,8 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
717
786
  unchanged, and a handle pickles by its arguments and its context's spec, so each worker
718
787
  process opens its own cheaply (a local path goes resolved against the working directory
719
788
  at open, so a worker that changes directory before unpickling, as cellmap-analyze's dask
720
- workers do, opens the same store; `reopen` uses it too), on the shared context when the handle had one, else on
721
- a context rebuilt once per process from the same spec, so a capped context stays
789
+ workers do, opens the same store; `reopen` uses it too), on the shared context when the handle had one (the same
790
+ `threads`, and the same cache budget), else on a context rebuilt once per process from the same spec, so a capped context stays
722
791
  capped (mesh-n-bone's per-process cache and thread caps).
723
792
 
724
793
  12. **Group attributes belong to everyone.** The package rewrites only the `multiscales`
@@ -755,7 +824,8 @@ Behaviours 10 to 16 are 0.2; 17 to 19 are 0.3.
755
824
  construction. `partial=True` lifts the check and does tensorstore's read-modify-write
756
825
  of the touched units, documented as unsafe while anything else writes the same unit
757
826
  (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,
827
+ call returns, timed out and retried by Behaviour 6 (0.5; before, it was not, and a
828
+ stranded write waited forever). `write(box,
759
829
  data)` maps a world box by Behaviour 2 and then calls `write_index`; a box reaching
760
830
  outside the array raises (nothing is padded on write) and `voxel_size` is not an
761
831
  argument (nothing is resampled on write).
@@ -942,7 +1012,20 @@ and the error after the attempts; `ensure_group_entry` on a foreign array, idemp
942
1012
  pickled handle keeping a capped context's spec and sharing the shared one, and a relative
943
1013
  path reopening after a `chdir`; `codecs_of` by path; an all-fill write removing its own key;
944
1014
  a scalar filling a box through `write_index`, `write` and `write_chunk`, refused when it
945
- does not fit the dtype.
1015
+ does not fit the dtype. 0.5: a read and a write whose first attempts never finish (a future
1016
+ nothing resolves) cancelled and retried, the warning naming `TimeoutError`, and the error
1017
+ after the last attempt; a capped context and a caller's context passed to an open both
1018
+ kicked by the watchdog, a dead context no longer watched, and a kick landing on a capped
1019
+ context's own pools; `thread_budget` from each variable, a bad value skipped, and the
1020
+ shared context's limits. 0.6: `shared_context(threads=)` at `None`, `N` and `0`, one
1021
+ context per pair, a negative count refused, and a pickled handle coming back on the same
1022
+ shared context. Opt-in (`WORLDARRAY_STRESS=1`, about a minute,
1023
+ `tests/test_lost_wakeup.py`): worker processes aim tasks at the moment a one-thread pool's
1024
+ worker exits idle, so a plain context strands some for good (13 of about 7,000 on
1025
+ tensorstore 0.1.78; 40 to 44 of 12,000 to 15,000 with 16 workers on 0.1.75 and 0.1.85)
1026
+ and a watched one strands none, its slowest task under a second. Run against cellmap-analyze's
1027
+ block loop (a chunk read, then a whole-chunk `write_index`, on its 0.6.1 context), 0.4.0
1028
+ gave 29 `IndexError`s and 55 writes stuck past 20 s, and 0.5.0 none, slowest 0.98 s.
946
1029
 
947
1030
  Grids (0.3): `blocks` partitioning with and without a box, `context` as an int, per axis
948
1031
  and as before/after pairs, clipping on and off, `len`, `blocks[id]` and `index_of`;
@@ -1126,6 +1209,16 @@ over funlib geometry, by this spec's own assignment.
1126
1209
  boxes on one grid. `Grid.blocks` could carry world boxes too (`b.box`, `b.read_box` as
1127
1210
  `Box`, a context in world units). Two lines on cellmap-flow's side today. Not planned
1128
1211
  (2026-10-22): daisy does cellmap-flow's tiling and stays; reopen if cellmap-flow drops it.
1212
+ 14. **When the watchdog goes.** Behaviour 6's watchdog works around tensorstore's lost
1213
+ wakeup, fixed upstream in google/tensorstore ed51718085 (2026-09-30, "Fix lost wakeups
1214
+ in TaskGroup when workers block or exit") and in no release up to 0.1.85. When a release
1215
+ contains it (`gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` reports
1216
+ "ahead" or "identical"), confirm it with `WORLDARRAY_STRESS=1 pytest
1217
+ tests/test_lost_wakeup.py -s` (a plain context should strand nothing), require that
1218
+ release, remove `watch`, `kick` and the watchdog thread from `context.py`, and keep the
1219
+ write retries. That release
1220
+ needs Python 3.11 or later, as tensorstore has since 0.1.81, so it also ends support for
1221
+ 3.10.
1129
1222
 
1130
1223
  ## 8. Constraints for whoever builds on this
1131
1224
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "worldarray"
3
- version = "0.4.0"
3
+ version = "0.6.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.6.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,215 @@
1
+ """Tensorstore contexts: one shared per cache budget, or a capped one per call.
2
+
3
+ ``shared_context(cache_bytes, threads=)`` is one ``tensorstore.Context`` per budget and
4
+ thread limit for the whole process, so the levels of a pyramid and repeat opens of an
5
+ array draw on one cache pool. The default budget is 0: tensorstore's decoded-chunk cache
6
+ is off, because every consumer either reads each region once or caches decoded chunks
7
+ itself. By default its decoding and file I/O pools are sized by ``thread_budget()``, from
8
+ the variables a cluster job sets, where tensorstore alone would size them to every CPU on
9
+ the node; ``threads=0`` leaves them at tensorstore's sizes (chunkmirage's server). Every
10
+ context here, and every one a store is opened on, is watched for tensorstore's lost wakeup
11
+ (``watch``).
12
+
13
+ ``capped_context`` is mesh-n-bone's worker-process context: every tensorstore concurrency
14
+ resource limited, so dozens of worker processes on one host do not multiply into thousands
15
+ of threads. It returns a **new** context each call, as those workers inline the spec into
16
+ every open today; pass the same object twice to share pools (spec, Behaviour 7).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import tempfile
24
+ import threading
25
+ import time
26
+ import weakref
27
+ from typing import Any
28
+
29
+ import tensorstore as ts
30
+
31
+ _shared: dict[tuple[int, int | None], ts.Context] = {}
32
+ _rebuilt: dict[str, ts.Context] = {}
33
+ _lock = threading.Lock()
34
+
35
+
36
+ # tensorstore's lost wakeup. In every release up to at least 0.1.85, a pool worker idle for
37
+ # 20 ms exits, and a task queued as it exits gets no thread while the pool counts itself
38
+ # full: the task never runs and its future waits forever, with no tensorstore thread alive.
39
+ # A pool limited to one thread meets it most (cellmap-analyze's workers on /nrs,
40
+ # 2026-10-22; its read retries of June 2025 were working around the same thing). Any later
41
+ # task on the same pool wakes it. So every context the package makes or opens a store on is
42
+ # watched: a daemon thread gives each one's decoding and file I/O pools a no-op task every
43
+ # KICK_EVERY seconds, and a stranded task, whoever waits on it (worldarray, ``vol.ts``,
44
+ # ``await``), runs within that. Fixed upstream in google/tensorstore ed51718085 (2026-09-30,
45
+ # "Fix lost wakeups in TaskGroup when workers block or exit"). Once a release contains it,
46
+ # require that release and remove the watchdog (spec, open question 14):
47
+ # `gh api repos/google/tensorstore/compare/ed51718085...vX.Y.Z` says "ahead" or "identical"
48
+ # for a release that has it, and `WORLDARRAY_STRESS=1 pytest tests/test_lost_wakeup.py -s`
49
+ # then strands nothing on a plain context.
50
+ KICK_EVERY = 1.0
51
+ _KICK_DIR = tempfile.gettempdir()
52
+ _KICK_KEY = "worldarray-kick-no-such-key"
53
+ _watched: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
54
+ _watch_lock = threading.Lock()
55
+ _watchdog: threading.Thread | None = None
56
+
57
+
58
+ def _kickers(context: ts.Context) -> tuple:
59
+ """An in-memory array and a local kvstore on ``context``: reading the one copies on its
60
+ decoding pool, reading a missing key from the other runs on its file I/O pool."""
61
+ array = ts.open({"driver": "array", "array": [0], "dtype": "uint8"}, context=context)
62
+ kv = ts.KvStore.open({"driver": "file", "path": _KICK_DIR + "/"}, context=context)
63
+ return array.result(timeout=10), kv.result(timeout=10)
64
+
65
+
66
+ def kick(context: ts.Context) -> list:
67
+ """One no-op task on each of ``context``'s decoding and file I/O pools; no store is
68
+ touched. Returns the futures, which the caller holds until done (a dropped future may
69
+ be cancelled before it is queued)."""
70
+ try:
71
+ with _watch_lock:
72
+ kickers = _watched.get(context)
73
+ if kickers is None:
74
+ kickers = _kickers(context)
75
+ with _watch_lock:
76
+ if context in _watched:
77
+ _watched[context] = kickers
78
+ array, kv = kickers
79
+ return [array.read(), kv.read(_KICK_KEY)]
80
+ except Exception: # noqa: BLE001 a kick that cannot start changes nothing
81
+ return []
82
+
83
+
84
+ def watch(context: ts.Context | None) -> ts.Context | None:
85
+ """Kick ``context``'s pools every ``KICK_EVERY`` seconds for as long as it lives, starting
86
+ the watchdog thread on first use. Returns ``context``."""
87
+ global _watchdog
88
+ if context is None:
89
+ return None
90
+ with _watch_lock:
91
+ if context not in _watched:
92
+ _watched[context] = None
93
+ if _watchdog is None or not _watchdog.is_alive():
94
+ _watchdog = threading.Thread(target=_watch, name="worldarray-kick", daemon=True)
95
+ _watchdog.start()
96
+ return context
97
+
98
+
99
+ def _watch() -> None:
100
+ held: list = []
101
+ while True:
102
+ time.sleep(KICK_EVERY)
103
+ with _watch_lock:
104
+ contexts = list(_watched.keys())
105
+ held = [f for f in held if not f.done()]
106
+ for context in contexts:
107
+ held += kick(context)
108
+ del contexts
109
+
110
+
111
+ def _forget_watchdog() -> None:
112
+ global _watchdog
113
+ _watchdog = None # a forked child has no such thread; the next watch() starts one
114
+
115
+
116
+ if hasattr(os, "register_at_fork"):
117
+ os.register_at_fork(after_in_child=_forget_watchdog)
118
+
119
+
120
+ _SLOTS = ("OMP_NUM_THREADS", "LSB_DJOB_NUMPROC", "SLURM_CPUS_PER_TASK", "NSLOTS")
121
+
122
+
123
+ def thread_budget() -> int:
124
+ """Threads for this process's tensorstore decoding and file I/O: ``OMP_NUM_THREADS``
125
+ when set (dask sets 1 in every worker it spawns; a job script sets its slots), else the
126
+ job's slots (LSF's ``LSB_DJOB_NUMPROC``, SLURM's ``SLURM_CPUS_PER_TASK``, SGE's
127
+ ``NSLOTS``), else the CPUs this process may run on."""
128
+ for name in _SLOTS:
129
+ try:
130
+ n = int(os.environ.get(name, ""))
131
+ except ValueError:
132
+ continue
133
+ if n > 0:
134
+ return n
135
+ try:
136
+ return len(os.sched_getaffinity(0))
137
+ except (AttributeError, OSError):
138
+ return os.cpu_count() or 1
139
+
140
+
141
+ def shared_context(cache_bytes: int = 0, *, threads: int | None = None) -> ts.Context:
142
+ """The process's one context for ``cache_bytes`` and ``threads``, made on first use and
143
+ watched. ``threads`` limits its decoding and file I/O pools: ``None`` to
144
+ ``thread_budget()``, a positive number to that many, ``0`` not at all (tensorstore's own
145
+ sizes, every CPU on the node)."""
146
+ key = (int(cache_bytes), None if threads is None else int(threads))
147
+ if key[1] is not None and key[1] < 0:
148
+ raise ValueError(f"threads must be None, 0 or positive, not {threads!r}")
149
+ with _lock:
150
+ ctx = _shared.get(key)
151
+ if ctx is None:
152
+ spec: dict = {"cache_pool": {"total_bytes_limit": key[0]}}
153
+ limit = thread_budget() if threads is None else key[1]
154
+ if limit:
155
+ spec["data_copy_concurrency"] = spec["file_io_concurrency"] = {"limit": limit}
156
+ ctx = watch(ts.Context(spec))
157
+ _shared[key] = ctx
158
+ return ctx
159
+
160
+
161
+ def capped_context_spec(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> dict:
162
+ """The spec dict behind ``capped_context``, for callers building their own opens.
163
+ ``network=False`` caps only decoding and local file I/O, leaving tensorstore's request
164
+ pools alone (cellmap-analyze's choice for remote reads). A ``concurrency`` of 1 is safe:
165
+ the context is watched (see ``KICK_EVERY``). It is not free: a read or write spanning
166
+ several chunks then decodes and stores them one at a time, half the speed of 4 on
167
+ 8-chunk blocks in a two-slot job, 7 to 19% slower in a one-slot one (spec, Behaviour 7)."""
168
+ limit = {"limit": int(concurrency)}
169
+ spec = {
170
+ "data_copy_concurrency": limit,
171
+ "file_io_concurrency": limit,
172
+ "cache_pool": {"total_bytes_limit": int(cache_bytes)},
173
+ }
174
+ if network:
175
+ spec.update(
176
+ gcs_request_concurrency=limit,
177
+ s3_request_concurrency=limit,
178
+ http_request_concurrency=limit,
179
+ )
180
+ return spec
181
+
182
+
183
+ def capped_context(cache_bytes: int = 0, concurrency: int = 1, network: bool = True) -> ts.Context:
184
+ return watch(ts.Context(capped_context_spec(cache_bytes, concurrency, network)))
185
+
186
+
187
+ def recipe_of(context: ts.Context) -> tuple[str, Any]:
188
+ """How another process rebuilds ``context``: ``("shared", cache_bytes)`` for a shared
189
+ context at the default limit (the other process takes its own ``thread_budget()``),
190
+ ``("shared", (cache_bytes, threads))`` for one with ``threads`` given, else
191
+ ``("spec", json)`` from the context's own spec, so a capped context stays capped across
192
+ a pickle (Behaviour 11, mesh-n-bone's workers)."""
193
+ with _lock:
194
+ for (cache_bytes, threads), ctx in _shared.items():
195
+ if ctx is context:
196
+ return ("shared", cache_bytes if threads is None else (cache_bytes, threads))
197
+ spec = context.spec
198
+ return ("spec", json.dumps(spec.to_json() if spec is not None else {}, sort_keys=True))
199
+
200
+
201
+ def from_recipe(recipe: tuple[str, Any] | None) -> ts.Context | None:
202
+ """``recipe_of`` inverted; one rebuilt context per spec per process, so handles that
203
+ shared a context keep sharing one."""
204
+ if recipe is None:
205
+ return None
206
+ kind, value = recipe
207
+ if kind == "shared":
208
+ cache_bytes, threads = value if isinstance(value, (tuple, list)) else (value, None)
209
+ return shared_context(int(cache_bytes), threads=threads)
210
+ with _lock:
211
+ ctx = _rebuilt.get(value)
212
+ if ctx is None:
213
+ ctx = watch(ts.Context(ts.Context.Spec(json.loads(value))))
214
+ _rebuilt[value] = ctx
215
+ 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
@@ -208,3 +301,23 @@ def test_gce_guard_is_left_alone_on_a_google_machine(monkeypatch):
208
301
  monkeypatch.setattr(store, "_on_google_cloud", lambda: False)
209
302
  store.kvstore_specs("gs://bucket/vol")
210
303
  assert os.environ["GCE_METADATA_ROOT"] == "metadata.google.internal.invalid"
304
+
305
+
306
+ def test_a_shared_context_takes_the_callers_thread_count(monkeypatch):
307
+ monkeypatch.setenv("OMP_NUM_THREADS", "1")
308
+ monkeypatch.setattr(wa.context, "_shared", {})
309
+ default = wa.shared_context(7)
310
+ assert default.spec.to_json()["data_copy_concurrency"] == {"limit": 1}
311
+ four = wa.shared_context(7, threads=4).spec.to_json()
312
+ assert four["data_copy_concurrency"] == four["file_io_concurrency"] == {"limit": 4}
313
+ machine = wa.shared_context(7, threads=0)
314
+ assert machine is wa.shared_context(7, threads=0) and machine is not default
315
+ spec = machine.spec.to_json()
316
+ assert "data_copy_concurrency" not in spec and "file_io_concurrency" not in spec
317
+ assert spec["cache_pool"] == {"total_bytes_limit": 7}
318
+ assert machine in wa.context._watched
319
+ assert wa.context.recipe_of(machine) == ("shared", (7, 0))
320
+ assert wa.context.from_recipe(("shared", (7, 0))) is machine
321
+ assert wa.context.from_recipe(("shared", 7)) is default
322
+ with pytest.raises(ValueError):
323
+ wa.shared_context(threads=-1)
@@ -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)
@@ -783,6 +826,8 @@ def test_a_pickled_handle_keeps_its_context(tmp_path):
783
826
  ) # one rebuilt context per spec
784
827
  shared = pickle.loads(pickle.dumps(wa.open(path)))
785
828
  assert shared.context is wa.shared_context(0)
829
+ machine = wa.shared_context(0, threads=0)
830
+ assert pickle.loads(pickle.dumps(wa.open(path, context=machine))).context is machine
786
831
 
787
832
 
788
833
  def test_codecs_of_a_path_and_the_fill_write_that_removes_a_key(tmp_path):
@@ -1247,7 +1247,7 @@ wheels = [
1247
1247
 
1248
1248
  [[package]]
1249
1249
  name = "worldarray"
1250
- version = "0.4.0"
1250
+ version = "0.6.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