Skip to content

fix(migrations): a leftover --replace aside is refused by name and never deleted blindly (panel F6) - #637

Merged
0x054 merged 1 commit into
feat/pinch-migration-needsfrom
sdd/fix-629-panel
Oct 12, 2026
Merged

0x054 merged 1 commit into
feat/pinch-migration-needsfrom
sdd/fix-629-panel

Conversation

@0x054

@0x054 0x054 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Part of #623 (panel fix round for PR #636; #629's files)

F6: --replace in place, a hard kill leaves the migration hidden and check does not name it

Before: --replace renamed 0002_add_x to .0002_add_x.replaced and then wrote the new directory. If the process was killed between those two steps, the only copy of the migration (hand-written and data steps included) sat in a dot-directory that the reader skips. check and status reported nothing, and a later --replace ran rmtree on the aside.

After: every directory read (check, up, down, status, new, new --replace) refuses a leftover that Ferro wrote. The refusal names the directory and how to recover:

$ ferro migrate check
interrupted_replace: .0002_add_x.replaced is 0002_add_x, set aside by an interrupted `ferro migrate new --replace 0002_add_x`; 0002_add_x was never written. Rename .0002_add_x.replaced back to 0002_add_x, then run `ferro migrate new add_x --replace 0002_add_x` again
interrupted_replace: .0002_add_x.0003_add_x.replaced is 0002_add_x, set aside by an interrupted `ferro migrate new --replace 0002_add_x`; its replacement 0003_add_x is complete. Delete .0002_add_x.0003_add_x.replaced
interrupted_write: .0003_more.partial is part of 0003_more, left by an interrupted `ferro migrate new`; 0003_more was never written. Delete .0003_more.partial, then run `ferro migrate new` again

What changed:

  • crates/ferro-migrate/src/directory.rs: two new DirectoryError kinds, InterruptedReplace (interrupted_replace) and InterruptedWrite (interrupted_write). MigrationsDir::read and read_replacing both check for them before any other check. An aside is reported before a partial because it may hold the only copy. Hidden names that Ferro did not write are still ignored.
  • src/ferro/migrations/generate.py (write): the aside's name now includes the replacement's name when the number changes (.0002_add_x.0003_add_x.replaced), so the refusal can check whether the replacement is in place. Because the new directory appears with a single rename, its presence means it is complete. A renumbered replace now also moves the old directory aside before writing, so a kill mid-cleanup never leaves a half-deleted directory under a visible name. At every moment, either a readable migration sits under a visible name or the reader names what was in flight. If an aside already exists, write refuses instead of deleting it.
  • src/ferro/migrations/layout.py (write_migration): if a .partial staging directory already exists, it refuses instead of deleting it.
  • Guide, "Two branches, one number": one Interrupted bullet.
  • No existing refusal text changed. DuplicateNumber and its --replace lines are untouched.

Covering tests:

  • tests/test_migrate_replace.py:
    • test_a_replace_killed_after_setting_aside_is_named_by_every_reader: a simulated crash (aside by hand, no 0002_add_x). check exits 3 and names the aside and the recovery. new --replace and plain new refuse and leave the tree byte-for-byte unchanged. After renaming the aside back, check exits 0 and the replace succeeds.
    • test_a_renumbered_replace_killed_before_its_cleanup_names_the_aside_to_delete
    • test_a_renumbered_replace_killed_before_writing_names_the_rename_back
    • test_a_write_killed_halfway_names_the_partial_to_delete
    • test_a_replace_leaves_nothing_hidden_behind
  • crates/ferro-migrate/src/directory.rs: a_leftover_replace_aside_or_partial_write_is_refused_by_every_read, which checks the kinds, the exact texts for both reads, and that the aside is reported before the partial.

RED before the fix: the four crash tests failed. check exited 0, or reported ungenerated, and new --replace exited 1 for a different reason. The Rust test failed because read returned Ok. All are GREEN after the fix.

Verify

Run on b870726 with FERRO_POSTGRES_URL=postgresql://postgres:password@127.0.0.1:5432/postgres?sslmode=disable UV_FROZEN=1:

uv run maturin develop                       -> Installed ferro-orm-0.22.0; exit 0
cargo check --all-targets                    -> Finished; exit 0
cargo test -p ferro-migrate                  -> 427 passed; 0 failed; exit 0
uv run pytest --db-backends=sqlite,postgres tests/test_migrate_replace.py tests/test_migrate_new.py tests/test_migrate_check.py tests/test_migrate_up.py tests/test_docs_migrations.py
                                             -> 186 passed, 6 skipped; exit 0
uv run prek run --all-files                  -> all Passed; exit 0

Outside Files owned

None.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

…ver deleted blindly (panel F6)

A kill between `--replace` setting the old migration aside and deleting it
left the only copy in a dot-directory the reader skipped, and a later
--replace deleted it. Every directory read now refuses a Ferro-shaped
leftover aside (interrupted_replace) or half-written staging directory
(interrupted_write), naming it and the recovery: rename it back while the
replacement is absent, delete it once the replacement is complete. The
aside names its replacement (.NNNN_x.MMMM_y.replaced when renumbered), a
renumbered replace also sets the old directory aside first, and an existing
aside or staging directory is refused instead of removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Scoped re-review: F6

Verdict: ADDRESSED at b870726.

  • (a) Walked the sequence in generate.write (generate.py:259-275) and write_migration (layout.py:259-270). Before old.rename(aside), the old directory is readable. After it, the reader refuses on the aside, complete=false, with rename-back. During staging, the aside is refused first and then the .partial. After the staging rename, the new directory is whole and the aside reads complete=true, with delete. A kill during the final rmtree is covered by the same state.
  • (b) The texts name the fix (directory.rs:530-566). The kinds interrupted_replace and interrupted_write are stable, and the golden test pins both texts.
  • (c) interrupted_writes (directory.rs:846-893) matches only .NNNN_<name>[.MMMM_<new>].replaced and .NNNN_<name>.partial, via split_numbered. Other dot-names stay ignored.
  • (d) The diff leaves DuplicateNumber and every existing text unchanged. No path deletes an aside or a .partial: both are refused (generate.py:263, layout.py:261).

No new findings. A concurrent check during a live new will refuse transiently, which is acceptable.

Verify at b870726: maturin develop 0; cargo check --all-targets 0; cargo test -p ferro-migrate 0 (427 passed); pytest sqlite+postgres 0 (186 passed, 6 skipped); prek 0. The merge preview was "Already up to date" and cargo check --all-targets passed (0), so there was no merge to abort.

@0x054
0x054 merged commit 36670c4 into feat/pinch-migration-needs Oct 12, 2026
6 checks passed
@0x054
0x054 deleted the sdd/fix-629-panel branch October 12, 2026 02:44
0x054 added a commit that referenced this pull request Oct 12, 2026
…ver deleted blindly (panel F6) (#637)

Panel finding F6 on PR #636: `ferro migrate new --replace` set the old directory aside under a hidden name before writing the new one, and a hard kill in between left the only copy where the directory reader skipped it and a later `--replace` deleted it first. Every directory read now refuses a ferro-named leftover (`interrupted_replace`, `interrupted_write`) naming the recovery, a renumbered replacement's aside carries the new directory's name so the reader can tell whether it completed, and nothing deletes an existing aside.

Part of #623

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
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.

1 participant