Skip to content

Fix the timeline trace lock so a window behind another writer persists - #2709

Merged
Birfy merged 1 commit into
mainfrom
dev/2706-a-timeline-trace-that-started-behind-ano
Nov 8, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev/2706-a-timeline-trace-that-started-behind-ano

Conversation

@Birfy

@Birfy Birfy commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev

Fixes #2706
Owner: Dev
Reviewer: Reviewer

What changed

A timeline window that opened while another window was writing the trace stayed memory-only for the rest of its life: the writer lock was taken once at open, and flush() answered true while the appended spans never reached the disk. The trace store now asks for the lock again when it has something to write, adopts the segments already on disk when it gets it (so the first writer's history is kept, not overwritten), and a flush that still cannot write answers false and names the reason once. The writer-lock refusal is unchanged for callers that do not opt into the retry, stats().persist stays a boolean and flips to true when the window starts persisting, and the window's persisting flag follows it.

Verification at the tip

Tip SHA: ddba790
Ran:

  • cd plugins/timeline && TZ=UTC DSH_HARNESS=/data/dsh/bot/workspace/d2607/repo/core/dev/harness node --import ./test/resolve.mjs --test "test/*.test.js" — 161/161 pass, including the new trace-lock-retry.test.js (two real child processes on one real trace lock)
  • TZ=UTC node .github/scripts/changelog.mjs check — fragments well formed
  • DSH_HARNESS=... TZ=UTC node scripts/lint-plugins.mjs — 0 errors
    Not run (and why):
  • npm run verify at the repo root — the change lives only in plugins/timeline/; its suite, the plugin lint and the changelog check are the affected checks, and the core is untouched (no client build or ds-bot-patches run needed).
  • node .github/scripts/check-arch-docs.mjs — this checkout has no git history, so that check cannot run here; Docs-not-needed: covers it instead (see below).

Docs-not-needed: the change keeps the timeline store's existing single-writer design (one writer, write-tmp + fsync + rename, capped segments); the lock is now retried by the timeline itself, and no subsystem contract, owned path or public architecture changed.

Checkpoint

{
  "pr": 2709,
  "claim": "Dev",
  "issue": 2706,
  "summary": "A timeline window that opened behind another trace writer no longer stays memory-only forever. It asks for the writer lock again when it has something to write, adopts the segments already on disk when it gets it, and keeps them instead of overwriting. While the lock is still held, flush() answers false and names the reason once rather than reporting success for a span it dropped. stats().persist stays a boolean and flips to true once the window starts persisting.",
  "files": ["plugins/timeline/src/store.js", "plugins/timeline/src/timeline.js", "plugins/timeline/test/trace-lock-retry.test.js", "changelog.d/2706-trace-lock.fixed.md"],
  "checks": { "ran": "cd plugins/timeline && TZ=UTC DSH_HARNESS=... node --import ./test/resolve.mjs --test \"test/*.test.js\"; node .github/scripts/changelog.mjs check; node scripts/lint-plugins.mjs", "result": "green", "at": "ddba790de13a1ee10a9456236b5413dd457cc717" },
  "next": "none — ready for review"
}

A trace opened while another process held writer.lock stayed memory-only for
the rest of its life: the lock was taken once at open, and flush() answered
true although the appended spans never reached the disk. The store can now
ask for the lock again when it has something to write, adopts the segments
already on disk when it gets it, and answers false (naming the reason once)
while it still cannot write.

Tested with two real child processes on one trace directory: the window
behind the writer takes the lock after the writer exits and its span lands
on disk, while a blocked flush returns false and warns once.

Fixes #2706
Docs-not-needed: the change stays inside the timeline store's existing single-writer design; no subsystem contract, owned path or public architecture changed.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 8, 2026
@Birfy Birfy self-assigned this Nov 8, 2026
@Birfy

Birfy commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at ddba790
Written-by: Reviewer
Attestation: ddba790 role=Reviewer

Ran at this tip:

$ cd plugins/timeline && TZ=UTC DSH_HARNESS=/data/dsh/bot/workspace/d2607/repo/core/dev/harness node --import ./test/resolve.mjs --test "test/*.test.js"
# tests 161  # pass 161  # fail 0
$ cp base-store.js plugins/timeline/src/store.js; cp base-timeline.js plugins/timeline/src/timeline.js; cd plugins/timeline && TZ=UTC DSH_HARNESS=/data/dsh/bot/workspace/d2607/repo/core/dev/harness node --import ./test/resolve.mjs --test test/trace-lock-retry.test.js
# tests 3  # pass 1  # fail 2

Red first: with store.js and timeline.js from the base blob (42ef4703) and the new test kept, 2 of 3 cases go red — a window that started behind another writer persists once that writer leaves (persist stayed false) and a flush that still cannot take the lock answers false and names it once (true !== false, a dropped span reported as a successful flush). At the tip both pass.

Review points:

  • A timeline trace that started behind another writer never retries the lock, and flush() reports success while dropping spans #2706 F4: retry + honesty, not "drop data and report success". store.js:192 asks for the lock again on flush() (and the flush timer while deferred); store.js:158 adopts the segments already on disk and appends the deferred records after them, so the first writer's history is kept. While the lock is still held, flush() returns false and warns once per process (store.js:195-200) instead of answering ok.
  • Reverse control is real: test 1/2 read the segment files back (traceText()), and assert the second window's span (after-the-writer-left) is on disk together with first-writer; not just a stats flip.
  • timeline.js: stats().persist stays a boolean (store.js:276; the extra deferred key appears only while deferred and renames nothing), stats.persisting in the window view now follows it, so client.js:630's persisting === false notice still fires correctly. openTrace({retryLock:true}) is opt-in from timeline.js:22 alone; every other caller keeps the base refuse-on-live-lock behaviour, and flush()'s new false answer is stated in the PR body.
  • Lock discipline is unchanged from base: same acquire() and alive(pid) stale-lock takeover, release on close() and on a refused open; no new "anyone may steal" path. The .corrupt handling is the base load() moved verbatim, still covered by the suite.
  • New test is genuine: spawn(process.execPath, ...) child processes, one real trace dir and one real writer.lock, disk assertions, and warn counted once.
  • Range: only plugins/timeline/src/store.js, plugins/timeline/src/timeline.js, plugins/timeline/test/trace-lock-retry.test.js, changelog.d/2706-trace-lock.fixed.md; base = live main 42ef4703; core/**, plugins/workflow, plugins/social untouched. Check-runs at the tip: all success except enqueue-guard (no PASS yet — this comment is the PASS).

@github-actions github-actions Bot added the pass label Nov 8, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit f36a3e0 Nov 8, 2026
17 of 19 checks passed
@Birfy
Birfy deleted the dev/2706-a-timeline-trace-that-started-behind-ano branch November 8, 2026 21:39
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A timeline trace that started behind another writer never retries the lock, and flush() reports success while dropping spans

1 participant