feat(data_sync): cursor provenance on run start and adapter input - #116
Closed
maxidragon wants to merge 5 commits into
Closed
feat(data_sync): cursor provenance on run start and adapter input#116maxidragon wants to merge 5 commits into
maxidragon wants to merge 5 commits into
Conversation
A run reaches an adapter carrying a cursor and no indication of where it
came from. A fresh dashboard start that silently inherited a previous
run's position is indistinguishable from a Retry that was told to resume.
For an adapter whose cursor encodes scope rather than only a position,
that difference decides whether the run is correct: an inherited cursor
imposes a stranger run's window and the run reports completed having
skipped everything outside it.
Specs cursorOrigin ('none' | 'explicit' | 'inherited' | 'self') on the
adapter input, persisted on sync_runs alongside the source run id, and
surfaced on the run detail page. Behaviour is unchanged.
The fourth value is load-bearing: the engine hands the adapter
run.cursor, not initialCursor, so on a queue redelivery a row stamped
'inherited' at creation would make a refusing adapter reject its own
mid-run resume. 'self' is derived from batchesCompleted, so the column
records where the run started and the delivered value describes what is
actually being handed over.
Closes the Risk #2 residual risk recorded in the run-scoped cursor spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run reached an adapter with a cursor and nothing to say where it came
from, so a fresh start that silently resumed the last incomplete run was
indistinguishable from a Retry told to resume. Adapters whose cursor
encodes scope had no basis to refuse the first without breaking the
second.
Adds CursorOrigin ('none' | 'explicit' | 'inherited' | 'self') as an
optional field on StreamImportInput/StreamExportInput, two nullable
columns on sync_runs recording the start-time origin and its source run,
and resolveStartCursorWithOrigin / resolveResumeCursorWithSource. The
previous resolvers stay as delegating wrappers, so no signature changes.
The engine derives what it is actually handing over rather than replaying
the stored label: once batchesCompleted > 0 the cursor is the adapter's
own output, so a redelivery reports 'self'. Without that, an adapter
refusing 'inherited' would reject its own mid-run resume.
Retry is labelled per case, not wholesale. Resuming the previous run's
own position is 'explicit', but retrying a run that never committed a
batch falls through to the same inherited resolution a dashboard start
uses, and is labelled 'inherited' accordingly.
Behaviour is unchanged; adapters ignoring the field see what they see
today, as do runs written before the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run detail page rendered neither cursor nor initial cursor, so an operator who started a run and watched it process a fraction of the table had no way to find out why. Cursor provenance makes that answerable, so answer it. Both read routes now return cursorOrigin and cursorSourceRunId, and the detail page renders one line for an inherited start: "continued where an earlier run stopped" with a link to that run, or "continued from the saved incremental position" when it came from the shared cursor row and there is no run to point at. Stays quiet for 'none', 'explicit' and 'self' — a note shown on every run is one every operator learns to skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d retry Pins the derivation that makes the discriminator safe to act on: once a run has committed a batch the engine reports 'self', so an adapter that refuses an inherited cursor does not refuse its own redelivered resume. Also covers the retry cursor precedence, which had no test at all before this change — including the fallback where a retry of a run that never committed a batch inherits like a fresh start. Adds TC-DS-011 for the wire contract on both run read APIs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cation Adds the framework-docs section an adapter author needs to act on the new field, including why 'self' makes refusing an inherited cursor safe, and the AGENTS.md rules for start paths that must label what they resolved. Records the change in BACKWARD_COMPATIBILITY.md as additive under the type-interface, function-signature and database-schema categories. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Should I review this or will you still redo it (after our today's call)? |
Member
Author
|
Nope, closing this for now. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Pressing Run on the Data Sync dashboard means "continue whatever ran last", and nothing says so.
api/run.tsresolves a start cursor for every non-fullSyncstart — the sharedsync_cursorsrow, or for an opted-out entity type the last incomplete run's position. That is the intended incremental behaviour and it is invisible: the operator is never told, and the only opt-out is a switch they have to remember to tick.For a cursor that is purely a position, fine. For a cursor that encodes scope — filters, date/id bounds, dry-run flags — it is not. A fresh, unfiltered run inherits a failed run's date window, walks only that window, and reports
completedhaving skipped everything outside it.The adapter cannot defend itself, because it cannot tell that apart from a Retry that was deliberately told to resume:
retry.tspasses both a cursor and the previous run's parameters, and nothing on the run row separates the two —initialCursoris written by both paths, and no column links a retry to its predecessor. So refusing an inherited cursor would break Retry.This is a recorded residual risk, not a new discovery.
.ai/specs/2026-08-12-data-sync-run-scoped-cursor.mdRisk #2 says it outright: "indistinguishable ... without a window fingerprint on the run row." Provenance is a lighter answer to the same problem, and this PR closes that risk.Solution
Tell the adapter where the cursor came from. Behaviour is unchanged.
CursorOriginis an optional field onStreamImportInput/StreamExportInput:noneexplicitinheritedselfThe origin and its source run are persisted on
sync_runs, and the run detail page — which rendered no cursor information at all — now says "continued where an earlier run stopped" with a link, or "continued from the saved incremental position" when it came from the shared row.Two things worth a reviewer's attention
selfis what makes refusinginheritedsafe. The engine hands the adapterrun.cursor, notinitialCursor, so once a batch commits the position is the adapter's own output whatever the run started from. A redelivered job would otherwise re-enter the adapter with the run's start-time label still attached, and an adapter refusinginheritedwould restart from the top on every worker hiccup — the Retry breakage, moved one level down. So the column records where the run started, while the value handed over is derived per delivery frombatchesCompleted.Retry is labelled per case, not wholesale.
retry.tsisprevious.cursor ?? resolveStartCursor(...), so a retry of a run that never committed a batch inherits exactly like a fresh dashboard start. Labelling every retryexplicitwould make the discriminator a second thing to distrust; the fallback is labelledinherited.Backward compatibility
Additive throughout — recorded in
BACKWARD_COMPATIBILITY.md:signal?: AbortSignaladdition already ondevelop.resolveStartCursorandresolveResumeCursorkeep their exact signatures as delegating wrappers.ADD COLUMN, no table rewrite, no backfill. Pre-migration rows readnull, which the engine reports as an absent origin rather than a guessed one.Adapters that ignore the field behave exactly as today.
Testing
yarn typecheck,yarn lint,yarn generate(no diff),yarn agents:check-budget— all clean, run locally.yarn db:generatereportsdata_sync: no changes, confirming the hand-written migration and snapshot match the entity change exactly.data_syncsuites: 35 passed, 254 tests (from 32/226). New coverage:lib/__tests__/cursor-origin.test.ts— the derivation truth table, above allbatchesCompleted > 0 → 'self'.api/runs/[id]/__tests__/retry-cursor-origin.test.ts— the three retry cases. Retry cursor precedence had no test at all before this, so this covers pre-existing behaviour too.lib/__tests__/sync-engine-cursor-origin.test.ts— the engine wiring, import and export.__integration__/TC-DS-011.spec.ts— the wire contract on both run read APIs.start-cursor,sync-run-service.shared-cursor,api/run,sync-scheduled, run-detail page.Two pre-existing failures elsewhere in the repo are unrelated and reproduce identically on a clean
develop: 10@open-mercato/documentssuites (Cannot find module '#generated/entities.ids.generated') and onestafftimesheets test. Both verified againstorigin/developdirectly.Review notes
data_sync/AGENTS.mdasks to check before changing cursor semantics — semantics are unchanged, but the adapter contract grew, so that is the call being requested here.