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.
- {worldarray-0.4.0 → worldarray-0.5.0}/CHANGELOG.md +40 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/PKG-INFO +10 -5
- {worldarray-0.4.0 → worldarray-0.5.0}/README.md +9 -4
- {worldarray-0.4.0 → worldarray-0.5.0}/docs/spec.md +85 -11
- {worldarray-0.4.0 → worldarray-0.5.0}/pyproject.toml +1 -1
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/__init__.py +3 -2
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/array.py +24 -8
- worldarray-0.5.0/src/worldarray/context.py +205 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/retry.py +44 -13
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/store.py +3 -1
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/write.py +2 -1
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_access.py +94 -1
- worldarray-0.5.0/tests/test_lost_wakeup.py +76 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_write.py +43 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/uv.lock +1 -1
- worldarray-0.4.0/src/worldarray/context.py +0 -84
- {worldarray-0.4.0 → worldarray-0.5.0}/.github/workflows/ci.yml +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/.github/workflows/docs.yml +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/.github/workflows/publish.yml +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/.gitignore +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/LICENSE +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/docs/changelog.md +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/docs/index.md +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/mkdocs.yml +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/geometry.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/grid.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/levels.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/placement.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/resample.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/src/worldarray/units.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/conftest.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/fixtures/metadata.json +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/fixtures/write.json +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_geometry.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_grid.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_metadata.py +0 -0
- {worldarray-0.4.0 → worldarray-0.5.0}/tests/test_read.py +0 -0
- {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.
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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
|
|
633
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
47
|
-
"""``
|
|
48
|
-
(
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
54
|
-
future =
|
|
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 <
|
|
71
|
+
if attempt < attempts:
|
|
65
72
|
delay = retry.base_delay * 1.3 ** (attempt - 1) + random.random()
|
|
66
73
|
logger.warning(
|
|
67
|
-
"
|
|
74
|
+
"%s %s failed on attempt %d/%d (%s); retrying in %.1fs",
|
|
75
|
+
verb,
|
|
68
76
|
what,
|
|
69
77
|
attempt,
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|