Skip to content

feat(acquisition): save Zarr v3 in the acquisition process - #691

Open
Alpaca233 wants to merge 16 commits into
masterfrom
feat/in-process-zarr-saving
Open

Alpaca233 wants to merge 16 commits into
masterfrom
feat/in-process-zarr-saving

Conversation

@Alpaca233

Copy link
Copy Markdown
Collaborator

Problem

tensorstore aborts any fork()ed child of a process that has already used it, on the child's first tensorstore call:

aborting: fork() use detected, which is not allowed due to internal threading use by tensorstore

Zarr v3 is written by SaveZarrJob inside the forked JobRunner subprocess, and the subprocess for the next acquisition is forked at the start of the current one. So once the main process has used tensorstore, every later Zarr v3 acquisition is saved by a subprocess that dies on its first write. The main process does not notice: the backpressure wait times out per image (30 s after the 10th pending job), the run logs Pending jobs for SaveZarrJob abandoned!!! and ends "normally" with no data on disk.

On master today the trigger is the NDViewer (enable_ndviewer), which opens stores with tensorstore in the main process: Zarr v3 + viewer loses the third acquisition and every one after it. #564 (Record + Z-Stack) adds a recording writer that uses tensorstore in the main process on every recording, which makes this hit every session. Measured in simulation: runs 1–2 complete, runs 3–4 lose the z-stack entirely.

What changes

Zarr v3 is saved in the acquisition process; no subprocess is forked for it.

  • InProcessZarrRunner (new, control/core/in_process_zarr_runner.py): presents the part of JobRunner's interface the worker uses (dispatch, output_queue, has_pending, shutdown, …) and runs SaveZarrJob here. dispatch submits the tensorstore write and returns (~1 ms in the camera callback; the first frame of each store also creates it there, ~2 ms median / 17 ms max on a local SSD); a drain thread waits on each future in order, keeps the same backpressure counters, and reports JobResults on the same output queue. shutdown(aborted=...) waits for outstanding writes and seals every store.
  • ZarrWriterRegistry: the open writers and HCS plate/well bookkeeping were class-level on SaveZarrJob (per-acquisition only because each acquisition had its own subprocess). Now an injectable object; the in-process runner owns one per acquisition, the subprocess keeps SaveZarrJob.default_registry.
  • ZarrWriter.submit_frame() / SaveZarrJob.submit(): the non-blocking halves of write_frame() / run(), which are now submit-then-wait with unchanged behavior.
  • ZarrWriter no longer uses a per-thread asyncio event loop (it used one only for ts.open). With initialize on the camera callback thread and finalize on the worker thread, the loop state broke the second acquisition on cameras whose callback thread persists; tensorstore futures are waited on directly now.
  • MultiPointWorker / MultiPointController.close(): SaveZarrJob gets the in-process runner (and an abnormal app shutdown seals its stores instead of trying to terminate() it); the TIFF job classes keep the forked subprocess exactly as before. MultiPointController.get_prewarmed_job_runner() hands nothing to a Zarr v3 run, so the warm subprocess stays ready for a later TIFF run.
  • Loud failure: a save subprocess that dies with jobs pending, a save job of any class whose failure only surfaces in the final drain, or a save backlog that outlives the end-of-run wait ("Pending jobs … abandoned") now ends the acquisition as an error instead of a quiet "completed" with data missing.

What does not change

  • The forked JobRunner path for SaveImageJob / SaveOMETiffJob: same start method, same pre-warm, same pickling.
  • ZarrWriter is still the only Zarr writer and writes the same bytes. The multiprocessing start method stays fork.

Behavior changes to know about

  • A Zarr v3 store of an acquisition that was aborted (user Stop, error, or a crash out of the worker) is now sealed acquisition_complete: false, aborted: true. The subprocess sealed every store acquisition_complete: true regardless.
  • Acquisition.USE_MULTIPROCESSING = False with Zarr v3 now works and seals the store. On master that path aborted on the first image (zarr_writer_info was never injected in the in-process fallback).
  • main_hcs_worker.log is no longer written for Zarr v3 runs (no subprocess); the Finalized zarr writer lines are in main_hcs.log.

Measurement (why in-process, not spawn/forkserver)

Z-stack phase only, 2 channels × 11 planes of 2048×2048 uint16, simulated camera, three runs each, fresh process per run:

