Skip to content

refactor(ingest): extract shared local ingestion core with concurrent-writer safety - #147

Closed
sun-970 wants to merge 3 commits into
bytefolk:mainfrom
sun-970:fix/ingest-extraction-111
Closed

sun-970 wants to merge 3 commits into
bytefolk:mainfrom
sun-970:fix/ingest-extraction-111

Conversation

@sun-970

@sun-970 sun-970 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Tracking

Refs #111
Refs #139

Supersedes #129 (rebased onto latest main with OS-backed cursor lock added).

Summary

Extracts the local ingestion mechanics from server/cmd/mem into a new server/internal/ingest package, so that put --watch (#110) consumes one core instead of writing a second state layer. Also carries forward the concurrent-writer safeguard from #139.

What moved to server/internal/ingest

Concern New home
Deterministic recursive transcript walk ingest.Walk, ingest.HasJSONLExtension, ingest.CanonicalRoot
Per-path line cursor (atomic write, shrink/rewrite reset) ingest.Cursor, CursorPath, LoadCursor, SaveCursor, FileState
--dry-run / --limit semantics, per-file degradation ingest.Run
Report vocabulary (scanned / ingested / deduped / failed) ingest.Report
Closed failure-code classification ingest.Code, ingest.Classify

What stays in the connector

Cobra flags, the Qoder JSONL parser, the /v1/memories payload shape, Idempotency-Key derivation, the HTTP upload, and all stdout/stderr text.

Concurrent-writer safety (from #139)

SaveCursor now acquires a per-cursor OS-backed advisory lock (flock on Unix, fcntl on AIX, LockFileEx on Windows) that covers the read/merge/write sequence. This prevents concurrent ingest processes from:

  • Regressing a cursor's LastLine with an older value
  • Colliding on a shared staging path

The lock sidecar (<cursor>.json.lock) remains on disk after release; the OS releases the advisory lock when the descriptor or owning process exits.

Behaviour preservation

  • Same memories payload shape
  • Same Idempotency-Key derivation
  • Same stdout summary
  • Same cursor file format and location (~/.mem/ingest/qoder)
  • Existing cursors remain readable

One intentional change: a root given as relative or through a symlink is now canonicalized to its absolute path before keying anything. This prevents cursor collisions between two working directories that each contain sessions/p.jsonl and share one state dir.

Requirements trace

Requirement Evidence
REQ-001 Walk, cursor, change gate, report, failure codes extracted to server/internal/ingest
REQ-002 Qoder connector passes all existing tests with import-path changes only
REQ-003 Report and failure vocabulary are explicit: scanned/ingested/deduped/failed, codes auth/plan_quota/provider_timeout/network/read_denied/upload_rejected/root_missing/state_corrupt
#139 safeguard OS-backed cursor lock covers read/merge/write sequence

Acceptance criteria

  • AC-001: All existing qoder tests pass with import-path changes only
  • AC-002: Core-level tests pin edge cases: dry-run writes nothing, shrunk file resets cursor, 409 degrades one file, corrupt cursor degrades without blocking
  • AC-003: Package has no cobra/stdout dependency, no new third-party dependency

Validation

  • go build ./...: pass
  • go test -race ./internal/ingest/...: 14/14 pass
  • go test -race ./cmd/mem/... -run "Ingest|Qoder": all pass
  • gofmt, go vet: clean

waterbro-8 and others added 3 commits September 1, 2026 14:11
`put --watch` (bytefolk#110) would otherwise re-implement the same cursor store,
state-root layout, failure classification and report vocabulary that PR
drift. Move walk, cursor, change gate, `--dry-run` / `--limit` semantics,
per-file degradation and report aggregation into `server/internal/ingest`
and leave the connector as a thin call site that supplies the Qoder
parser, the memory payload and the HTTP upload.

Behaviour is preserved at the bytes level, not just by assertion:
memories request bodies, `Idempotency-Key` derivation, stdout summary,
stderr conflict warning, exit status and cursor file format and location
are identical before and after extraction on a shared fixture tree. The
PR bytefolk#108 test suite passes with import-path changes only.

Refs bytefolk#111
The connector used --root exactly as spelled, so the walk, the project/session
split and the cursor key disagreed whenever the root was relative or reached
through a symlink. Two working directories each holding sessions/p.jsonl shared
one cursor, and the second run saw an up-to-date checkpoint and posted nothing.
Canonicalize the root once and derive every identity from it.

Checkpoint saves staged through one shared <cursor>.tmp, so a second run could
fail on the name or rewind a cursor a faster run had already committed. Each
save now gets its own staging file and never moves a cursor backwards.

Failures now reach the classifier intact: the upload adapter keeps the typed API
error and the command maps exit codes at its own boundary, and the transport
check no longer runs ahead of the local-file checks that a syscall.Errno also
satisfies, so an unreadable source reports read_denied or root_missing instead of
network. A cycle that aborts while reading records the code it died on.
Add per-cursor advisory lock (flock on Unix, fcntl on AIX, LockFileEx on
Windows) that covers the read/merge/write sequence in SaveCursor. This
prevents concurrent ingest processes from regressing a cursor's LastLine
or colliding on a shared staging path.

The lock sidecar (<cursor>.json.lock) remains on disk after release; the
OS releases the advisory lock when the descriptor or owning process exits.

Refs bytefolk#111
Refs bytefolk#139
@waterbro-8

Copy link
Copy Markdown
Collaborator

Not a review. No APPROVE, no REQUEST_CHANGES, no acceptance, no vote, and nothing here marks this PR ready. Conflict-of-interest disclosure up front: two of this PR's three commits (e9e17f8b, ab5bc035) are authored by waterbro-8 — my own account — so this cannot be read as independent review. The requested reviewers are PeterGuy326 and Bindy-lbb. What follows is local execution evidence against the exact head, plus three defects found by executing rather than by reading.

This head has never been built by CI

For 785de61e00661f4e5115129500be642c47422108: all three check suites (CI, Validate Agent memory, PR Policy) are status=completed conclusion=action_required, statusCheckRollup is empty, and commits/<sha>/check-runs returns total_count: 0. The author sun-970 is neither an org member nor a repo collaborator, so the fork PR's workflows are awaiting a maintainer's approval to run.

Consequence: the ## Validation list below is unverified by any gate, and none of the ACs are machine-checked on this head. Approving the workflow runs is a maintainer decision — I did not attempt it.

What I ran locally, and what it said

Go 1.25.0, linux/amd64. Source fetched from sun-970/mem@785de61e via codeload (git transport is not usable from here), baseline bytefolk/mem@10d4bf7a.

  • go build ./..., go vet ./..., gofmt -l internal/ingest cmd/mem → all clean.
  • go test -count=1 ./... across the whole module → every package ok.
  • go test -count=1 -race ./internal/ingest/... → ok; 14 test functions, matching the "14/14" in the body.
  • go test -count=1 -race ./cmd/mem/... -run "Ingest|Qoder" → 13/13 pass; the unfiltered cmd/mem suite also passes.
  • AC-003 confirmed: grep -rn "spf13\|cobra\|fmt.Print\|os.Stdout\|log\." internal/ingest/ returns exactly one hit, inside a comment. server/go.mod is not in the diff, and golang.org/x/sys was already a direct require on main — so there genuinely is no new third-party dependency, including for the AIX/Windows lock files.
  • AC-001's connector half confirmed: cmds_ingest_test.go has only 4 deleted lines, all loadQoderCheckpointingest.LoadCursor renames; no test function was removed or renamed and 5 were added. The stdout format string, ingestMemoryBody and ingestIdempotencyKey are byte-identical to base. fromAPIError passes non-APIError values through unchanged, so the parse-error exit-code path is preserved too.
  • The staging-collision half of [Bug] Prevent concurrent Qoder checkpoint regression and staging collisions #139 is genuinely fixed: os.CreateTemp per save plus TestSaveCursorDoesNotStageInASharedSlot pins it.

The two findings below are the ones I'd hold the PR on.

1. The cursor lock does not actually prevent a rewind, because the value it compares is sampled outside the lock

saveCursorLocked (server/internal/ingest/ingest.go:320-324) refuses to move a cursor backwards only when both clauses hold:

current := LoadCursor(stateDir, cp.Abs)
if current.LastLine > cp.LastLine && current.Size <= cp.Size {
    return nil
}

The lock does cover the read/merge/write, as the body says. The problem is cp.Size: Run samples it at ingest.go:476 via FileState(abs)before SaveCursor takes the lock. If the transcript grows and another process commits a further cursor in that window, then current.Size > cp.Size, the second clause is false, && short-circuits, and the older LastLine is written. That is precisely the regression #139 is about.

Reproduced with a scratch test in package ingest (file really on disk, so LoadCursor's shrink reset at ingest.go:277-281 cannot be what decides the outcome):

func TestProbeRewindViaStaleSize(t *testing.T) {
	states := t.TempDir()
	abs := filepath.Join(t.TempDir(), "a.jsonl")
	os.WriteFile(abs, make([]byte, 30000), 0o600) // 30000 bytes, never shrinks

	SaveCursor(states, Cursor{Abs: abs, Size: 30000, LastLine: 200}) // run A, finished first
	SaveCursor(states, Cursor{Abs: abs, Size: 20000, LastLine: 100}) // run B, stat taken before A's save

	// observed: LastLine=100 Size=20000  → 200 -> 100
}

The existing suite does not cover this. TestSaveCursorKeepsCommittedProgressAndLeavesNoTempFile pins the rewind guard with equal sizes (Size: 200 both sides, ingest_test.go:291-294), where 200 <= 200 makes the second clause true. And its "shrank" case at ingest_test.go:302 is recorded against a path that is never created on disk, so the only signal that anything got shorter is the stored Size field itself. In other words the current.Size <= cp.Size clause is load-bearing for a fixture that does not exercise a file.

Dropping the clause to if current.LastLine > cp.LastLine { return nil } fixes the hole without breaking the documented truncate/rewrite reset: with a real file, a genuine shrink is already handled by LoadCursor, which zeroes current.LastLine and lets the rewind through. I ran both sides of that:

  • probe above: fails as submitted, passes with the clause dropped;
  • a second probe (real 300-byte file truncated to 40) still rewinds to LastLine=2 with the clause dropped;
  • with the clause dropped, the only failure in the whole module is ingest_test.go:306 — the no-file-on-disk assertion — and cmd/mem stays green.

So this is one clause plus one fixture that needs a real transcript. The alternative, if you'd rather keep Size in the comparison, is to re-stat cp.Abs inside the lock instead of trusting the value Run captured at ingest.go:476. Either way the body's claim that processes "cannot move LastLine backwards" is currently stronger than the code.

2. Canonicalizing the root re-keys Idempotency-Key, and a new key is a duplicate insert, not a replay

CanonicalRoot resolves symlinks (ingest.go:196-205, filepath.EvalSymlinks), and the resulting path feeds both CursorPath and ingestIdempotencyKey(abs, line)sha1("<abs>:<line>") at server/cmd/mem/cmds_ingest.go:317-320. Base called filepath.WalkDir(base) with the root as spelled, so for a root given relatively or reached through a symlink (e.g. a dotfile manager that symlinks ~/.qoder), every per-line key changes on upgrade.

docs/integrations/qoder-ingest.md says such a store "is re-posted once under new keys" and the CHANGELOG says it "replays those files once". On the server, replay is keyed on the idempotency column only: internal/memory/service.go:107 is ON CONFLICT (workspace_id, idempotency_key_sha256) DO NOTHING and the replay lookup at :141-148 selects on that same pair. 0010_memory_lifecycle.sql:110 and 0012_memory_privacy_hardening.sql:25 add no other uniqueness — nothing deduplicates by content, path or source.ref. A new key therefore returns Replayed: false and writes a second copy of every memory for that store, which the connector will report under memory written, not server-replay.

Two things to untangle here:

If the goal is only the collision bug the body describes (two cwd's each holding sessions/p.jsonl), that is already achieved by filepath.Abs alone; it is the EvalSymlinks step that re-keys existing users. Suggest one of: key on the absolute-but-unresolved path, or add a one-time fallback read of the legacy sha1(spelled path) cursor, or say plainly in the CHANGELOG that upgrading duplicates that store's memories so the operator can decide.

3. #139 now has two live implementations

#139's canonical state names PR #140 (fix(ingest): serialize qoder checkpoint writers, PeterGuy326, branch codex/fix-139-cross-process-checkpoint) as the implementation candidate, still OPEN and currently mergeable: CONFLICTING. It also states the fix "must ship as a clean current-main bug fix, not by reviving the stale draft". Commit 785de61e here is a second, differently-shaped fix for the same function, arriving inside the refactor PR — while superseded #129 is still OPEN rather than closed. Somebody needs to pick which one owns saveCursorLocked; merging both is a conflict, and merging neither leaves #139 open.

Nits, not blockers

  • acquireCursorLock uses a blocking LOCK_EX with no LOCK_NB and no ctx, while Run holds a cancellable context — a save can block indefinitely behind another live process, and Ctrl-C is the only escape. Blocking may well be the intent; if so, worth a line in the package doc so put --watch doesn't rediscover it.
  • cursor_lock_other.go returns an error on unsupported OSes, so on plan9/js every checkpoint save now fails (warning only, exit code unchanged) and ingest silently re-reads from line 0 forever. Base succeeded there.
  • Report.Unchanged and Report.LocalGone carry the REQ-003 names but are always zero and have no test pinning their semantics — #110 is the first consumer, so REQ-003's "consumes names instead of inventing them" isn't yet exercised end to end.
  • The third CHANGELOG entry describes a misclassified failure tally that nothing in mem ingest qoder prints today; it is correct as written for internal/ingest, just not user-visible until feat(sync): put --watch one-way directory watch (minimal tier, carved from Phase 2 sync drive) #110.

Command to reproduce finding 1: drop the probe above into server/internal/ingest/ and run go test -count=1 -race ./internal/ingest/. I did not push anything to this branch, did not close or comment on #111/#129/#139/#140, and did not modify this PR.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Follow-up with a tested patch, not a review. I co-authored e9e17f8b and ab5bc035 on this PR, so read this as a proposed change to code we both own, not as independent review. Nothing has been pushed to sun-970:fix/ingest-extraction-111, including though maintainerCanModify is set.

Measured: this PR's guard fails #140's own regression scenario

#140 ships TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses (server/cmd/mem/qoder_checkpoint_test.go:182-230): a real 33-byte transcript, the further-along writer commits LastLine 12 / Size 33, then a stale writer saves LastLine 4 / Size 4, and the test asserts the cursor stays at 12 including its diagnostics.

I ran that exact scenario against the guard as submitted here, and against #140's guard:

head scenario result
this PR 785de61e LastLine 12 → 4, Size → 4 — regresses
#140 b9226a67 stays 12 / 33 — holds

The cause is what my earlier comment described: cp.Size is sampled by Run at server/internal/ingest/ingest.go:476, outside the lock, so a smaller incoming Size makes current.Size <= cp.Size false, short-circuits the &&, and lets the rewind through. #140's rule has no Size term at all, which is why it holds.

Also worth knowing: PeterGuy326's CHANGES_REQUESTED on #129 said "then add a deterministic interleaving test that proves the final cursor is the maximum value rather than any winner". TestConcurrentSaveCursorPublishesWholeCursors here still asserts cp.LastLine >= 1 && cp.LastLine <= 8 — the same 1-through-8 acceptance that review called out. I tightened that assertion to != 8 → fail on an untouched copy of this head and ran it 5× with -race: it still passes, because all eight writers share Size: 100. So "assert the maximum" is necessary but not sufficient here — the case that exposes this bug needs differing Size values, which is what the patch below adds.

Patch

Three files. It applies to 785de61e with patch -p1 (verified from a clean extract of that exact head), and with it applied: go build ./..., go vet ./..., gofmt -l internal/ingest cmd/mem clean, and go test -count=1 -race ./... green across the whole module.

--- a/server/internal/ingest/ingest.go
+++ b/server/internal/ingest/ingest.go
@@ -319,7 +319,7 @@
 // read as well as the atomic replacement.
 func saveCursorLocked(stateDir, p string, cp Cursor) error {
 	current := LoadCursor(stateDir, cp.Abs)
-	if current.LastLine > cp.LastLine && current.Size <= cp.Size {
+	if current.LastLine > cp.LastLine {
 		return nil
 	}
 	b, err := json.Marshal(cp)
--- a/server/internal/ingest/ingest_test.go
+++ b/server/internal/ingest/ingest_test.go
@@ -285,7 +285,11 @@
 
 func TestSaveCursorKeepsCommittedProgressAndLeavesNoTempFile(t *testing.T) {
 	states := t.TempDir()
-	abs := filepath.Join(t.TempDir(), "a.jsonl")
+	dir := t.TempDir()
+	abs := filepath.Join(dir, "a.jsonl")
+	if err := os.WriteFile(abs, make([]byte, 200), 0o600); err != nil {
+		t.Fatal(err)
+	}
 
 	// A run that read the file earlier must not rewind one that finished first.
 	if err := SaveCursor(states, Cursor{Abs: abs, Size: 200, ModTime: "2026-08-30T06:14:01Z", LastLine: 10}); err != nil {
@@ -299,6 +303,9 @@
 	}
 
 	// A rewrite that shrank the file is the one case allowed to rewind.
+	if err := os.Truncate(abs, 40); err != nil {
+		t.Fatal(err)
+	}
 	if err := SaveCursor(states, Cursor{Abs: abs, Size: 40, ModTime: "2026-08-30T06:14:03Z", LastLine: 2}); err != nil {
 		t.Fatal(err)
 	}
--- /dev/null
+++ b/server/internal/ingest/cursor_stale_size_test.go
@@ -0,0 +1,33 @@
+package ingest
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// TestSaveCursorRejectsRewindFromStaleSizeSnapshot pins the invariant #139 is
+// about: Run samples FileState before it takes the cursor lock, so a save can
+// arrive carrying a Size older than the committed one. With a real transcript
+// on disk that never shrank, only LastLine may decide the merge.
+func TestSaveCursorRejectsRewindFromStaleSizeSnapshot(t *testing.T) {
+	states := t.TempDir()
+	abs := filepath.Join(t.TempDir(), "a.jsonl")
+	if err := os.WriteFile(abs, make([]byte, 30000), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	// A further-along run commits first and saw the whole file.
+	if err := SaveCursor(states, Cursor{Abs: abs, Size: 30000, ModTime: "2026-09-01T00:00:00Z", LastLine: 200}); err != nil {
+		t.Fatal(err)
+	}
+	// A slower run saves second with a stat taken before that commit.
+	if err := SaveCursor(states, Cursor{Abs: abs, Size: 20000, ModTime: "2026-09-01T00:00:01Z", LastLine: 100}); err != nil {
+		t.Fatal(err)
+	}
+
+	got := LoadCursor(states, abs)
+	if got.LastLine != 200 || got.Size != 30000 {
+		t.Fatalf("cursor regressed to %+v, want LastLine 200 / Size 30000", got)
+	}
+}

What it does:

  1. ingest.go:322 — drop && current.Size <= cp.Size, so LastLine alone decides the merge, as in fix(ingest): serialize qoder checkpoint writers #140.
  2. ingest_test.go:285-307 — the existing "rewind is allowed when the file shrank" case asserted against a path that is never created on disk, so the recorded Size field was the only shrink signal in the test. The patch gives it a real 200-byte transcript and an actual os.Truncate, which is what LoadCursor's documented reset at ingest.go:277-281 keys on. With a real file the reset handles the shrink case and no Size clause is needed in the guard — that is why removing the clause keeps this test passing.
  3. cursor_stale_size_test.go — the new deterministic case: same shape as fix(ingest): serialize qoder checkpoint writers #140's test, at the core's API.

An alternative I'd also accept: keep return nil but mirror #140 more closely by not letting the rejected writer's ModTime/Size reach disk at all — which the current return nil already guarantees. The one thing that should not stay is the Size term in the merge condition.

Unchanged by this patch, and still open from my earlier comment: the EvalSymlinks re-key that duplicates memories rather than replaying them, and the #140/#147 overlap on who owns this function.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Closing under the fork-workflow decision recorded on 2026-09-03: repository
automation is not being enabled for fork pull requests, so a fork head cannot
carry a CI result, and every acceptance gate in this repository is written
against checks that ran. Nothing in this comment is a judgment that the work is
wrong; where it is right, it is re-landed on an organization branch instead.

#111 and #139. This one needs a specific warning attached to it before it is
reused by anyone.

It is three commits, and only one of them is this contributor's own work:

commit author committer
e9e17f8b waterbro-8 <waterbro-8@users.noreply.github.com> liyuanyang
ab5bc035 勒布朗-詹姆斯 <2986253039@qq.com> liyuanyang
785de61e liyuanyang <liyuanyang@users.noreply.github.com> liyuanyang

The first is work that was already merged in this repository under #129,
re-committed with liyuanyang inserted as committer. The second carries a
personal, non-noreply address for someone outside the organization. So
cherry-pick 保留原作者署名 applied to this branch would preserve exactly the
attribution #145's rule rejects, and would re-present #129's merged work as new
contribution. Neither is done here.

What survives as a fact is the analysis in the PR description, which is sound:
#111's shared local-ingestion core is a real gap. It has no organization-branch
implementation now, so #111 has no candidate pull request after this close.

The commits are not lost. A closed fork PR keeps its head ref:

git fetch https://github.com/bytefolk/mem.git refs/pull/147/head:pr-147

Every file in this branch was therefore available to the re-doing work, whether
or not it was used.

@waterbro-8 waterbro-8 closed this Sep 3, 2026
waterbro-8 added a commit that referenced this pull request Sep 18, 2026
Refs #111

## Requirement and scope

Re-lands #147 onto current `main` as an organization branch. #129/#147
were closed under the 2026-09-03 fork-workflow decision, not as a
judgment that the extraction was wrong. Blocker PR #108 is already
merged.

Preserves qoder behaviour: same memories payload shape, same
`Idempotency-Key` derivation for a canonical absolute root, same stdout
summary, same cursor file format/location. Adds the OS-backed cursor
lock from the #147 follow-up so concurrent writers do not share a `.tmp`
name.

## Changes

- New `server/internal/ingest` package: walk, per-path cursor (atomic
rename, shrink-reset), `--dry-run`/`--limit`, closed failure codes,
report aggregation.
- `mem ingest qoder` is a thin connector (parser + HTTP upload).
- OS advisory lock around cursor load/save (`cursor_lock_*.go`).
- No `fsnotify`, no `--watch` (#110 stays a successor).

## Validation ledger

| ID | Criterion | Command | Status |
| --- | --- | --- | --- |
| V1 | Qoder tests with import-path changes | `go test ./cmd/mem -run
Ingest` | NOT VERIFIED locally — host Go 1.22, module requires 1.25 |
| V2 | Core fixtures: dry-run, shrink-reset, 409 degrade, corrupt cursor
| `go test ./internal/ingest` | NOT VERIFIED locally — same toolchain
gap |
| V3 | `git diff --check` | local | PASS |
| V4 | No cobra/stdout in the core package | source review of
`server/internal/ingest` | PASS |

Independent review still required. No merge or issue close.

Original extraction: @waterbro-8. Cursor lock follow-up: @sun-970 /
liyuanyang. Canonical-path identity follow-up: 勒布朗-詹姆斯.

---------

Co-authored-by: waterbro-8 <waterbro-8@users.noreply.github.com>
Co-authored-by: liyuanyang <liyuanyang@users.noreply.github.com>
Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
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.

3 participants