fix(attach): only reassign DB after a successful physical move (bookshelf-8l09k) #1461
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-8l09k"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Root cause
internal/books/attach_service.go'sAttachFilescommitted the DB libraryreassignment (Phase 2, a single atomic transaction) UNCONDITIONALLY, then
attempted the physical file move afterward (Phase 3), recording any move
failure in
MovesFailedpost-commit. A failed move therefore leftbook_filerows pointing at the target library while the physical fileremained at the source path (or was gone) — contradicting the documented
"skip-on-failed-move, no data loss" guarantee. Pre-existing bug, surfaced
during f8gf8/#1454 review.
Chosen approach: move-then-commit
Of the three options considered (move-then-commit / compensating rollback /
tx-scoped-to-moved-files), move-then-commit is simplest and safest given the
existing hard-delete of source books:
reassigned source books. Undoing a hard
DELETEafter a downstream movefailure would require reconstructing the deleted row from scratch — much
riskier than never deleting it in the first place.
per-file guard pattern (null bytes, symlinks, source-containment,
dest-exists, mkdir). The physical move is just one more guard: attempt it
before building the final
fileIDs/reassign-count lists, and treat afailed move exactly like every other guard failure — exclude the file
from DB reassignment (and therefore from its source book's hard-delete
eligibility). No new state-tracking machinery was needed.
What changed
stays owned by its source book, so the DB and filesystem never disagree
about where a file lives.
guard-passed and (for
move_files=true) actually moved.MoveFailure/AttachResult.MovesFaileddoc comments updated to describe"excluded from reassignment" instead of "DB already committed, file
stranded."
one just closed: if the DB transaction itself fails (e.g. a lost DB
connection) AFTER files have already been moved on disk, those files' rows
will not reflect the move. This is an infra-failure edge case, not a move
failure, and is called out explicitly rather than silently accepted.
Test plan
attach_service_test.gocontexts that asserted the OLD(buggy) "DB committed, move fails" behavior to instead assert the fixed
behavior: a failed move (a) excludes the file from
reassignedFileIDs,(b) does NOT hard-delete the file's source book, (c) is still reported in
MovesFailedwith file ID / src path / error summary.transaction itself fails, files that already passed guards have already
been moved on disk by that point (
move-then-commit).make test/make lint/make coverageall green locally (100%internal/coverage maintained).Closes bead bookshelf-8l09k on merge.
Security Review — PR #1461 (bd-bookshelf-8l09k)
Scope:
internal/books/attach_service.goreorder (move-then-commit) + updatedattach_service_test.go.Summary: This is a well-scoped reorder. All pre-existing physical-file guards (null-byte rejection, symlink
os.Lstatskip, source-root containment, dest-exists, mkdir) are preserved verbatim and still run before any move or DB write is attempted — none were dropped or weakened by moving the guard/move block earlier in the loop. Ownership/library scoping (getUserLibraryIDs,filterOwnedBookIDs,userIDFromRequest) lives entirely in the unchangedAttachHandler/wiring layer and is untouched by this diff. Net effect on security posture is neutral-to-positive: it closes the previously-flagged risk of a committed DB row pointing at a file that never actually moved.[MINOR] internal/books/attach_service.go:doc comment (move-then-commit guarantee) — residual reverse-direction risk is documented but untested
The new doc comment correctly calls out that if the DB transaction fails after files have already been moved on disk (e.g. lost DB connection), those rows will not reflect the move — the source book's file is now physically missing from the source library while the DB still claims it lives there. This is accepted as an explicit, rare, infra-failure tradeoff (not introduced by this PR's happy path; symmetrical to the previous version's own residual risk in the opposite direction) and is exercised by the "DB transaction fails AFTER files have already been moved on disk" test context, so it is not a blind spot — flagging only because it's the one behavior a future reader might mistake as newly-introduced by this change. No fix required; the tradeoff is reasonable and already documented plus tested.
No BLOCKER or MAJOR findings:
strings.HasPrefix(filepath.Clean(srcAbs), srcRoot+sep), dest-existsos.Stat,mkdirAll). Order relative to each other unchanged; only the move itself was pulled earlier, after all guards, same as before.wire.go), not touched by this PR.filepath.Join(libRoot, FileSubPath)), still validated by the same containment guard before use inrenameFile.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code Review: PR #1461 (bookshelf-8l09k)
Reviewed
internal/books/attach_service.godiff againstorigin/main. Scope is exactly as described: move-then-commit reorder, no unrelated files touched, no.golangci.yml/coverage-exclusion changes.Phase 1: Spec compliance
Matches the bead. The fix reorders the physical move to happen inside the per-file guard loop, BEFORE the DB transaction, and a failed move now does a plain
continue(same as every other pre-existing guard failure: null-byte, symlink, containment, dest-exists, mkdir) — excluding the file fromfileIDs/bookFileReassignCountentirely.Phase 2: Code quality
Invariant check (item 1): Traced the loop in the new file (
git show origin/bd-bookshelf-8l09k:internal/books/attach_service.go) —fileIDs = append(fileIDs, row.FileID)andbookFileReassignCount[row.BookID]++only execute after falling through the entire per-file guard block, including the new move attempt. OnmvErr != nilthe code hitscontinuebefore reaching that append. So DB reassignment is now strictly limited to files whose move succeeded (or move wasn't requested).hardDeleteBooksRawis driven byfullyReassignedBookIDs, computed from the samebookFileReassignCount, so a source book with any failed-move file is correctly retained, never hard-deleted. Verified against the new tests (does NOT reassign files in DB whose move failed,does NOT hard-delete source books whose file move failed) — the invariant holds.Residual window (item 2): The new doc comment at the top of
AttachFilesis honest about the inverted residual risk: if the DB tx itself fails/rolls back AFTER a physical move succeeded, the file is now on disk at the destination but the DB row still points at the source. This is documented, and a test explicitly locks it in (has already moved the files on disk by the time the transaction fails— assertsrenameshas 2 entries even though the tx failed). This is a smaller/no-worse trade: the old window fired on any catastrophic-I/O rename failure (the same failure class, exercised N independent times); the new window requires guards to pass, N independent renames to ALL succeed, AND then a single subsequent DB commit to fail — a narrower, lower-probability compound event, matching the PR author's own reasoning (compensating rollback was rejected because the tx hard-deletes source books, and undoing a hard DELETE post-move-failure is riskier than the current design). Acceptable trade-off, adequately documented in both code comments and tests. Not a blocker.Symlink guard (item 3): the
os.Lstatsymlink-skip guard (Security: reject symlinks...skip symlinked sources to restore the no-follow safety) is untouched by the reorder — still runs before the move attempt, samecontinuepattern. Confirmed via diff — this block was not touched at all.Transaction integrity (item 4):
runInTxstill wrapsreassignBookFilesRaw+hardDeleteBooksRawatomically; thefileIDs/fullyReassignedBookIDsslices passed in are now correctly the post-move-success sets (previously they were the post-guard, pre-move sets). No change to transaction atomicity itself, only to what data feeds it — correct given the reorder.Test quality (item 5):
internal/books/attach_service_test.goispackage books_test, curried DI via the existingAttachFiles(...)constructor, one-Expect-per-It (verified — eachItin the changed contexts asserts a single value). New/changed assertions correctly flip to match the new invariant: a full-move-failure context now assertsreassignedFileIDsempty +hardDeletedIDsempty +FilesReassigned==0+SourceBooksRemoved==0(previously asserted the opposite, DB-committed values) — these WILL fail if the reorder regresses back to move-after-commit, since the fakes track call order via the stubbedrenameFile/tx functions. The single-book retained-on-move-failure context and the tx-fails-after-move context are equally solid. No guarded/tautological assertions spotted in the diff hunks.Downstream consumers: grepped all callers of
AttachResult/MovesFailed/FilesReassigned(attach_handler.go,bulk_attach_by_filter_store.go, and their tests) — none assume the old "DB committed but file may be stranded" semantics; they only read/sum/log the counts, which remain correct under the new invariant. The removed "Phase 3: move physical files AFTER the transaction commits..." doc block and its "post-commit"/"two-phase guarantee" language were fully replaced consistently; no stale references to the old ordering remain anywhere else in the codebase.Concurrency/idempotency/N+1 (item 6): No change to fan-out shape — still a single per-attach-request loop (not a per-library fan-out), no N+1 introduced.
renameFile/mkdirAllcall counts unchanged, just reordered relative to the tx.Findings
No BLOCKER or MAJOR findings. This is a correct, well-tested, adequately-documented reorder that closes the originally reported data-integrity gap without introducing a worse or undocumented failure mode.
[MINOR] internal/books/attach_service.go — doc/comment rewrite (MoveFailure, AttachResult, AttachFiles doc block) fully replaces the stale "Phase 3"/"post-commit"/"two-phase guarantee" language with accurate move-then-commit wording; confirmed no stale references remain anywhere else in the repo. No action needed — noted for the record only.
REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code Review: PR #1461 (bookshelf-8l09k)
Reviewed
internal/books/attach_service.godiff againstorigin/main. Scope is exactly as described: move-then-commit reorder, no unrelated files touched, no.golangci.yml/coverage-exclusion changes.Phase 1: Spec compliance
Matches the bead. The fix reorders the physical move to happen inside the per-file guard loop, BEFORE the DB transaction, and a failed move now does a plain
continue(same as every other pre-existing guard failure: null-byte, symlink, containment, dest-exists, mkdir) — excluding the file fromfileIDs/bookFileReassignCountentirely.Phase 2: Code quality
Invariant check (item 1): Traced the loop in the new file (
git show origin/bd-bookshelf-8l09k:internal/books/attach_service.go) —fileIDs = append(fileIDs, row.FileID)andbookFileReassignCount[row.BookID]++only execute after falling through the entire per-file guard block, including the new move attempt. OnmvErr != nilthe code hitscontinuebefore reaching that append. So DB reassignment is now strictly limited to files whose move succeeded (or move wasn't requested).hardDeleteBooksRawis driven byfullyReassignedBookIDs, computed from the samebookFileReassignCount, so a source book with any failed-move file is correctly retained, never hard-deleted. Verified against the new tests (does NOT reassign files in DB whose move failed,does NOT hard-delete source books whose file move failed) — the invariant holds.Residual window (item 2): The new doc comment at the top of
AttachFilesis honest about the inverted residual risk: if the DB tx itself fails/rolls back AFTER a physical move succeeded, the file is now on disk at the destination but the DB row still points at the source. This is documented, and a test explicitly locks it in (has already moved the files on disk by the time the transaction fails— assertsrenameshas 2 entries even though the tx failed). This is a smaller/no-worse trade: the old window fired on any catastrophic-I/O rename failure (the same failure class, exercised N independent times); the new window requires guards to pass, N independent renames to ALL succeed, AND then a single subsequent DB commit to fail — a narrower, lower-probability compound event, matching the PR author's own reasoning (compensating rollback was rejected because the tx hard-deletes source books, and undoing a hard DELETE post-move-failure is riskier than the current design). Acceptable trade-off, adequately documented in both code comments and tests. Not a blocker.Symlink guard (item 3): the
os.Lstatsymlink-skip guard (Security: reject symlinks...skip symlinked sources to restore the no-follow safety) is untouched by the reorder — still runs before the move attempt, samecontinuepattern. Confirmed via diff — this block was not touched at all.Transaction integrity (item 4):
runInTxstill wrapsreassignBookFilesRaw+hardDeleteBooksRawatomically; thefileIDs/fullyReassignedBookIDsslices passed in are now correctly the post-move-success sets (previously they were the post-guard, pre-move sets). No change to transaction atomicity itself, only to what data feeds it — correct given the reorder.Test quality (item 5):
internal/books/attach_service_test.goispackage books_test, curried DI via the existingAttachFiles(...)constructor, one-Expect-per-It (verified — eachItin the changed contexts asserts a single value). New/changed assertions correctly flip to match the new invariant: a full-move-failure context now assertsreassignedFileIDsempty +hardDeletedIDsempty +FilesReassigned==0+SourceBooksRemoved==0(previously asserted the opposite, DB-committed values) — these WILL fail if the reorder regresses back to move-after-commit, since the fakes track call order via the stubbedrenameFile/tx functions. The single-book retained-on-move-failure context and the tx-fails-after-move context are equally solid. No guarded/tautological assertions spotted in the diff hunks.Downstream consumers: grepped all callers of
AttachResult/MovesFailed/FilesReassigned(attach_handler.go,bulk_attach_by_filter_store.go, and their tests) — none assume the old "DB committed but file may be stranded" semantics; they only read/sum/log the counts, which remain correct under the new invariant. The removed "Phase 3: move physical files AFTER the transaction commits..." doc block and its "post-commit"/"two-phase guarantee" language were fully replaced consistently; no stale references to the old ordering remain anywhere else in the codebase.Concurrency/idempotency/N+1 (item 6): No change to fan-out shape — still a single per-attach-request loop (not a per-library fan-out), no N+1 introduced.
renameFile/mkdirAllcall counts unchanged, just reordered relative to the tx.Findings
No BLOCKER or MAJOR findings. This is a correct, well-tested, adequately-documented reorder that closes the originally reported data-integrity gap without introducing a worse or undocumented failure mode.
[MINOR] internal/books/attach_service.go — doc/comment rewrite (MoveFailure, AttachResult, AttachFiles doc block) fully replaces the stale "Phase 3"/"post-commit"/"two-phase guarantee" language with accurate move-then-commit wording; confirmed no stale references remain anywhere else in the repo. No action needed — noted for the record only.
REVIEW VERDICT: 0 blocker, 0 major, 1 minor
5b378c3055ab27f059d2