feat: checkpoint requests#198
Conversation
…t request instead of sync status
rkistner
left a comment
There was a problem hiding this comment.
I'm happy with the model overall, but I'll leave review of the technical implementation to @simolus3.
Reading the docs, one part that's not 100% clear to me is seed_checkpoint_request_id:
- When exactly is this meant to be called? seed typically implies it's during setup only, or perhaps once per connection. Or is that potentially after every request to the server? Describing the SDK implementation like for other operations would help a lot here.
- Are there concurrency risks here? For example: (1) client sends 3x requests to the server, each incremental
last_checkpoint_request_id(2) client receives a response back from the server, callsseed_checkpoint_request_id, which then sets it back to an older value. Current docs mention the client SDK is responsible for reconsiling these, which can include dealing with concurrency issues like that, but the docs should be clear on what the client SDK should do.
On the docs: It's nice to have all the details, and the SDK implementation descriptions are really useful. But some of the other docs are quite verbose and still difficult to follow: It describes a lot of detailed mechanics, but I'm missing the high-level purpose. For example, it took some digging to see what exactly each of the ps_kv fields are used for, and why we need the 4x separate fields.
This currently happens once per connection. This is used for the case where the PowerSync service might have a checkpoint record, but the client doesn't - perhaps after a
We limit the concurrency on the SDK level currently. Essentially, we do 1 request to the service on each connect iteration. We wait for the response from the server before allowing other checkpoint-requests from executing. The client's sequence is then seeded from the server response. Subsequent checkpoint requests then use the client's sequencing. On the service side, the PowerSync service avoids concurrency issues by only advancing the
That's fair feedback. I'll use this to improve the docs. |
simolus3
left a comment
There was a problem hiding this comment.
I agree that more information on checkpoint request seeding and reconciliation would be helpful, the docs say that this is owned by SDKs but having an overview of the process and more details on the "recommended pattern" part would help.
Overview
This implements the core changes for the Checkpoint Requests proposals:
The proposals refactor write checkpoints into a general Checkpoint Requests methodology: clients
now generate and track checkpoint request ids themselves (previously the PowerSync service
generated write checkpoint ids). The service treats client-generated checkpoint request ids as the
write checkpoint ids it reports to older-protocol clients, so both flows share one id namespace.
For background on the legacy system, see
docs/historic-write-checkpoints.md. For a detailedoverview of the new system, see
docs/write-checkpoint-requests.md. Thepowersync_controlcommand and instruction reference in
docs/sync.mdhas been updated accordingly.Migrations
Write checkpoint state was previously tracked in a synthetic
$localbucket inps_buckets.Migration v14 moves this state into dedicated
ps_kvkeys:$local)ps_kv)last_applied_oplast_applied_checkpoint_request_idlast_oplast_seen_checkpoint_request_idtarget_optarget_checkpoint_request_idThe migration then deletes the
$localrow (sops_bucketsonly contains real sync buckets) anddrops the
ps_buckets.target_opcolumn, so older SDKs fail hard rather than silently diverge ifthey open a migrated database. A full down migration restores the v13 schema and rebuilds the
$localrow fromps_kv; re-upgrading after a downgrade treats the$localrow as the source oftruth, including any progress an older SDK made in between. A read/write audit confirmed the mapped
ps_kvusages are semantically identical to the legacy$localbookkeeping.A new
last_requested_checkpoint_request_idkey holds the client-side allocation counter. It isintentionally not seeded from migrated state: SDKs reconcile the counter with the service on every
connect (see below). See the "Migration from
$local" section indocs/write-checkpoint-requests.mdfor details, including the ambiguous max-op-sentinel case.Additions
All new functionality is exposed through
powersync_control, consistent with the rest of the syncinterface. Unlike most
powersync_controlcommands, the three query/allocation commands belowreturn their result directly as a SQLite value instead of an instruction array — they are
responses to a function call, not sync-lifecycle instructions:
next_checkpoint_request_id— allocates, persists and returns the next checkpoint requestid as an integer result. Requires an active sync iteration and seeded request state. It
participates in the caller's transaction, so a rolled-back allocation can be retried safely.
current_checkpoint_request_id— returns the current sequence value (orNULLwhenunseeded) without allocating. SDKs use this to repost an existing checkpoint request after a
crash or restart without burning a new id.
target_checkpoint_request_id— probes, updates, or clears (payload0) the target used toblock applying downloaded rows while local writes are outstanding, returning the previous value.
Works outside a sync iteration. This is the split that keeps explicit checkpoint requests from
blocking applies: only write checkpoints update the apply gate.
seed_checkpoint_request_id— seeds the allocation counter from service state. RejectsNULLand0payloads: a conforming SDK always has a positive id to seed after reconciliation.Seeding and reconciliation
EstablishSyncStreamnow carries alast_checkpoint_request_idhint. On every connection attempt,the SDK reconciles this hint with the service's checkpoint-request state (posting at least
1) andseeds core with the response. Core stores seeded values verbatim — it does not enforce
monotonicity. The SDK owns id validity and cannot allocate new requests until seeding completes.
This connect-time reconciliation doubles as a re-request, so checkpoint-request waiters resolve in
every new session without durable applied state in core.
Notifying applied checkpoint requests
When a full checkpoint carrying a
write_checkpointis applied locally, core reports the appliedrequest id through two channels:
DidCompleteSync.applied_checkpoint_request_id— the canonical signal. The field is onlyserialized when the applied checkpoint carried a write checkpoint, so the instruction is
unchanged for other checkpoints.
UpdateSyncStatus.status.internal_last_applied_checkpoint_request_id— an event-style fieldfor SDKs that drive waiters from the sync status stream (e.g. web-worker setups where reacting
to status is easier than plumbing an instruction through). It is set on the status update that
follows the apply and cleared by later status updates without an applied id, including
reconnects — SDKs react to the value when a status update carries it rather than polling it as a
high-water mark. It is runtime-only SDK state, not app-visible progress, and is never reported
by
powersync_offline_sync_status.Partial (priority) checkpoint completions never report an applied request id and never advance the
seen/applied state.
A general SDK
requestCheckpointflow:powersync_control('next_checkpoint_request_id', NULL)(in a transaction).powersync_control('target_checkpoint_request_id', id).DidCompleteSyncinstruction (or status update) with an applied id>= id.Local writes set
target_checkpoint_request_idto the max-op sentinel and clear the seen/appliedhigh-water marks, preserving the legacy behavior that only checkpoint request ids observed after
a write can satisfy the apply gate.
Testing
validation), seeding semantics (verbatim/non-monotonic stores,
NULL/0rejection), apply-gatebehavior, and applied-id reporting through both
DidCompleteSyncand the sync status for fullvs. partial checkpoints.
ops and schema-equality assertions against fixtures.
Related PRs for the PowerSync service and the initial Swift SDK implementation will be linked here.
Historical context and alternatives considered
Alternatives considered
Standalone SQLite functions — the initial implementation exposed
powersync_next_checkpoint_request_id()andpowersync_probe_local_target_op()as dedicatedSQLite functions, e.g.:
These were replaced with
powersync_controlcommands so the checkpoint request lifecycle livesin the same interface (and connection) as the rest of the sync client.
Instruction-shaped responses — after the move to
powersync_control, the query/allocationcommands initially returned dedicated
CheckpointRequestIdandLocalTargetOpinstructions.These weren't really instructions — they were responses to a function call — and handling them
required special cases in SDKs. They now return their values directly as SQLite results, handled
in
control()without ticking the sync client (mirroring howsubscriptionsis handled).A dedicated
CheckpointRequestAppliedinstruction — applied checkpoint requests were firstnotified through a separate instruction. Since it was only ever emitted together with
DidCompleteSyncfor a fully-applied checkpoint, it was merged intoDidCompleteSync.applied_checkpoint_request_id, which also removed any ordering questionbetween the two instructions.
Sync status as the notification stream — an earlier revision emitted the applied request id
only on the
SyncStatusstream. That was first dropped in favor of the dedicated instruction(to keep an internal high-water mark from being presented as meaningful sync progress), then
partially reinstated: the status now carries
internal_last_applied_checkpoint_request_idas anevent-style, runtime-only field alongside the canonical
DidCompleteSyncsignal, because someSDK architectures (e.g. web workers) find it much easier to react to the status stream than to
individual instructions. The
internal_prefix and the clear-on-later-updates semantics keep itfrom being mistaken for persisted state or user-facing progress.
max()seeding in core — seeding originally usedmax(local, service)semantics so thecounter could never move backwards. This was dropped in favor of storing seeded values verbatim:
the SDK performs the bidirectional reconciliation with the service and is the only party that can
judge which value is correct (e.g. after switching users or a service-side reset). The same
reasoning applies to
last_applied_checkpoint_request_id, which is always the id from the lastapplied checkpoint as sent by the service.
Allowing
NULLseeds — an earlier revision acceptedNULLas a seed to mean "no servicestate". Since a conforming SDK always posts at least
1during reconciliation and seeds theaccepted response,
NULL(and0) seeds are now rejected by core.Naming:
local_target_op— the apply-gate key and control command were originally namedlocal_target_op, carrying over the legacyps_buckets.target_opterminology. Sincetarget_opreferred to a bucket concept that no longer exists on the client, both were renamed to
target_checkpoint_request_id. The legacy column name survives only in the v13 schema, the downmigration that restores it, and the historic docs.
Single JSON value vs. separate
ps_kvkeys — the migrated state could have been stored asone JSON document under a single key. Separate keys were chosen to keep querying simple (both for
core's apply-gate SQL and for debugging).
Seeding the request counter from migrated
target_op— a concrete legacy$local.target_opcould seed
last_requested_checkpoint_request_idduring migration. This was left out asredundant: SDKs reconcile the counter with service state on connect before advancing it.
Keeping the
target_opcolumn — the column could have been retained for compatibility.Dropping it was chosen deliberately so that older SDK versions fail with a hard SQLite error on
local writes instead of silently maintaining state the new implementation no longer reads. The
down migration restores the column for supported downgrades.
AI Disclosure: I initially implemented the work by hand without AI. Codex 5.5 assisted with
creating tests and writing docs; Claude Code assisted with a review pass, cleanups, and doc
updates. All AI changes have been manually guided and verified.