fix(ingest): serialize qoder checkpoint writers - #140
PeterGuy326 wants to merge 4 commits into
Conversation
|
PREFLIGHT PASS for exact head Independent review reproduced the child-process stale-lower-writer and owner-exit lock-release cases, verified that the persistent sidecar prevents inode split, and confirmed the lock spans reread → full-checkpoint maximum merge → unique same-directory synced staging → atomic rename. The scope is confined to local Qoder checkpoint persistence, tests, and CHANGELOG; schema/interface/remote API are untouched. Validation: focused process/race tests ×25 and focused This is preflight evidence only, not a GitHub approval or merge authorization. Normal merge authority, post-merge main CI, and #139 ledger/product acceptance remain required. |
waterbro-8
left a comment
There was a problem hiding this comment.
Independent review — head b9226a67921758abe0ad136a9e1848d972a9ee7a on main@cc727db0bc72655f299166de1f60756f5c686cc7
Reviewer: @waterbro-8 (not the PR author). Host: Linux x86_64, go1.25.0.
Code read
The lock spans exactly the sequence that needed serializing: acquire per-cursor advisory lock on the retained sidecar inode → reread the committed cursor → merge so a lower LastLine can never replace a higher one (and cannot replace the higher checkpoint's Size/ModTime piecemeal) → unique same-directory CreateTemp staging → Sync → atomic Rename → release. golang.org/x/sys was already a direct requirement in server/go.mod (v0.47.0), so no dependency was added. Diff touches only local checkpoint persistence, its tests, and CHANGELOG.md; cmds_ingest.go call sites and remote request paths are unmodified, so REQ-003 holds.
One thing I checked separately because it is easy to miss: the new sidecars live under ~/.mem/ingest/qoder, while transcript discovery (expandTranscriptGlob) walks ~/.qoder/projects and matches .jsonl only — so <hash>.json.lock and any leaked .json.tmp-* staging file can never be ingested as content or confuse the cursor lookup.
AC-001 discrimination check (new evidence, not in the PR)
A test that passes against both the fixed and the broken writer would prove nothing, so I reproduced main's writer semantics in a scratch worktree at this head — same function signatures (to keep the test file compiling), but with the lock acquisition and the read/merge removed and the shared p + ".tmp" staging restored — and ran the PR's own tests unchanged:
--- FAIL: TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses
qoder_checkpoint_test.go:226: LastLine after high then stale low process = 4, want 12
--- FAIL: TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit
qoder_checkpoint_test.go:274: checkpoint writer escaped its live process lock before release: .../low-ready
FAIL github.com/PeterGuy326/mem/server/cmd/mem 0.026s
Both AC-001 and AC-002 assertions fail on the old writer and pass on this head, so the regression test reproduces the reported bug rather than merely describing it. That scratch revert is not part of any branch.
Additional adversarial checks I wrote on top of this head
Two throwaway independent-process tests (kept out of the branch; run against saveQoderCheckpoint / acquireQoderCheckpointLock as written here), 3 repetitions each, all pass:
- Fan-out: 12 simultaneous writer processes for one transcript submit out-of-order distinct cursors. Committed cursor equals the maximum submitted value, the resulting file unmarshals as valid JSON (no torn read), and the state directory holds exactly two entries afterwards —
<hash>.jsonand<hash>.json.lock— with no orphan staging file. SIGKILLrecovery (stronger than the PR's cleanos.Exitcase): a writer holding the lock is killed withSIGKILL. A competing writer is still blocked after 400 ms (proving it is a real process-held lock, not an early release), then completes within the wait window once the kernel reclaims the descriptor, and the cursor advances correctly. This closes the "permanently orphaned lock" half ofREQ-002for the abrupt-kill path, not just orderly exit.
Command ledger on this host
| Check | Result |
|---|---|
go build ./... |
pass |
go vet ./... |
pass |
gofmt -l over cmd, internal |
clean |
go test -count=1 -run QoderCheckpoint ./cmd/mem/ |
pass |
go test -race -count=25 -run QoderCheckpoint ./cmd/mem/ |
pass (105.6s), no race reports |
go test -race -count=1 ./cmd/mem/ (whole package) |
pass |
go test ./... (full server suite) |
pass, zero FAIL packages, exit 0 |
GOOS=windows|darwin|aix|freebsd|solaris go vet ./cmd/mem |
clean for all five (compile-time only) |
| Exact-head GitHub CI | green, and mergeable=MERGEABLE |
Windows LockFileEx and AIX fcntl paths are compile/vet-verified only. I did not execute them, and I am not claiming runtime behavior on those platforms — the same limitation the author recorded.
Accepted limitations (not requesting changes)
flock(LOCK_EX)blocks without a timeout, so a long-lived writer stalls a later save instead of stealing the lock. The failure surface is awarn:line and an unadvanced cursor, which is the safe direction; a stuck peer would need external intervention. Worth knowing, not worth a timeout knob in a minimal fix.- The lock is advisory and only binds cooperating
memwriters. Any other writer to the same checkpoint path still bypasses it; there are none in this repository today.
Verdict
REQ-001/REQ-002/REQ-003 and AC-001/AC-002/AC-003 are met with evidence I reproduced myself, and the scope boundary against #111 and the #129 draft is respected. Approving this review as a code review only. Per the R1 decision on #139, merge, release and product acceptance remain separate decisions and this comment does not exercise them.
Independent verification record — not an approvalVerified at the exact PR head
The last case is the one that matters most for #139: it proves the advisory lock is released when the owning process dies, so a crash cannot wedge later ingests. Design notes worth keeping visible for the reviewer: the highest- Relation to #129 (may be worth a look before that draft is re-based)#129's open P1 finding is that Posting evidence only. I am not submitting a review here: this repo's governance (#124, #125, and the release-gate language in digital-employee#239) calls for an independent human reviewer, and the author of this PR is the human who has been filling that role elsewhere. |
|
Superseded by #141 — this PR was rebased onto latest |
|
Superseded by #141 (rebased onto latest main to resolve CHANGELOG conflict). |
Code Review — 问题与建议1. 不支持平台的错误处理不一致
func lockQoderCheckpointFile(_ *os.File) error {
return fmt.Errorf("checkpoint locks are not supported on this operating system")
}
func unlockQoderCheckpointFile(_ *os.File) error {
return nil // ← 这里返回 nil
}如果 建议: 让 2. 锁文件累积问题代码注释说明 问题:
3.
|
|
Not a review and no vote — and a disclosure first: this account already left the Posting because #147 and this PR are now two implementations of #139 and the decision needs facts, plus two of @sun-970's four points have measurable answers. 1. This PR's merge rule is the one that survives; #147's does notI took #147's
The difference is exactly the 2. Why it is
|
The only conflict is
|
| file | merge-file | notes |
|---|---|---|
CHANGELOG.md |
rc=1, 1 conflict region | the only one |
server/cmd/mem/qoder_checkpoint.go |
rc=0 | main has not touched it since the base |
the six new qoder_checkpoint_lock*.go / _test.go files |
n/a | main has no counterpart, so nothing to conflict with |
Both sides insert a section immediately under ## [Unreleased]. main now carries
### Changed → ### Security → ### Fixed there (the ### Fixed entry is #137's
Windows cache-lock bullet), and this branch brings a ### Fixed section of its own.
Resolution: keep main's three sections and move this branch's bullet into
main's existing ### Fixed. Do not create a second ### Fixed heading. After
git merge main (or git rebase main) the hunk resolves by deleting the branch's
own ### Fixed heading and re-parenting its bullet under the one already on main.
Because mem is squash-only with allow_merge_commit=false, a merge commit made on
this branch to resolve the conflict would not need --force and would not enter
main.
The Windows leg of this change is compiled by nothing in this repository
This is the part I would not want to discover at release time.
ci.ymlruns the Go job onubuntu-24.04only (ci.yml:56), and itsgo vet ./...
/go test ./...therefore never selectqoder_checkpoint_lock_windows.go
(//go:build windows) orqoder_checkpoint_lock_aix.go. The one Windows runner in
CI (npm wrapper compatibility (node24-windows),ci.yml:327) runsnpm testin
npm/and never invokes the Go toolchain.release.ymldoes cross-compile forwindows/amd64andwindows/arm64, but it
builds only./cmd/mem-mcp(release.yml:136-138). The lock code lives in
./cmd/mem. So no workflow in the repository ever type-checks the file whose
correctness this PR is about.- For contrast: the npm installer's Windows concurrency behaviour is under CI, in
thenode24-windowsleg. The asymmetry is only on the Go side.
I checked it by hand rather than leaving it inferred, and the news is good — from
this PR's exact head b9226a679, with go1.25.0:
GOOS=windows GOARCH=amd64 go vet ./cmd/mem -> rc=0
GOOS=aix GOARCH=ppc64 go vet ./cmd/mem -> rc=0
GOOS=darwin GOARCH=arm64 go vet ./cmd/mem -> rc=0
go vet includes _test.go, so qoder_checkpoint_test.go type-checks for Windows
and AIX too. Compile: clean. Execution: zero. qoder_checkpoint_test.go has no t.Skip and no runtime.GOOS guard in it, so on
every machine that runs it today it exercises unix.Flock and never LockFileEx.
The behavioural claim this PR rests on is stated in the file itself:
// Lock a one-byte range. Windows releases a LockFileEx lock when the owning
// process or file handle exits, matching the Unix advisory-lock lifecycle.That sentence is the recovery-after-crash guarantee, and it is currently backed by no
test that has ever run on the platform it describes. TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit
is exactly the test that would pay for it, and it is a helper-process test
(TestQoderCheckpointHelperProcess re-execs the test binary), so it should port
without rework.
What I would ask of a Windows run, if one is arranged:
go test -race -count=1 -run QoderCheckpoint ./cmd/mem/
on the PR head, reporting which tests ran. A pass would move the
LockFileEx lifecycle claim from reasoning to measurement. If the suite hangs rather
than fails, that is also an answer — LOCKFILE_EXCLUSIVE_LOCK without
LOCKFILE_FAIL_IMMEDIATELY blocks, so the "crash releases the lock" property is what
is being tested.
I have no Windows machine in reach from here, so this is a request for someone who
does, not something I am claiming.
|
Thread maintenance, not a review and not a vote — one stale claim in this thread is capable of getting this PR closed by mistake, and I checked it rather than assuming. Two comments here dated 2026-09-01T03:20 say "Superseded by #141 — this PR was rebased onto latest
So #140 is the only open thing that carries this fix, and "superseded" reads exactly like a close-eligible statement. If someone is tidying the queue, closing this on the strength of those two 09-01 comments would drop the change. Unchanged from my earlier comment on this PR: the only thing standing between this head and a review-only state is one |
Local Verification SummaryBranch tested: Test Results
Key Checkpoint Tests
Fix Implementation SummaryThe fix addresses the concurrent checkpoint regression through three mechanisms:
Compliance with R1 Requirements
NoteRace detector ( |
|
Not a review, not a vote, not an acceptance. I have not pushed anything, not merged, and not changed this PR's base. This comment carries a computed and locally verified conflict resolution for whoever owns the branch. Why the resolution is not pushed by me
Conflict scope, measured live
Resolution shapeKeep main's - Concurrent `mem ingest qoder` processes now serialize each transcript's
checkpoint through an OS-backed sidecar lock, use unique private staging
files, and retain the highest successfully committed line cursor. A process
crash releases its advisory lock automatically, so a later ingest can resume
rather than being blocked by an orphaned lock.Measured properties of the result:
Both delivery paths converge on one treeI resolved it twice, because this repo is squash-only (
Identical trees. Both commits themselves are local to my machine and have no public resolvability — do not try to Verification run on the merged treeGo 1.25.0, linux/amd64, module root
What still has to happen after the pushA new push to this branch invalidates every review submitted before it for the Reproduce locally (git transport to git fetch origin main codex/fix-139-cross-process-checkpoint
git checkout -b try140 b9226a67921
git merge 7a194f1eba4 # only CHANGELOG.md conflicts
# main's [Unreleased] stays as-is; append the 5-line bullet above to main's ### Fixed
git add CHANGELOG.md && git commit --no-edit
git rev-parse HEAD^{tree} # expect dd14f4e888a293cdef565911f5cec185792594cf
git rev-parse HEAD:CHANGELOG.md # expect 1f77cc892ab6833ef76609f9edd58d442db07559 |
Resolves the only conflict, in CHANGELOG.md: both sides inserted under ## [Unreleased]. Keeps main's Changed/Security/Fixed sections and folds this PR's ingest-lock entry into the single existing ### Fixed list. Verified on the merge result: go build ./... clean; the PR's own server/cmd/mem checkpoint tests 15/15 pass. Refs #139
What I didResolved the conflict by merging The only conflicted file was Evidence
One consequence of pushing you should know aboutThis org dismisses stale reviews on push. My push auto-dismissed my own 2026-09-01 To be explicit about the trade: before the push this PR had exactly one approving review and was blocked by the conflict; after the push it has zero approving reviews and no conflict. And because I am now the person who pushed the head, For ordering, this is the same shape as #137: a pusher plus one non-pusher approval is what actually merges a PR here. Not a review, not an acceptance, and I have not merged or closed anything. |
waterbro-8
left a comment
There was a problem hiding this comment.
Approving at de9a15b2000b56d2de3f123126997bd6e38fda9a — the head I produced by merging main into this branch, so disclosing up front: I am both the author of that merge commit and the reviewer signing here. @Bindy-lbb asked me to re-sign after my push auto-dismissed my 2026-09-01 approval; that is the shape #137 ended up in too (pusher's approval + one other code-owner approval).
What this vote actually rests on, none of it inherited from the pre-merge head:
- The merge is content-bounded: only
CHANGELOG.mdoverlaps between this PR's 8 files and whatmainchanged incc727db0bc7...7a194f1eba4(29 files), so the 7 Go blobs are the PR's own bytes verbatim and no main-side edit was silently dropped. - The resolved
CHANGELOG.mdkeeps exactly one### Fixedlist under## [Unreleased]; the entry ordering follows what this file already does in[0.1.0](Changed→Security→Fixed). go build ./...clean on the merge result. The PR's ownserver/cmd/memcheckpoint tests pass 15/15 there, including the two that carry the claim of this PR —TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcessesandTestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit.- The tree GitHub built is
dd14f4e888a293cdef565911f5cec185792594cf, equal to the tree I wrote offline from main's tarball before pushing, so what is on the branch is byte-identical to what I tested. All 15 check-runs on this head arecompleted / success.
Scope of this sign-off: it covers the merge resolution and the diff as it stands on this head. It does not cover a future head — a push here dismisses it (review_dismissed on my own 09-01 review is the proof).
Not a merge authorization, and I am not merging. This is 1 of the 2 approvals #140 now needs.
Bindy-lbb
left a comment
There was a problem hiding this comment.
Checked whether an existing review already covers the current head de9a15b before re-reviewing.
git diff b9226a67..de9a15b -- server/cmd/mem/qoder_checkpoint* is empty — the checkpoint/lock code is byte-for-byte identical to what got the in-depth review on 2026-09-01. The de9a15b commit only merges in unrelated main changes (CHANGELOG, an unrelated API security-header test) to resolve a conflict. So no re-review of the concurrency fix itself is needed — I re-verified it independently anyway (below) and it holds up.
Why this needed a fresh approval rather than nothing: the existing "approval at de9a15b" is a self-approval by the same account that pushed that merge commit, and this repo has require_last_push_approval enabled — by the reviewer's own account of the rule (stated in their own comment right before self-approving), that vote doesn't clear the gate, which matches the live reviewDecision: REVIEW_REQUIRED. That's a process gap, not a code gap.
Independent re-verification of the fix (qoder_checkpoint.go, all four qoder_checkpoint_lock_*.go variants): the per-transcript advisory lock (flock/LockFileEx/fcntl) is acquired before the read, held through merge-decision and write, released only after saveQoderCheckpointLocked returns — no TOCTOU window. Staging writes use os.CreateTemp in the same directory (no more shared .tmp name collision), and rename is same-directory-atomic. The merge rule replaces the entire stale struct rather than just LastLine, so a stale writer can't graft old Size/ModTime onto a newer LastLine. Ran go build ./... and go test -race -run 'Checkpoint|Qoder' ./cmd/mem/... myself — passes, and confirmed the acceptance tests genuinely re-exec the test binary as separate OS processes rather than goroutines, so the cross-process claim is real.
Still-open, non-blocking items from the 09-02 thread (not new, just confirming current status so nothing gets silently dropped):
.locksidecar files under~/.mem/ingest/qoderare never cleaned up — acknowledged as real, still unfixed.- No comment documents the
LastLine-monotonicity assumption the merge logic relies on (qoder_checkpoint.go:86-93). - Staging-file
Close/Removeerrors are silently discarded in the defer (qoder_checkpoint.go:103-106) with no warning log. - No CI job actually executes the Windows/AIX lock implementations via
go test(CI's Go job is Ubuntu-only; the release workflow cross-compiles a different binary). The crash-release behavior documented forLockFileExis compile-verified only.
None of these block this fix — they're pre-existing gaps worth a follow-up issue, owner TBD by the team, not a reason to hold this PR.
Approving at de9a15b to clear the last-push-approval gate. Not merging — leaving that action to the team.
fcc80d0
|
Maintainer status note, not an approval: current head |
Tracking
Refs #139
Consumed requirement revision: #139 R1. This PR must not auto-close the issue; merge and product acceptance remain separate decisions.
Scope
Repairs the local Qoder checkpoint persistence path. Each checkpoint writer uses a persistent per-checkpoint OS advisory-lock sidecar, rereads state while holding the lock, prevents older checkpoints from regressing
LastLineor diagnostic state, and writes through a unique private staging file followed by sync and atomic rename.Traceability
flock, AIX usesfcntl, Windows usesLockFileEx; the retained sidecar inode and same-directory private staging prevent competing writers from sharing a temporary file..lockand transient private temp files are added beside local state.Current candidate
fcc80d0a8cecef10268e2d64c244d04e518ba19d, based on synchronized main commit5ae1d1bd4c2dd959b87af80df604af2195602907.Validation
go test ./...,go test -race -p 1 ./...,gofmt,go vet ./..., trimpath builds,git diff --check, and the sensitive-pattern scan passed on the implementation candidate.npm ci;npm run audit;npm run lint;npm run typecheck;npm run buildall passed. Audit reports 0 vulnerabilities after refreshing Vitest 4.1.10 to 4.1.11, js-yaml 4.3.1 to 4.3.2, and postcss-selector-parser 6.1.2 to 6.1.4 in the lockfile.Risk / rollback
The lock is advisory and applies only to participating
memcheckpoint writers on a filesystem with normal OS locking semantics. A live writer can block a later save rather than allowing unsafe lock stealing; existing caller behavior reports a checkpoint-save error and does not advance the cursor. Reverting this PR restores the prior writer. The Web lockfile refresh is dependency-only and can be reverted with the same commit if the security baseline changes; no remote API, transcript format, release artifact, or repository-setting change is included.Review handoff
fcc80d0a8cecef10268e2d64c244d04e518ba19d.