Visit written URLs before their dependents, and record the write order in the diagnostics - #6070
Conversation
An incremental index job re-indexes the URLs the triggering write named
(the targets) plus every card that depends on them, and the pass's write
order was decided by sortInvalidations followed by the topological
ordering over the persisted `deps` rows. That put a target first only
when the dependency graph happened to say so. Two reachable cases put it
last instead:
- A dependent whose persisted `deps` row does not yet name the target —
the state between a link being written and that row being reindexed.
With no edge to order the pair, lexical order alone decides.
- A target inside a dependency cycle with its dependents (two cards
with mutual `linksTo` links). A cycle has no topological order, so
the stranded URLs fall back to the order they arrived in, which is
the lexical one.
Lead the ordering input with the written URLs, in write order, via
prioritizeWrittenURLs. orderInvalidationsByDependencies reads position
as a priority rather than a fixed order, so a real dependency edge still
overrules the hoist — an instance written alongside the module it adopts
from is still visited after it — while the cases the graph leaves open
now resolve target-first.
Nothing else about the job changes: same single job, same priority, same
invalidation set, same clientRequestId handling, same settle-then-
broadcast, same response timing.
Make the resulting order observable by stamping `diagnostics.writeSeq`
on each row as it enters the writer's write path. `indexedAt` resolves
only to the millisecond and a pass's rows drain through buffered
multi-row upserts that share one timestamp, so it cannot order two rows
of the same pass; grouped with `invalidationId`, `writeSeq` reconstructs
a pass's visit order in SQL for both the tests and operators.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25e69685cc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in ordering and write-sequence handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR prioritizes written URLs before their dependents during incremental indexing and records per-row visit order with diagnostics.writeSeq.
Changes:
- Adds target-first dependency ordering.
- Persists write-sequence diagnostics.
- Adds unit and end-to-end ordering tests.
Review findings:
index-runner.ts:491— critical, 2 votes: module-before-instance ordering can still fail with missing or stale dependency data.index-runner.ts:1194— moderate, 1 vote: mixed cycles can still place a target after a dependent.index-writer.ts:312— moderate, 2 votes: sequence state and invalidation IDs can become misaligned across reused batches.index.ts:740— nit, 2 votes: update outdated write-stamp documentation.index.ts:747— nit, 1 vote: clarify that the diagnostic query returns rows, not unique visited URLs.
File summaries
| File | Summary |
|---|---|
packages/runtime-common/index.ts |
Defines write-sequence diagnostic metadata. |
packages/runtime-common/index-writer.ts |
Assigns and persists write sequences. |
packages/runtime-common/index-runner/dependency-resolver.ts |
Documents priority-based ordering. |
packages/runtime-common/index-runner.ts |
Prioritizes written URLs during dependency ordering. |
packages/realm-server/tests/target-first-index-ordering-test.ts |
Tests persisted ordering end to end. |
packages/realm-server/tests/index-visit-order-test.ts |
Tests composed ordering and edge cases. |
Review details
Suppressed comments (2)
packages/runtime-common/index-runner.ts:1198
- [Claude Code 🤖] Leading the input with targets does not guarantee target-first order when a target is trapped in a dependency cycle alongside one dependent and another fan-out dependent has no persisted
depsrow. The topological pass emits the zero-indegree dependent first and appends the cyclic leftovers afterward, so the target can still be written after a dependent despite this hoist. Please make target priority span the queue/cycle boundary or add a regression for this mixed graph.
// The result is the input to `orderInvalidationsByDependencies`, which reads
// position as a priority rather than as a fixed order: a topological edge
// still wins, so a target that depends on another URL in the same set (an
// instance written alongside the module it adopts from) is visited after it,
// and the module's file entry exists before the instance renders. What the
packages/runtime-common/index.ts:748
- [Claude Code 🤖] This example returns one row for every index row, not one position per visited URL: a card visit writes both
fileandinstancerows, as the new test accounts for. Operators will therefore see duplicate URLs and cannot directly use this query as the promised visit order; group by URL and order by its minimumwriteSeq, or describe the result as row order.
// `SELECT url FROM boxel_index WHERE diagnostics->>'invalidationId' = '<id>'
// ORDER BY (diagnostics->>'writeSeq')::int`. An incremental pass writes
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…index channel `prerendered_html.diagnostics` carried only what the render itself reported — no `invalidationId`, no `indexedAt`, no order. A row with a quiet render carried nothing at all, so a `prerender_html` job's fan-out could not be grouped, ordered, or even attributed to a pass, while the index channel's could. That asymmetry has no reason behind it: both channels are written by a `Batch`. Route both channels' stamps through one `#writeSideStamps` helper and merge them over the render's own diagnostics in `writePrerenderedHtmlRow` (and onto the mirrored `error_doc.diagnostics`, matching the index channel's error-row pattern). Every live rendering now carries all three. Positions come from the caller rather than the counter, so a fused visit's two rows — its `boxel_index` half and its `prerendered_html` half — share one `writeSeq` instead of consuming two: one visit is one position. A `prerenderHtmlOnly` batch has no index half and numbers its own writes. The two channels' `invalidationId`s stay different, because the id is scoped to a `Batch` and an index pass and the `prerender_html` job it spawns are separate batches. Each id groups its own channel; the channels join on url. Documented on the field and in the skill rather than papered over. Tombstones are unchanged: the index channel's predate the pass's visits and are overwritten by them, and the render channel's clear `diagnostics` outright. A NULL `writeSeq` on an `is_deleted` row is therefore what identifies a URL a pass never reached. Update the indexing-diagnostics skill for both channels: document the stamps and the cross-channel caveat, add a "Reconstructing a pass's write order" section, and correct the stalled-job queries that ordered partial progress by `indexedAt` — which cannot separate rows a single buffered upsert stamped with one millisecond. The planned-visit-order description now names all three ordering steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
…he hoist Three defects in the target-first ordering, found in review. Leading the ordering input with the written URLs in write order let a target instance overtake a target module in the same job. The module's file entry has to exist before an instance that adopts from it renders, and `orderInvalidationsByDependencies` cannot restore that edge for a brand-new pair because the index holds no `deps` row for either yet — so the hoist silently dropped a safeguard `sortInvalidations` had been providing. The write path's own module-then-instance flush gate keeps most batches apart, but queue coalescing can still merge an instance job and a module job into one. Extract the visit-class rule both orderers now share (`visitClassRank`) and rank the hoisted targets by it, keeping write order inside each class. A target stranded in a dependency cycle lost to any dependent the graph left schedulable, because the topological pass appended a cycle's leftovers after everything else regardless of priority. Split the scheduler into `#kahnByPriority` and run it twice: whatever the first pass cannot schedule is the set of URLs sitting in or behind a cycle, and every edge out of such a URL leads to another one of them, so dropping their out-edges leaves an acyclic graph whose second pass schedules them on priority. Costs a cycle nothing it had not already cost — no order satisfies every edge in a cycle — and leaves an acyclic set's ordering byte-identical, since the second pass only runs when the first strands something. `invalidate()` rotated the correlation ID without touching the write sequence, so a second fan-out on one batch numbered from where the first left off, and a row buffered before the rotation was prepared after it — filed under the new fan-out while carrying the old one's sequence. Flush before rotating and reset the sequence with the ID, so the two stamps always describe the same fan-out. Also: correct the `writeSeq` doc's example query, which selected rows rather than URLs and so returned every URL twice, and document the retried-job case where a promoted generation legitimately holds two batches' ids — grouping by the newest one omits every URL the earlier attempt finished, which for a stuck-job investigation is the wrong half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
|
[Claude Code 🤖] A target stranded in a dependency cycle lost to any schedulable dependent. Reproduced exactly as described: target Fixed by splitting the scheduler into Dropping those edges costs a cycle nothing it hadn't already cost: no visit order satisfies every edge in a cycle, so some member was always going to render against a stale sibling. And an acyclic set is untouched — the second pass only runs when the first strands something, which I pinned with a test rather than left to inspection. That makes the ordering claim in the PR description true as written, which it wasn't before. Two regression tests added in Verification for the round: 139 local tests pass across Generated by Claude Code |
Freeing a cycle's members to be scheduled on priority was done by dropping every out-edge of every URL a first scheduling pass could not place. That set is wider than the cycle: a URL reachable from a cycle member also never clears its indegree, so it was classified as stranded too, and its outgoing edges went with it. A URL that merely depends on a cycle member therefore lost the edge that ordered it — an instance adopting from a module caught in an import cycle could be visited before that module had a file entry, which is the module-first prerequisite the hoist was just taught to respect. Only the edges whose endpoints share a strongly-connected component are unsatisfiable, so only those are dropped. `#stronglyConnectedComponents` computes them with an iterative Tarjan (iterative so a deep dependency graph cannot overflow the stack), and `#edgesWithinCyclesDropped` keeps every edge that crosses out of a component — including the one from a cycle member to a URL that depends on it. The condensation of a digraph by its components is a DAG, so one scheduling pass now always completes and the second pass is gone. Measured against the previous implementation on a module pair importing each other plus an instance adopting from one of them: it returned `card.json, one.js, two.js`, visiting the instance first; this returns `one.js, card.json, two.js`. The baseline assertion in the cycle-ordering test moves with the behavior: `bbb`, which links to the target but is in no cycle, keeps its edge and now waits for the target rather than being stranded alongside it, so the un-hoisted order is `aaa, zzz, bbb`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
…ng test The end-to-end ordering test measured a pass that wrote only the URLs the write named — no dependents — and reported that as an ordering failure, which is the least informative shape the failure could take. Two changes, both borrowed from the atomic-batch indexing test next door. Drain the queues explicitly instead of relying on `?waitForIndex=true`: each push now awaits `realm.incrementalIndexing()` and then `settlePrerenderHtmlJobs`, so a scenario's setup pushes are fully settled on both channels before the next one starts. Assert the precondition the ordering assertion silently depends on. A dependent reaches a pass's fan-out only by naming the written URL in its `deps` row, so each scenario now checks that first and prints the actual deps when it does not hold — a link that failed to record reads as a link that failed to record, not as a target written in the wrong order. The ordering assertions also carry a dump of every stamped row in the realm (url, type, sequence, pass, timestamp), so a failure names what the pass did write rather than only what it did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
The ordering scenarios need a card to depend on the card they write, and the fixture declared the link `searchable` but rendered it nowhere. That does not record a dependency: `boxel_index.deps` captures a relationship the templates actually read, which is why the relationship-deps test's `hiddenFriend` — linked but never rendered — is asserted absent from deps while its rendered siblings are present. With nothing recorded, the invalidation walk found no dependents and the pass under test wrote only the URLs the write named, which is what the assertions reported. Render `friend` in the isolated and embedded templates as `atom`, the shape the cyclic-link fixture in the relationship-deps test uses: atom reads only `firstName`, so following the link terminates after one hop and two cards can link to each other — the cycle these scenarios need — without the embedded render recursing through it. `searchable` stays for the search doc; it is no longer what the fan-out rests on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
Host Test Results 1 files ±0 1 suites ±0 2h 37m 27s ⏱️ + 5m 8s Results for commit 817bf2c. ± Comparison against earlier commit cd7bc2b. Realm Server Test Results 1 files ± 0 214 suites +2 1h 22m 21s ⏱️ + 3m 22s Results for commit 817bf2c. ± Comparison against earlier commit cd7bc2b. |
The hoist put every written URL ahead of every dependent, which could lead with a target instance while the module it adopts from was still waiting in the fan-out — and because the topological pass reads the incoming order as a priority (and drops the edges inside a cycle), a module that landed behind an instance stayed there. Rank the whole list by (visit class, target, position within group) instead of concatenating targets ahead of dependents, so class is the first-order priority for targets and dependents alike and only a recorded non-cycle edge can invert it. Within a class the targets still lead in write order and the dependents keep the order they arrived in. The composed order is now, for every random graph probed: exactly (class, target, position) when the index holds no deps rows, and free of class inversions whenever the recorded edges respect class themselves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
Two corrections to the write-side stamps: The buffer's dedupe (one multi-row upsert cannot touch a conflict target twice) kept the later of two writes of the same row, and with it the later position — moving the row behind URLs the visit loop only reached afterwards, and leaving a pass whose first-written row was rewritten with no row at position 0 at all. The row's contents are still the later write's; only its position is the earlier one's now. The render channel's error rows were also mirroring the stamps onto `error_doc.diagnostics`, which is the blob operator mode renders verbatim in "send error to AI assistant". The stamps are bookkeeping for the operator queries, so the mirror goes back to carrying what the render itself reported, exactly as before. The canonical `diagnostics` column still always carries them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
Docs: the guarantee is stated with the two things that outrank it (visit class, and a recorded non-cycle dependency); `invalidationId` is described as one id per invalidation fan-out, refreshed by each `invalidate()` call, rather than one per batch; an index-channel tombstone carries that id and `indexedAt` but no position, rather than no diagnostics at all; two rows per URL is a card instance's shape, not every URL's; and the working-table queries name the `generation` column they actually have instead of a `realm_version` that has not existed for some time. Test: the scenario was built around a link back from the target, which made the target genuinely depend on one of its dependents — the pass was right to visit that dependent first. Drop the link, so the target depends on nothing in its own fan-out and only the ordering under test can put it first, and read the preconditions through the two-channel `depsForIndexEntry` helper: a rendered relationship is recorded on the render channel, which the fan-out walks and a single-table query misses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues remain in copied-row stamping, duplicate write sequencing, error diagnostics, and the documentation rubric.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
.claude/skills/indexing-diagnostics/SKILL.md:596
- [Claude Code 🤖] This new Mode C guidance uses
writeSeqto identify the last completed file, but the “High confidence” rubric below still says to use the row with maxindexedAt(line 689). Since one buffered upsert can give several rows the same millisecond, the rubric can select the wrong row; update that remaining rubric to use the per-invalidationIdwriteSeqquery as well.
The bottom row of the per-`invalidationId` query (max `writeSeq`) is **the most recently completed file**; the file the worker stalled on is most likely the _next_ one in the planned visit order. That order is: `index-runner.ts::sortInvalidations` (realm config first, then non-`.json` files before the `.json` ones that depend on them, otherwise lexical by href), then `prioritizeWrittenURLs` hoists the URLs the triggering write named ahead of the dependents the fan-out found, then `orderInvalidationsByDependencies` topologically orders the result — treating the position it was handed as a priority, so a dependency edge wins and everything else keeps the order above. Combine three signals to pin it down:
1. The bottom row's `url` (max `writeSeq`) is the last-completed file.
packages/runtime-common/index-writer.ts:1522
- [Claude Code 🤖] In fused mode,
prerenderedHtmlEntryFrompasses this already-stamped diagnostics object into the error entry, and the error path copies it intoerror_doc.diagnostics. That putsinvalidationId,indexedAt, andwriteSeqin the operator-facing error mirror, contradicting the stated contract that it contains only render-produced diagnostics while the canonicaldiagnosticscolumn keeps write-side stamps. Strip the write-side fields when constructing the error-doc mirror.
let diagnostics: Diagnostics = {
...(entry.diagnostics ?? {}),
...this.#writeSideStamps(seq),
};
packages/runtime-common/index-writer.ts:1059
- [Claude Code 🤖] The first-position rule is implemented only in the split branch above. In fused mode, every duplicate
(url, type)remains inbufferedand this loop writes them all, so the final upsert replaces the earlierwriteSeqwith the later one. A repeated visit can therefore appear after URLs reached later, violating the documented order contract; apply the same last-content/first-sequence deduplication here.
} else {
for (let { url, entry, seq } of buffered) {
await this.#writeEntryNow(url, entry, seq);
}
packages/runtime-common/index.ts:719
- [Claude Code 🤖] This guarantee is bypassed by
Batch.copyFrom()andcopyPrerenderedHtmlFrom(), which insert source rows' diagnostics directly instead of applying#writeSideStamps. A copied row can therefore have no stamps (if its source predates this change) or retain another batch'sinvalidationId/writeSeq, so queries can attribute it to the wrong pass. Either stamp copied rows with the destination batch's stamps or explicitly exclude copy batches from this contract.
// `RenderTimeoutDiagnostics` (which already carries `requestId`) with three
// write-side stamps. Every live row on either channel carries all three,
// stamped as the row enters the IndexWriter's write path — so a row is
// always attributable to the pass that wrote it, whether or not its render
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
`writeSeq` was minted at every write, so a row written twice in a pass took two positions and kept whichever landed last. The buffer's dedupe covered that only within a single drain: a rewrite on the far side of a flush — the size cap, or the flush a dependency read forces — still overwrote the row's position with a later one, putting it behind URLs the visit loop only reached afterwards. A position is now taken once per row (`#positionOf`, keyed by url and row type, held for the fan-out and cleared when `invalidationId` rotates) and reused by every later write of that row, on both channels and on both the buffered and the immediate path. The row's contents are still the last write's. A rewrite consumes no position, so a fan-out's sequences are gapless rather than pitted with the gaps the per-drain rule left behind. Two documentation corrections that go with it: the stamps contract now excludes rows a realm copy produced, which clone the source realm's rows — and its diagnostics — rather than rendering them; and the render channel's `error_doc` mirror is described as mirroring the entry's own diagnostics, which for a fused visit already carry that visit's index half stamps, rather than claiming it never carries stamps at all. The stalled-job rubric in the indexing-diagnostics skill orders by `writeSeq` too — it was still naming max `indexedAt`, which the same skill explains cannot order rows within a pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
backspace
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Convergence pass over the head commit: each finding the automated reviews raised, checked as a change in the code rather than from the reply, plus the ordering and write-sequence paths read end to end — the strongly-connected-component edge drop, the priority Kahn pass, #positionOf across drains and across channels, the invalidate() flush-and-reset, both channels' stamping and the error-doc mirror.
No blocking issues remain. Approving.
Dispositions of the automated-review findings, each verified on the head commit:
- Target instance hoisted ahead of the module it adopts from — resolved.
prioritizeWrittenURLsranks byvisitClassRankbefore target-ness; the empty-depsregression test pins it. - Target stranded in a cycle losing to a schedulable dependent — resolved.
#edgesWithinCyclesDroppedremoves only intra-component edges, so a cycle member competes on priority while a URL behind the cycle still waits; both halves have a test. writeSeqcontinuing acrossinvalidate()calls, and buffered rows filed under the new id — resolved by the flush before the id rotates plus the counter and position reset.- A rewrite losing its first position across a flush — resolved by
#positionOf, keyed per row and held for the fan-out; the split-pipeline test rewrites across an explicit flush. - Copied rows outside the stamping contract, the fused error-doc mirror, the Mode C rubric ordering by
indexedAt, the row-vs-URL example query, and the stale two-stamps doc sentence — resolved in the field doc and the skill as the threads describe. - A retried job mixing two attempts' ids — documented rather than changed. The thread's reasoning holds (restamping would defeat the resume), and the skill's Mode C query enumerates the ids per generation.
One non-blocking question, inline on target-first-index-ordering-test.ts: whether the end-to-end test can fail without the hoist. The unit suite already pins the mechanism, so it only affects what that test proves.
The fixture's `friend` link was `searchable`, which put the scenarios' edges on the index channel as well: the search-doc walk follows a searchable link and the meta route unions the targets it collected into `boxel_index.deps`, and that is an edge the dependency ordering reads. Where it reaches the ordering it puts the target first by itself, so the scenarios could pass without the ordering they exist to pin. Drop `searchable` so the link is recorded only by the `atom` render. The edge then lands on the render channel, which the invalidation walk reads and the ordering does not: the fan-out still finds the dependents, and only the write's own URL can put the target ahead of them. Each scenario's precondition reads both channels, so a fan-out that no longer reaches a dependent fails there, naming the deps it did find. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U
An incremental index job re-indexes the URLs the triggering write named — the targets — plus every card that depends on them, writing one row per card. This makes the targets' rows the first ones the pass writes, so the card a user actually changed is never written after cards that merely depend on it, and records the resulting order on both diagnostics channels so it can be read back in SQL.
Where the order comes from
The pass builds its visit list in three steps:
sortInvalidations(realm config first, then modules, then instances lexically), thenorderInvalidationsByDependencies(a topological order over the persistedboxel_index.depsrows). That produced a target-first order only where the dependency graph happened to say so. Three reachable cases produced the opposite:depsrow does not name the target — the state between a link being written and that row being reindexed, and also the steady state for a relationship only the format renders record (those edges live onprerendered_html.deps, which the invalidation walk reads but the ordering does not). With no edge to order the pair, lexical order alone decides.linksTolinks. A cycle has no topological order, so the URLs it strands fall back to the order they arrived in, which is the lexical one.In all three, a target that sorts late lexically was written last.
The ordering change
prioritizeWrittenURLsranks the visit list by (visit class, target, position within group): the targets lead in the order they were written, then the dependents in the order they arrived in.orderInvalidationsByDependenciesreads that rank as a priority rather than a fixed order, so exactly two things outrank being a target:depsrow is visited after it.visitClassRank— the class rule extracted fromsortInvalidationsand now shared by both orderers — is why the guarantee survives the topological pass, which ranks a cycle's members on priority alone and would otherwise leave a module stuck behind an instance.#edgesWithinCyclesDroppeddrops exactly the edges whose endpoints share a strongly-connected component (iterative Tarjan), so a cycle's members compete on priority — no order satisfies a cycle anyway — while the edge that leaves a cycle survives and still holds back the URL sitting behind it. The condensation of a digraph by its components is a DAG, so one Kahn pass always schedules everything, and an acyclic set's ordering is byte-identical to before.Nothing else about the job changes: one job per write, same priority, same invalidation set, same
clientRequestIdhandling, same settle-then-broadcast, same response timing (the request still waits for the whole pass).Recording the order —
diagnostics.writeSeqwriteSeqis stamped on each row as it enters the writer's write path.indexedAtresolves only to the millisecond and a pass's rows drain through buffered multi-row upserts that share one timestamp, so it cannot order two rows of the same pass. Grouped with the existinginvalidationId,writeSeqreconstructs a pass's write order. A card instance contributes two rows (fileandinstance), so reduce to one position per URL:The lowest sequences in an index fan-out are its targets; everything after them is dep closure.
invalidate()flushes the write buffer before rotatinginvalidationIdand resets the sequence with it, so the two stamps always describe the same fan-out and each fan-out is 0-based. Without the flush, a row buffered before the rotation would be prepared after it — filed under the new fan-out while carrying the old one's sequence.A position is taken once per row and reused by every later write of it (
#positionOf, keyed by url and row type, held for the fan-out and cleared with the id), so a row written twice in one pass keeps the position of its first write while its contents are the last write's. Without that, the buffer's dedupe — one multi-row upsert cannot touch a conflict target twice — would move the row behind URLs the visit loop only reached afterwards, and would only cover a rewrite that landed inside the same drain. A rewrite consumes no position, so a fan-out's sequences are gapless.Both channels, not just the index one
prerendered_html.diagnosticscarried only what the render itself reported — noinvalidationId, noindexedAt, no order — and a row with a quiet render carried nothing at all, so aprerender_htmljob's fan-out could not be grouped, ordered, or even attributed to a pass. Both channels are written by aBatch, so that asymmetry had nothing behind it. Both now route their stamps through one#writeSideStampshelper, and every live rendering carries all three, merged over the render's own diagnostics.The render channel's
error_doc.diagnosticsmirror — the blob operator mode renders verbatim in "send error to AI assistant" — is unchanged by this PR on either pipeline: it mirrors the entry's own diagnostics and adds nothing. What that amounts to differs by pipeline, as it did before: a split pipeline's render entry carries render-produced fields only, while a fused visit's entry is built from its index half's blob (prerenderedHtmlEntryFrom) and so arrives with that row's stamps already merged in.Positions come from the caller rather than the counter, so a fused visit's two rows share one
writeSeq— itsboxel_indexhalf and itsprerendered_htmlhalf are one position, not two. AprerenderHtmlOnlybatch has no index half and numbers its own writes.The two channels'
invalidationIds are deliberately different: the id is minted perBatchand refreshed by eachinvalidate()call, and an index pass and theprerender_htmljob it spawns are separate batches. Each id groups its own channel; the channels join onurl(plusgeneration). That's documented on the field and in the skill rather than papered over.Tombstones take no position and so carry no
writeSeq. The index channel's still carry the pass'sinvalidationIdandindexedAt(they are written byinvalidate()and overwritten by the visit that follows); the render channel's cleardiagnosticsoutright. A NULLwriteSeqon anis_deletedrow is therefore what identifies a URL a pass never reached.Rows a realm copy produced are outside the contract, and the field doc says so:
copyFrom/copyPrerenderedHtmlFromclone the source realm's rows rather than rendering them, so a copied row keeps whatever its source carried — that realm's pass, or nothing when the row predates these stamps. A copy performs no visit, so it has no write order of its own to record.Promotion stays atomic on both channels —
done()swaps each working table in one transaction — so this is a within-pass write-order guarantee, not a change to what a reader can observe mid-pass.Known limit
A retried job's promoted generation holds rows from two batches:
loadResumedRowskeeps the previous attempt's rows as they are, so they retain that attempt'sinvalidationIdand sequences and only the URLs this attempt visits carry the current pair. Ordering within one id stays sound, but a query that wants the whole generation has to union the attempts' ids. That predateswriteSeq(resumed rows already kept the prior attempt'sinvalidationId); restamping them would defeat the resume. Documented on the field and in the skill's Mode C, with a query that enumerates every id at the generation.Skill update
.claude/skills/indexing-diagnostics/SKILL.mdgains a "Reconstructing a pass's write order" section, documents the stamps on both channels plus the cross-channelinvalidationIdcaveat and the retried-job case, and corrects the stalled-job reading in Mode C that ordered partial progress byindexedAt— which cannot separate rows a single buffered upsert stamped with one millisecond — in both the query and the confidence rubric that reads it. The planned-visit-order description now names all three ordering steps instead of justsortInvalidations. Drive-by: the working-table queries now name thegenerationcolumn they actually have, in place of arealm_versionthat has not existed for some time and made them error out on paste.Tests
packages/realm-server/tests/index-visit-order-test.ts— pins the composed ordering at the seam where it is decided, with no services:prioritizeWrittenURLson its own, then fed throughorderInvalidationsByDependenciesover a stubbeddepsgraph. Covers a recorded dependency overruling the priority, a module never losing its class to an instance (whether or not the write named the instance, and with thedepsgraph empty so nothing downstream can restore the edge), a target realm config keeping the head of the target group, a dependent with nodepsrow, a target in a cycle, the mixed cycle-plus-dropped-edge graph, a URL behind a cycle still waiting for the member it depends on, the acyclic no-op, and a multi-target batch. Where the change alters an outcome, the test asserts both the un-prioritized and prioritized orders so it states the difference rather than just the result.packages/realm-server/tests/prerender-html-split-test.ts— two new tests pin the stamps' contract per channel: on the render channel, one grouping id per pass, a 0-basedwriteSeqin render order rather than lexical order, and the stamps merging around a rendering's own diagnostics; on the index channel, the buffered write order surviving onto the rows, and a row rewritten both inside a drain and after an explicit flush keeping its first position while taking the last write's contents. The three existing tests thatdeepEqual'd a whole diagnostics blob now assert the stamps are present and compare the render-produced remainder.packages/realm-server/tests/target-first-index-ordering-test.ts— end-to-end, next to the indexing tests: pushes cards through/_atomic, drains both queues, then reads the pass's write order back offboxel_index.diagnostics. Asserts a single write's row is written before its two dependents, and that a batch writes every target before any dependent. Each target depends on nothing in its own fan-out and sorts last lexically, so only the ordering under test can put it first, and each scenario asserts the fan-out precondition — that the dependents really do record the target, across both channels'deps— before it asserts an order.Verification
index-visit-order-test.ts,prerender-html-split-test.ts,dependency-normalization-test.ts,canonical-url-memo-test.tsand the diagnostics-persistence suites, against a real Postgres.orderInvalidationsByDependenciesdirectly, before and after: with a cycle, with nodepsrows, or with a module written second, the target (or module) landed last beforehand; only the clean acyclic case was already correct.depsrows it is exactly (class, target, position) over 2,000 random sets, and with class-respecting edges (cycles included) no class inversion appears over another 2,000. The topological pass itself was checked against a brute-force reference over several thousand random digraphs, byte-identical tomainon acyclic ones, and timed at 50k URLs.writeSeqpersistence checked against real Postgres on both channels: buffer order survives onto the promoted rows; a fused visit's two halves share one position; a rewrite keeps its first position across a drain boundary; twoinvalidate()calls on one batch produce two ids each numbered from 0; a tombstone-only row keeps itsinvalidationIdbut has a NULLwriteSeqon both the working and production tables.ember-tsc --noEmitandeslintclean for every changed file in both packages.target-first-index-ordering-test.tscannot run in this environment (the realm-server suite's full service stack needs mise and the docker-hub blob CDN, both blocked by the network policy), so it runs in CI.🤖 Generated with Claude Code
https://claude.ai/code/session_01DmkPknpuQyb6uvura5Aj4U