Skip to content

feat(tagging): rename before writing, and sync the database inline - #826

Merged
ajslater merged 3 commits into
developfrom
feat/rename-first-sync
Aug 26, 2026
Merged

feat(tagging): rename before writing, and sync the database inline#826
ajslater merged 3 commits into
developfrom
feat/rename-first-sync

Conversation

@ajslater

Copy link
Copy Markdown
Owner

The redesign the audit pointed at. Renaming now happens before the write, and every move it causes is applied to the database before write_tags returns — which removes the window three earlier rounds of fixes were narrowing without closing.

Requires comicbox 4.8.6 (already pinned) for predict_filename().

The window, and why it was the design

Renaming used to happen after the write, and the database was told about it afterwards by an ImportTask queued behind whatever else the scribe was doing. Between those two moments the row pointed at a path that no longer existed. Any scan processed in that gap saw an unexplained delete plus create, deleted the row, and cascaded the comic's bookmarks and read progress away.

Every previous fix — recording watched-library renames, chasing conversions to final_path, the guard registry — made the gap smaller. None removed it, because the gap was the design.

So the order is inverted:

  1. Predict each archive's final name (comicbox does the predicting, with the pending patch overlaid so the name reflects the tags about to be written).
  2. Rename on disk.
  3. Apply the moves to the database, inline, on the scribe's own thread.
  4. Write tags at the final path.
  5. Sync a conversion's move the same way as soon as the batch finishes.

No scan can be processed while the two disagree. A stale delete finds no row; a stale create converges onto the row already at that path. What stays queued is only the metadata re-read, which is safe because it names a path the database already holds.

Verified end-to-end against real comicbox: a junk-named .cbt is renamed, converted to .cbz, and the row follows it — same pk, and the queued task carries files_moved={}.

What this closes

Deleting the guard registry (tagwrite_moves) removes the apparatus and the things it could not do — all findings from the audit:

  • the guard was released at the start of the import carrying the move, not after the move applied, so an abort or a dropped move stranded the row on a dead path
  • it never filtered scanner-inferred moves, so a mispaired move onto a guarded source was applied anyway
  • being process-local, it could not survive a librarian restart
  • it could not help a second tag write, which resolves paths from the database

That last one was a user-visible bug: a follow-up edit of the same comics used to fail every write with "no such file", because BulkTagWriteTask outranks ImportTask and the sync could never run between two queued writes. There is now nothing to run between them.

Notes for review

  • Renaming is planned for the whole batch first. Two comics predicting one name, or a name already taken on disk or in the database, are reported rather than colliding. Both the interim and the post-conversion destination are checked, since comicbox refuses to convert onto an existing file and that refusal would land after the rename and its move.
  • A move the database refuses puts the file back, so disk and database cannot diverge. Failures are read back from the database rather than inferred from the move phase's count, which reports nothing about which comic it dropped and can return zero after the rows were already updated.
  • Kept-original conversions are deliberately excluded from rename-first: the row stays on the untouched original, and the new CBZ is named after the write, exactly as before.
  • library_events is gone. Watched or not no longer changes anything, and the re-read is requested either way because a watched library's own re-read is stat-only unless the import-metadata flag is on.
  • COMICBOX_RENAME_CONFIG is gone too — it was a workaround for the extension bug that comicbox 4.8.6 now fixes at the source.
  • Never calls the importer's apply()/finish()finish() would end this batch's live progress status. Only the move phase runs, per library, wrapped so one library's failure can't abandon the rest.
  • The test suite was rewritten for the new architecture; the case-only-rename and refused-move tests were both checked to fail against a deliberately broken implementation. make lint, make ty, full pytest and vitest all pass.

Remaining from the audit after this: the poller pairing relaxation (PR E) and the watcher unmount guard.

ajslater and others added 3 commits August 25, 2026 19:51
Renaming used to happen after the write, and the database was told about
it afterwards by an ``ImportTask`` queued behind whatever else the scribe
was doing. Between those two moments the row pointed at a path that no
longer existed, and any scan processed in the gap saw an unexplained
delete plus create: it deleted the row and cascaded the comic's bookmarks
and read progress away. Three rounds of fixes narrowed that gap without
closing it, because the gap was the design.

So the order is inverted. Each archive is renamed to the name comicbox
predicts for it — with the pending patch overlaid, so the name reflects
the tags about to be written — and every resulting move is applied to the
database before ``write_tags`` returns, on the scribe's own thread. No
scan can be processed while the two disagree. A stale delete then finds
no row, and a stale create converges onto the row already at that path.

A conversion still moves the file after the write, so it is synced the
same way the moment the batch finishes. What stays queued is only the
metadata re-read, which is safe precisely because it names a path the
database already holds.

Consequences:

- Renaming is planned for the whole batch first, so two comics predicting
  one name, or a name already taken on disk or in the database, are
  reported instead of colliding. Both the interim and the post-conversion
  destination are checked, since comicbox refuses to convert onto an
  existing file and that refusal would land after the rename.
- A move the database refuses puts the file back, so disk and database
  cannot diverge.
- A second edit of the same comics now resolves the renamed paths, where
  before every write in it failed with "no such file".
- Whether a library is watched no longer changes any of this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The other half of the guard: the importer no longer holds back paths an
in-flight tag write is moving through, because there is no longer a
window in which to hold them. Moves are applied before ``write_tags``
returns, so any scan the importer processes is reconciling against a
database that already agrees with the disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ajslater
ajslater merged commit b6c0630 into develop Aug 26, 2026
3 checks passed
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