Skip to content

subset, recast, split, concat: write beside the output, rename into place - #42

Merged
Claptar merged 2 commits into
mainfrom
fix/staged-writes
Oct 15, 2026
Merged

Claptar merged 2 commits into
mainfrom
fix/staged-writes

Conversation

@Claptar

@Claptar Claptar commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #35, plus the other non-atomic writes found alongside it. These are data-safety fixes for 0.7.1.

What was wrong

  • subset x.zarr -o x.zarr emptied the store. The output was opened "w" before the selection was read from the source it had just cleared, and the command reported success.
  • A failed selection still left an empty output. That included truncating an existing -o target.
  • subset --inplace and recast --inplace deleted the source, then moved the new store in. An interrupt between the two steps lost the data. recast's module docstring called this "an atomic swap".
  • recast -o, split and concat wrote straight into their outputs. A failure left a partial store, which the next command would read as a finished one.

What changed

  • subset: resolves the selection before any output exists, and refuses an output that names the input (use --inplace). Overwriting an existing -o output still works as before; the old output is replaced only once its successor is complete.
  • subset, recast, split and concat: all now write through util/staging.staged_outputs, as convert already did.
    • --inplace renames the source aside and deletes it only after the new store is in place.
    • split writes its manifest last, and refuses an output path that lands on the input.
  • staged_outputs is hardened:
    • it refuses a leftover *.adata-old.* instead of deleting it, since after an interrupted swap that backup may be the only copy;
    • if a rename fails part-way, it puts back the outputs it has already swapped;
    • the temporary copy of a Zarr store named without the .zarr suffix now stays Zarr (store.adata-tmp would otherwise be opened as HDF5).
  • same_path moves to util/path.py. convert's copy answered "different paths" when it couldn't resolve them, which is the unsafe answer.
  • Cleanup: recast's unused ALL_PREFIXES is removed.

Tests

  • tests/test_write_safety.py reproduces each bug on both h5ad and Zarr. 13 of its tests fail on main.
  • tests/test_staging.py covers the helper's failure paths. It also takes over the two direct staging tests from test_convert.py.
  • Locally:
    • pytest -m "not integration": 1221 passed, coverage 94.1%;
    • anndata compatibility suite: 204 passed;
    • perf guards unchanged and green.
  • Manual check: SIGINT and SIGTERM sent mid-way through recast --inplace on a 300k × 3k store (18M nonzeros), for both h5ad and Zarr.
    • In every case the source still reads correctly.
    • After SIGINT, nothing is left behind.
    • After SIGTERM, the *.adata-tmp* copy stays, and the next run refuses to start until it is removed.

#36 (concat's Zarr version) is stacked on this branch in a separate PR.

🤖 Generated with Claude Code

Claptar and others added 2 commits October 15, 2026 14:29
Fixes #35 and the other non-atomic writes found alongside it.

- subset: resolve the selection before the output exists, refuse an
  output that names the input (on Zarr it emptied the store), and write
  through staged_outputs, so a failed query leaves nothing and an
  existing output survives a failure.
- subset/recast --inplace: the source was deleted before the new store
  was moved in; it is now renamed aside and deleted only afterwards.
  recast -o is staged too.
- split: stage every output, write the manifest last, refuse an output
  that lands on the input.
- staged_outputs: refuse a leftover *.adata-old.* rather than deleting
  what may be the only copy, put back already-swapped outputs if a
  rename fails, and keep a suffixless Zarr store's temporary a Zarr store.
- same_path moves to util/path.py; convert's copy answered "different"
  when it could not tell. Drop recast's unused ALL_PREFIXES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failure part way through left a partial store at -o, which then read
as a finished one and blocked the rerun (concat refuses an existing
output).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 15, 2026 13:34

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (py3.12)

1 217 tests   1 217 ✅  2m 34s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 8a3550a.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a3550a54e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/util/staging.py
Comment on lines +30 to +33
if path.is_dir() and path.suffix != ".zarr":
# A Zarr store named without the suffix. A temporary named the same
# way would not exist yet, and so be opened as HDF5.
temp = temp.with_name(f"{temp.name}.zarr")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify a directory is Zarr before replacing it

When a subset, recast, or split destination is an existing ordinary directory without a .zarr suffix, path.is_dir() incorrectly classifies it as a suffixless Zarr store. The temporary path consequently receives .zarr, writing succeeds there, and the swap later moves the original directory aside and recursively deletes it as a backup. Previously open_store() inspected the destination and rejected directories lacking Zarr metadata, so a typo such as -o existing-directory now destroys unrelated contents; use the Zarr-content check rather than treating every directory as a store.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T13:37:25.804237Z 8a3550a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (py3.13)

1 217 tests   1 217 ✅  4m 1s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 8a3550a.

@Claptar
Claptar merged commit 3946973 into main Oct 15, 2026
6 checks passed
@Claptar
Claptar deleted the fix/staged-writes branch October 15, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

subset: a failed selection leaves a half-written output, and -o equal to a Zarr input destroys it

2 participants