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.
| // Tracks the local target used to block applying downloaded rows while local writes are | ||
| // outstanding. When present, this is normally either the max-op sentinel for pending local writes or | ||
| // a concrete checkpoint request id also stored in LAST_REQUESTED_CHECKPOINT_REQUEST_ID_KEY. | ||
| const LOCAL_TARGET_OP_KEY: &str = "local_target_op"; |
There was a problem hiding this comment.
Would something like target_checkpoint_request_id be a better description here? target_op originally referred to a concept for buckets that is not really relevant on the client anymore.
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. |
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_oplocal_target_opThe 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:
next_checkpoint_request_id— allocates and returns the next checkpoint request id in aCheckpointRequestIdinstruction. Requires an active sync iteration and seeded request state.local_target_op— probes, updates, or clears (payload0) the local target op used toblock applying downloaded rows while local writes are outstanding. Returns the previous value in
a
LocalTargetOpinstruction. Works outside a sync iteration. This is the split that keepsexplicit checkpoint requests from blocking applies: only write checkpoints update the apply gate.
seed_checkpoint_request_id— seeds the allocation counter from service state.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 the effective id
to the service) and seeds 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 emits aCheckpointRequestApplied { request_id }instruction. SDKs resolverequestCheckpoint()-stylewaiters by watching for an instruction with
request_id >= N. Partial (priority) checkpointcompletions never emit this instruction and never advance the seen/applied state.
A general SDK
requestCheckpointflow:powersync_control('next_checkpoint_request_id', NULL)(in a transaction).powersync_control('local_target_op', id).CheckpointRequestAppliedinstruction withrequest_id >= id.Local writes set
local_target_opto the max-op sentinel and clear the seen/applied high-watermarks, preserving the legacy behavior that only checkpoint request ids observed after a write can
satisfy the apply gate.
Testing
stores), apply-gate behavior, and instruction emission for full vs. 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, and responses flow through
the shared instruction serialization.
SyncStatus as the notification stream — an earlier revision emitted a
last_applied_checkpoint_request_idfield on theSyncStatusstream, letting SDKs reuseexisting status watchers to wait for checkpoint requests. This was replaced with the dedicated
CheckpointRequestAppliedinstruction: it keeps an internal high-water mark from being presentedas meaningful sync progress, and avoids coupling waiters to status serialization.
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.
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.