fix(ingest): bound cursor lock waits so a wedged peer cannot hang ingest - #191
PeterGuy326 wants to merge 1 commit into
Conversation
|
CI baseline dependency: the current main Web audit gate fails on published js-yaml/Vitest/postcss-selector-parser advisories, independently of this checkpoint diff. Separate maintenance PR #192 refreshes only |
|
Candidate: Independent automated preflight (Mendel, not the implementation worker): PASS, bounded source/CI/compile-only scope. This is not a human APPROVE. Checked retained lock inode, lock-held reread and whole-record maximum selection, private unique staging, checked sync/close, and rename before unlock. Unsupported platforms fail before writing. The Go files equal the final #140 implementation, but no old vote is inherited. Current exact-head CI passes the full race suite; all 20 checks are green. Linux test, Windows and AIX cross-compilation and diff/format checks passed. Reviewer-local runtime execution is NOT VERIFIED; no temporary native macOS test executable was run. Still stacked on #192. Land the baseline through independent human review, then retarget and recheck the final candidate. |
Review summary (verified independently)
Minor, currently-unreachable caveat: Same base/retarget blocker as #189/#190 — this PR bases off #192's branch, not |
## Requirement / goal Refs #139 (CI baseline only; this PR does not expand the checkpoint bug scope). Restore the current `mem` Web audit gate by refreshing the lockfile entries with published fixes. ## Scope - Refresh only `web/package-lock.json`. - Resolve the current main Web audit findings for js-yaml, Vitest/@vitest/mocker, and postcss-selector-parser. - No application code, server behavior, transcript format, or checkpoint implementation changes. ## Validation ledger - PASS — `npm ci` - PASS — `npm run audit` (production and high-severity development gates) - PASS — `npm run lint` - PASS — `npm run typecheck` - PASS — `npm run build` - PASS — `git diff --check` ## Known limitations - This is a CI/security-baseline prerequisite for clean current-main PR validation, not the #139 product fix and not an issue-close claim. - The lockfile refresh is intentionally separate from [#191](#191); #191 should be rebased or retargeted after this baseline lands.
ReviewOverall design is sound — lock granularity, coverage, max-cursor semantics, and process-crash recovery paths are all handled correctly. Tests using real subprocesses to verify cross-process semantics are solid. A few points worth noting: 1.
|
waterbro-8
left a comment
There was a problem hiding this comment.
REQUEST-CHANGES — the lock is correct, the tests don't cover what the lock is for
Two corrections to what I expected going in, stated up front because they change what's left to
fix: locking a separate p + ".lock" sidecar is the right design (a lock file you
rename over would silently split into two inodes, and your comment on
qoder_checkpoint_lock.go:8-12 shows you thought about exactly that), and
TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit is a real exclusion test —
delete the lock entirely and it fails. Also cp = current on the losing side keeps the whole
record rather than field-merging, so no torn (LastLine, Size) pair can be committed.
What's left is that the unbounded wait is a hang, not an error, and the property your doc
comment claims about lock extent has zero coverage.
S2-1 — no bound on the wait, on both platforms
qoder_checkpoint_lock_unix.go:12→unix.Flock(int(file.Fd()), unix.LOCK_EX)— no
LOCK_NB, no deadline.qoder_checkpoint_lock_windows.go:14-21→LockFileEx(…, LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, …)
— noLOCKFILE_FAIL_IMMEDIATELY.
Advisory locks are released when the owning process exits, not when it stops. A peer that is
SIGTSTP'd, wedged in a hang, or sitting on a slow/network-backed state dir while holding the
lock blocks the next mem ingest qoder forever, with no output at all.
That contradicts the contract in the comment you just wrote — "Errors are returned (callers may
warn without failing the whole ingest)" (qoder_checkpoint.go:63-64) — because a hang is not
an error and never returns to the caller who would warn. The ingest already tolerates a failed
checkpoint (the caller only prints warn: at cmds_ingest.go:246-251), so degrading the same
way on lock contention costs nothing.
Ask: LOCK_EX|LOCK_NB with a bounded retry and an explicit give-up path (or a deadline
around the blocking wait), so a wedged peer produces a warning instead of a silent freeze.
S2-2 — nothing pins the release to after the commit
The helper's save action calls saveQoderCheckpoint — which locks, merges, writes and
releases — and only then signals readiness: checkpointHelperWriteSignal(ready) sits at
qoder_checkpoint_test.go:75, after the switch at :51-73. So in
TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses (:182) the test observes
highReady and then starts the low writer — high's lock was long gone before low opened the
file. The two critical sections never overlap:
- Delete the entire lock block and that test still passes. It is a max/merge test (a worthwhile
one — it does pin thatSize == 33andModTimedon't regress, which is not nothing), but it
is not a concurrency test despite its name and its comment's emphasis on "independent live
processes (the high writer still waits for release)". - Conversely, move
lock.release()to beforesaveQoderCheckpointLockedat
qoder_checkpoint.go:80and both tests still pass: test 2's high writer uses the
save-hold-lockaction and never releases throughrelease(), and nothing observes the low
writer's release timing.
So the single property your doc comment claims — "the per-cursor OS-backed lock covers the
read/merge/write sequence" — is the one property with no test behind it, and it's the property
the fix depends on: a lock released before the rename commits protects the read but not the
write, and the lost update is back.
Ask: drive test 1's high writer with the save-hold-lock action you already built (:56), so
the overlap is real, and rename it to match what it proves once it does.
S3 — Size/ModTime are injected, not derived
MEM_QODER_CHECKPOINT_SIZE is read from the environment at qoder_checkpoint_test.go:33 and
the tests hand-pair 33 with LastLine: 12 and 4 with LastLine: 4, while the real file is
one fixed 33-byte write. So the coupling that actually matters in production —
(LastLine, Size) coming from different moments — is invisible to these tests.
That's an observation about the caller, which #191 does not touch and I am not asking you to
touch: cmds_ingest.go:243-244 samples fileState(abs) after the per-line POST loop, so
LastLine reflects lines successfully posted while Size reflects a later file size.
loadQoderCheckpoint's only reset trigger is fi.Size() < cp.Size, so a cursor that recorded a
size larger than the file ever had at that line count raises the bar for truncation detection.
Tracked separately, please — I raise it only so nobody reads this lock as covering that
coupling, and to be explicit that I checked whether a concurrent merge could strand a line
range and concluded it cannot, since the merge copies the whole record.
Small and non-blocking
_lock_other.goreturns an error on unsupported platforms, which surfaces as awarn:and
leaves ingest working. Good default.- The lockfile churn is properly isolated in its own
chore(web): carry audited lockfile baseline from #192commit, which keeps the Go change
reviewable.
Provenance
Static read of head f91fb4404 (qoder_checkpoint.go, the three lock files,
qoder_checkpoint_test.go) plus cmds_ingest.go and qoder_checkpoint.go on main 3c13f04.
I ran no Go tests. The two mutation claims in S2-2 are reasoned from the helper's
signal ordering, not demonstrated by deleting code and re-running — if either is wrong, say so
and I'll retract it, since they are the basis for the request. Windows behaviour is unread
experimentally: I read LockFileEx's flags but never ran the bat.
Review: the lock is right, and nothing here proves it does anythingThe lock design is sound and I want to say that plainly before the objection.
The gap: neither test fails if you delete the lock
current := loadQoderCheckpoint(stateDir, cp.Abs)
if current.LastLine > cp.LastLine {
cp = current
}Two writers running fully concurrently, with no lock at all, would still land on 12. This test measures the merge rule and exercises the lock only incidentally.
So the missing case is mutual exclusion itself: no test asserts that B cannot complete while A holds the lock, and that B's result therefore lands after A's rather than being interleaved with it. Related, What I am asking forAdd one case: A takes the lock via
Point 3 is the one that makes this a lock test. With B's cursor higher than A's, the merge rule cannot produce the final value — only B actually running after A can. That is the property Also
|
|
Draft + conflicting, and I previously requested changes. Not merging. Rebase onto current main (which now has #194) and address the outstanding review before asking again. |
93407ff to
1271102
Compare
|
#217 已经把 cursor 锁搬到 `server/internal/ingest`。原先 cmd/mem 的 checkpoint 文件叠回去会变成死代码,所以本 PR 改写成对现包的补丁。 CHANGES_REQUESTED 对应:
请再审。 |
Refs #139.
#217 already extracted the qoder checkpoint lock into
server/internal/ingest. This PR no longer reintroducesserver/cmd/mem/qoder_checkpoint*.go.It ports the remaining review items onto the live package:
LOCK_EX|LOCK_NB/LOCKFILE_FAIL_IMMEDIATELYHead:
1271102on current main.