Save path Z-stack phase wall time Camera callback blocked per image
Subprocess, as on master (fork + pickle each image) 1.28–1.34 s ~0 ms (copy on a feeder thread)
In-process, existing fallback (waits on every write) 1.99–2.08 s 36–38 ms
In-process, non-blocking submits (this PR) 1.06–1.09 s 1.0 ms

tensorstore compresses and writes on its own threads, so the subprocess only added a copy of every image. A spawn/forkserver subprocess would also fix the abort but costs 1.6 s to become ready (vs 8 ms) and 224 MB idle (vs 83 MB).

Tests

  • New: tests/control/core/test_in_process_zarr_runner.py (9), registry / submit() / submit_frame() tests in tests/control/core/test_zarr_writer.py, and in tests/control/test_MultiPointController.py: Zarr v3 multipoint saves in-process and seals; sealed with multiprocessing off; abort mid-run seals aborted; a Zarr run neither consumes nor restarts the warm subprocess; three Zarr runs in one process all sealed; a write error ends the run as an error (per-FOV and end-of-run); a dead TIFF subprocess aborts the run.
  • Full suite in one process passes locally (GitHub CI does not install tensorstore, so it skips every tensorstore-dependent test; a green CI says nothing about this path).

Merge gate: bench on the instrument

  1. Multipoint saved as Zarr v3, 3 acquisitions back to back with the NDViewer enabled, on a camera whose frame-callback thread persists across runs (Hamamatsu, ToupTek): every OME group zarr.json has acquisition_complete: true; no aborting: fork() and no Event loop is closed in the terminal. (On today's master the third run loses its data.)
  2. Multipoint OME-TIFF, 2 acquisitions: unchanged behavior (the subprocess path).
  3. Per-image time of a Zarr v3 z-stack at 2×2 binning 16-bit, master vs this branch: must not be slower. Also a many-FOV run with 1–3 images per FOV (store creation now happens on the first frame of each FOV, in the callback): per-image time must not be slower.
  4. main_hcs.log carries the Finalized zarr writer lines; no main_hcs_worker.log for the Zarr runs.

🤖 Generated with Claude Code

Alpaca233 and others added 15 commits October 7, 2026 01:35
…gistry

The open writers and the HCS plate/well bookkeeping were class-level, which
meant per-acquisition only because each acquisition had its own save
subprocess. ZarrWriterRegistry makes that explicit and injectable, so an
acquisition saving in the main process gets its own. The subprocess keeps
using SaveZarrJob.default_registry; clear_writers() and finalize_all_writers()
delegate to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rrJob.submit()

Both return the tensorstore write future instead of waiting on it; write_frame()
and run() are now submit then wait, unchanged in behavior. Needed by the
in-process runner, which must not block the camera callback on a write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
JobRunner's interface, SaveZarrJob.submit() underneath: dispatch submits the
write and returns in about a millisecond; a drain thread waits for each write,
keeps the backpressure counters and reports JobResults on the output queue.
Not wired to the workers yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A frame dispatched after the stores are sealed would open a writer nothing
finalizes and leave the backpressure counters raised. dispatch() now returns
False, which the worker already treats as a reason to abort.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er; settle counters in tests

A dispatch racing shutdown could land behind the sentinel and never drain.
The tests also read the backpressure counters right after the result was
queued, before the drain thread's finally had run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SaveZarrJob never gets a forked subprocess any more: MultiPointWorker gives
it an InProcessZarrRunner. The TIFF formats keep the subprocess. Also seals
the store at the end when multiprocessing is off, which the old in-process
path never did, and seals an aborted run's stores as aborted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…later TIFF run

With Zarr saved in-process, handing the pre-warmed subprocess to a Zarr run
only released it and forked a replacement. Nothing is forked for Zarr now, so
a process that has used tensorstore (the NDViewer, or a recording) can no
longer lose a later Zarr acquisition to the tensorstore fork abort.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dead save subprocess, or a failed Zarr write surfacing at the end, used to
be waited out and the run finished as if nothing had happened, with images
never written. The worker now aborts with an error in both cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s the run as an error

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ead can open stores run after run

initialize() runs on the camera frame callback thread. It created and
set an asyncio loop for that thread, and finalize()/abort() on the
worker thread closed it, leaving the closed loop as the thread's current
loop. Real cameras keep one read thread for their lifetime, so every
store of the next Zarr acquisition failed with "Event loop is closed".

Open the store and wait on pending writes with tensorstore futures'
.result() instead; drop the loop bookkeeping. Spec, codecs, metadata and
written bytes are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d of terminating it

The abnormal-shutdown loop in MultiPointController.close() called
terminate()/join()/kill() on every live runner. InProcessZarrRunner has
no terminate()/join(), so close() logged an AttributeError and the open
Zarr stores were never sealed. Shut it down with aborted=True instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When _finish_jobs timed out with jobs still pending it logged them as
abandoned and killed the runner, but the run was still reported as
completed, while the in-process Zarr runner sealed those stores as
aborted. Missing data is an error: abort the run once after the loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- in_process_zarr_runner: the process's tensorstore user today is the
  NDViewer (a recording writer will be).
- SaveZarrJob: it writes through its registry, with zarr_writer_info
  injected by whichever runner dispatches it; the missing-info error
  names submit()/run().
- multi_point_controller: the pre-warm comments apply to TIFF formats; a
  Zarr v3 run gets (None, None).
- test: open zarr.json with a with block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…selves subprocesses

A bare MagicMock answers runs_in_process truthily, so close() took the new
in-process branch and never called terminate().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Shutdown can seal and clear a Zarr store while TensorStore writes remain active after the drain timeout.

1 open finding
What changed in this PR

Moves Zarr v3 saving into the acquisition process to avoid TensorStore failures in forked subprocesses.

Changes:

  • Adds asynchronous in-process Zarr writes and per-acquisition writer registries.
  • Updates acquisition lifecycle, backpressure, failure handling, and store sealing.
  • Adds comprehensive tests and documentation.
File Description
software/​control/​core/​in_process_zarr_runner.py Adds the in-process Zarr runner.
software/​control/​core/​job_processing.py Adds writer registries and non-blocking job submission.
software/​control/​core/​zarr_writer.py Uses TensorStore futures without asyncio.
software/​control/​core/​multi_point_worker.py Routes Zarr saving in-process and propagates failures.
software/​control/​core/​multi_point_controller.py Preserves prewarmed TIFF runners and handles Zarr shutdown.
software/​docs/​zarr-v3-format.md Documents in-process Zarr saving.
software/​tests/​control/​core/​test_in_process_zarr_runner.py Tests runner lifecycle, failures, and backpressure.
software/​tests/​control/​core/​test_zarr_writer.py Tests registries, futures, and cross-thread use.
software/​tests/​control/​test_MultiPointController.py Adds acquisition-level Zarr integration tests.
software/​tests/​control/​core/​test_backpressure.py Updates subprocess shutdown mocks.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +114 to +118
self._thread.join(timeout=max(timeout_s, 1.0))
if self._thread.is_alive():
self._log.warning("drain thread still waiting on a write after the shutdown timeout; sealing anyway")
if self._killed or aborted:
self.registry.abort_all()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in 79f5075 - shutdown() now returns False when a write is still in flight after the bounded join and seals the stores as aborted instead of stamping them complete; the worker treats False as an error and aborts the run. Covered by test_shutdown_with_a_write_still_in_flight_seals_aborted_and_reports_it.

- InProcessZarrRunner.shutdown() never stamps a store complete over a write it
  did not see finish: if the drain thread is still blocked after the bounded
  join, the stores are sealed aborted and shutdown() returns False, which the
  worker turns into an error end (Copilot review).
- One failure policy: _summarize_job_result requests the abort when a save job
  fails and abort_on_failed_job is set; the per-FOV and end-of-run copies go.
- The backpressure counter contract lives in backpressure.note_job_dispatched /
  note_job_completed, used by JobRunner and the in-process runner alike.
- The drain thread holds the job id, not the job (and its frame), while a
  write is in flight; a failed submit is a concurrent.futures.Future instead
  of a bespoke class; isinstance(InProcessZarrRunner) replaces the duck-typed
  runs_in_process flag.
- ZarrWriter loses the never-populated pending-futures API (wait_for_pending,
  pending_write_count) and write_frame is one line over submit_frame.
- Tests: one Zarr v3 controller fixture and one failing-submit helper replace
  six copies of the same setup; runner tests reuse make_test_capture_info and
  gain the blocked-write shutdown case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants