Skip to content

feat: Persist source state only after destinations commit the synced records - #23546

Merged
kodiakhq[bot] merged 22 commits into
mainfrom
feat/cli-checkpoints
Oct 19, 2026
Merged

kodiakhq[bot] merged 22 commits into
mainfrom
feat/cli-checkpoints

Conversation

@disq

@disq disq commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

When every destination supports flush, the CLI flushes them before it writes each source state checkpoint to the backend.

Tested with a local SQLite sync: after a source crash, the stored cursor never passed the committed rows.
Uses the protocol from plugin-pb-go v1.28.2.
Checkpoints stay off with transformers or destinations without flush support. The sync logs whether it requested checkpoints, or why not, and the sync command reference documents the feature and its failure policy.
If a destination or the state backend reports a failed flush, the CLI saves no more state for that source, finishes the sync and exits with an error, which the sync summary also gives as state_error. The next sources still sync.
A destination that ends its stream after it reports the failure still lets the sync finish. A writer that stops on a failed write before it reports it on a flush, such as the mixed batch writer, still ends the whole run, as on main.
At the end of the sync, the CLI flushes failed destinations again, so it knows every table they lost records of.
If the source fails after a checkpoint, the CLI still saves the state that checkpoint covers.
Side effects of this policy:

  • A row that a destination always rejects stops every incremental cursor of the source, not only the cursor of its table.
  • Delete-stale is skipped for the relation trees of the failed tables on every such run, or for every table if the destination does not name them or ends its stream.
  • In append mode, each rerun appends again the rows synced after the last saved state.

Also, with or without checkpoints, the CLI now closes every destination before it returns a close error.

@erezrokah

Copy link
Copy Markdown
Member

Can we flush pending checkpoints while the source is idle and split large checkpoint batches to stay within the gRPC message limit?

@disq

disq commented Oct 14, 2026 •

Copy link
Copy Markdown
Member Author

@erezrokah Both done in 118eac4:

  • The CLI now receives sync messages on a separate goroutine, so pending state is persisted when the 30s interval elapses even while the source sends nothing (TestCheckpointerCommitsWhileSourceIsIdle).
  • State written to the backend is split into records of about 64 MiB, well under the 200 MiB gRPC limit (TestCheckpointerSplitsLargeState).

Checkpoint messages from the source are not split yet: a single Flush larger than the SDK's 100 MiB message limit still fails the sync. I can split those in cloudquery/plugin-sdk#2632 if you want.

disq added 2 commits October 14, 2026 13:01
- Validate checkpoint column types instead of panicking on an unexpected version type
- Report a pipeline's error instead of a cancelled commit when a pipeline fails during a commit
- Name the destination whose flush failed
- Log when the source sends checkpoints, instead of claiming they are enabled up front
- Test the checkpoint flow through syncConnectionV3 with fake plugins, and the switch that enables it
@erezrokah

Copy link
Copy Markdown
Member

Could we apply checkpointCommitError to the receive-error path too? When a destination write fails while the source is idle, the receiver can report only context canceled, hiding the actual destination error.

@disq

disq commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

@erezrokah Done in 30ee29e: the receive-error path now goes through the same check (renamed to unlessFailedElsewhere), so when a pipeline's destination write fails while the source is idle, the sync returns the destination error instead of context canceled. TestSyncReportsDestinationErrorWhileSourceIsIdle covers it end to end.

disq added 12 commits October 14, 2026 19:19
- Build and send state records one at a time, and count every column in the record size estimate
- Report a duplicate state column as such, and route receive errors through receiveError
- Test that a flush waits for pipeline delivery, idle commits through syncConnectionV3, backend errors across several records, and prefix matching of the state backend
- Flush every destination that has not failed yet at the end of the sync, so later failures skip delete-stale too
- Keep syncing other sources when a sync finishes without saving its state, and report every such source at the end
- Treat a failed state backend flush like a failed destination flush
- Report destination commit failures together with hard flush errors
- Prefer the pipelines' errors over the receive loop's instead of filtering each loop error
- Word the error differently for sources that sent no checkpoints
- Warn about skipped delete-stale only for destinations that run it
- Bump plugin-pb-go to the regenerated flush_error wording
- Skip delete-stale only for the tables a destination failed to write, when it names them
- Ignore the end of a write stream after the destination reported a failed flush, and close every destination before returning
- Keep the unsaved state errors of earlier sources when a later source fails
- Pin the preference of pipeline errors over the receive loop's in a helper with a deterministic test
- Warn append mode destinations that the next sync appends the unsaved rows again
- Stop UpdateTableNames from removing tables from its input, which skipped delete-stale on every destination after the first
- Bump plugin-pb-go to the failed_tables field
- Flush destinations again at the end of the sync after they failed, so delete-stale skips the tables they fail later too
- Add stream errors after a failure to the recorded failure, as records of any table may then be lost
- Skip delete-stale for the whole relation tree of a failed table
- Keep the state-not-saved cause on later errors, which still stop the next sources
- Warn append mode destinations about synced again rows only for sources that sent checkpoints
- Test a Send on a stream that ended without a reported failure, and several sources that save no state
- Drop the table name changer subtest that main already has
- Treat a failed table the source did not sync as any table, and bound the walk up a relation tree
- Say the sync stopped, not finished, when a later error joins the unsaved state
- Report a failure once when a hard flush error follows it
- Test a stream end first seen by a flush and a later failure that names no tables
- Check the delete-stale decision in the checkpointer tests instead of a test-only helper
Adds state_error to the sync summary, so a sync that finished but lost records differs from one that crashed. The summary gained fields before without a destination migration.
- Commit the checkpoints already received when the source stream ends with an error, as they cover only records sent before them
- Give the exit error's text, warnings included, as the summary's state_error, and describe it as why the sync stopped saving state
@disq
disq marked this pull request as ready for review October 19, 2026 10:24
@disq
disq requested a review from a team as a code owner October 19, 2026 10:24
@disq
disq requested a review from alonnoga October 19, 2026 10:24
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Critical impact] The latest changes appear safe to merge and address both previous findings.

Summary

The CLI saves source checkpoints only after all destinations commit the preceding records. Failed commits stop further state saves while allowing later sources to run.

  • Sync checkpoints wait until destinations commit earlier records.
  • Failed commits stop further state saves without stopping the sync.
  • The sync closes every destination before returning close errors.

Diagram

sequenceDiagram
  participant Source
  participant CLI
  participant Destinations
  participant Backend
  Source->>CLI: Records and checkpoint
  CLI->>Destinations: Deliver preceding records
  CLI->>Destinations: Flush
  Destinations-->>CLI: Commit result
  alt Every destination committed
    CLI->>Backend: Save checkpoint
  else A commit failed
    CLI->>CLI: Stop saving state
  end
  CLI->>Destinations: Close every stream and plugin
  CLI->>CLI: Write final file summaries and logs
Loading

Reviews (2) · Last reviewed commit: "fix: Write the summary files once the de..." · Reviewed by Greptile

Comment thread cli/cmd/sync_v3.go
Comment thread cli/cmd/sync_v3_checkpoints_test.go Outdated
- state_error in the summary files and logs now has the failures reported as the streams close
- Test closing every destination without checkpoints too, and check the Close RPC
@disq

disq commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

@greptileai retry

…s commit

- Add an Incremental State section to the sync command reference: what it needs, and the failure policy
- Log at info whether a sync with backend_options requested state checkpoints, or why not
@disq disq added the automerge Automatically merge once required checks pass label Oct 19, 2026
@kodiakhq
kodiakhq Bot merged commit 1ad79a6 into main Oct 19, 2026
11 checks passed
@kodiakhq
kodiakhq Bot deleted the feat/cli-checkpoints branch October 19, 2026 11:51
kodiakhq Bot pushed a commit that referenced this pull request Oct 19, 2026
🤖 I have created a release *beep* *boop*
---


## [6.49.0](cli-v6.48.0...cli-v6.49.0) (2026-10-19)


### Features

* Persist source state only after destinations commit the synced records ([#23546](#23546)) ([1ad79a6](1ad79a6))


### Bug Fixes

* Apply transformer table name changes with --no-migrate ([#23550](#23550)) ([dbd349e](dbd349e))
* **deps:** Update module github.com/cloudquery/plugin-pb-go to v1.28.2 ([#23551](#23551)) ([c44b66d](c44b66d))
* Fix nil pointer dereference in getDockerToken on request creation failure ([#23276](#23276)) ([5b4f2ee](5b4f2ee))
* Run delete-stale on every overwrite-delete-stale destination ([#23548](#23548)) ([ab7923a](ab7923a))

---
This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
kodiakhq Bot pushed a commit that referenced this pull request Oct 20, 2026
#### Summary

PostgreSQL, Snowflake, BigQuery and S3 support write flushes, so a CLI with #23546 saves source state only after they commit the records synced before it. S3 supports them only when `path` contains `{{UUID}}`, as a flush ends the open object and the next one would otherwise overwrite it; its docs explain this and the smaller objects flushes cause.

Their writer suite tests now run the flush test; PostgreSQL's passes locally on PostgreSQL 10 and CockroachDB, and Snowflake's and BigQuery's run in CI with credentials. S3's new flush test, which checks that each flush uploads a separate object, passes locally against MinIO and runs in CI against the test bucket.
kodiakhq Bot pushed a commit to cloudquery/plugin-sdk-python that referenced this pull request Oct 20, 2026
#### Summary

Brings the source side of state checkpoints (cloudquery/plugin-pb#42, cloudquery/plugin-sdk#2632, cloudquery/cloudquery#23546) to the Python SDK. When the CLI sets `Sync.Request.supports_checkpoints`, flushed state goes on the sync stream as `Sync.MessageCheckpoint` instead of being written straight to the backend. The CLI then persists it only after every destination commits the records synced before it.

Why we want this: some of our Python syncs run for many hours, often limited by provider rate limits. A transient failure late in the sync currently throws away all the progress, because state is only safe to write once the sync ends. With checkpoints, a failed sync keeps the cursors whose records were committed, and the next run resumes from there.

Opt-in, and unchanged unless both sides opt in:

```python
self._scheduler = Scheduler(concurrency=...)
self._state_client = StateClientBuilder.build(
    backend_options=options.backend_options,
    checkpoints=self._scheduler.checkpoints,  # new, optional
)
self._scheduler.set_post_sync_hook(self._state_client.flush)

# in a table resolver, with one cursor per form
key = state_key("responses", client_id=client.id(), form_id=form["id"])
for item in api.list_responses(form["id"], since=state_client.get_key(key)):
    yield item
    state_client.set_key(key, item["updated_at"])
    state_client.flush()  # a checkpoint, sent once this item and its relations are on the stream
```

Without `checkpoints`, or when the CLI doesn't support them, `flush()` writes to the backend as it does today.

#### Ordering

This follows the Go SDK's semantics:
- A state change made inside a table resolver is tied to that resolver call's scope. The scheduler records the scope in a thread-local while the resolver runs.
- On `flush()`, each group of keys becomes a checkpoint. It goes on the scheduler's queue once every resource those scopes produced before the flush is on it, including the relations those resources spawned.
  - Child resolvers run asynchronously on the depth pools, so the checkpoint waits for every child scope that was still open at flush time.
  - Unlike Go, the Python scheduler has no batcher: resources reach the stream in queue order, so nothing needs flushing first.
- A checkpoint also waits for earlier unsent checkpoints of the same keys. The state table has no `version` column, so the CLI relies on stream order.
- Changes made outside table resolvers wait for nothing. Flush them after the resources they summarize, for example in the post-sync hook. Checkpoints flushed by the post-sync hook are now sent before `Scheduler.sync` returns.
- Flushing in checkpoint mode while no sync is running raises an error instead of dropping the state.

#### Granularity

Two things set how much a checkpointing sync writes, and today plugin authors and users control only one of them:

- **How many keys change between commits.** That's the plugin's choice: one cursor per table, or one per parent, account or other partition. This PR adds `state_key(table, *, client_id=None, **partition)`, so plugins choose the granularity without each inventing a key format. Keys are deterministic, partition fields are sorted, and separators are escaped, so tables, clients and partitions never share a key. Each key is one state row, and a partition's checkpoint waits only for the resolver that set it. Finer keys mean a resumed sync re-fetches less, at the cost of more rows per commit.
- **How often the CLI commits.** The CLI merges checkpoints and commits at most every 30 seconds (`checkpointInterval` in `cli/cmd/sync_v3_checkpoints.go`). Each commit flushes every destination, then writes every key changed since the last commit to the state backend. The interval is hard-coded.

We'd like the commit interval to be configurable per sync, for example in the source spec next to `backend_options`, including an option to commit only at the end. The right value depends on the destination and the sync, not on the plugin:
- **Destination batches:** each flush ends the destination's batch, so a 30-second interval caps batch size by time. For S3 it also writes a new object per table every 30 seconds.
- **State backend load:** a sync with per-partition keys writes up to one row per partition per commit to a relational state backend.
- **Sync length:** for a sync that runs many hours against rate-limited APIs, committing every few minutes loses little on failure and writes far less. A short sync gains nothing from mid-sync commits.

Keeping the plugin-side flush cheap matters too, and it is: `flush()` in checkpoint mode only puts a message on the sync stream, and nothing touches a database until the CLI commits.

#### Other changes

- **State client:** now thread-safe. It clears changed keys after a successful flush (previously every flush rewrote every key changed since the start), and it keeps them after a failed one so a later flush retries them.
- **Backend options:** `BackendOptions` gains `supports_checkpoints`. The server now builds `BackendOptions` from the request instead of passing the protobuf message through.
- **Server guard:** the server refuses to send a checkpoint when the CLI didn't ask for them.
- **Key helper:** `state_key` is exported from `cloudquery.sdk.stateclient`.

#### Depends on

The `plugin-pb-python` bindings with the new messages (cloudquery/plugin-pb-python#250), which aren't released yet (release PR cloudquery/plugin-pb-python#225). `pyproject.toml` requires `>=0.0.55`, but `uv.lock` still needs updating after that release, so CI will fail until then. That's why this is a draft.

#### Open questions

1. **Failed relations.** Like Go, a parent's checkpoint still goes out when one of its child resolvers fails. For a parent cursor such as `updated_since`, that can skip the failed parent's children for good. Would you take an opt-in to hold back a scope's checkpoints when anything in its subtree errored?
2. **`version` column.** Should the Python state table get the `version` column, to match the Go client?
3. **Python destinations.** Python destination plugins don't implement `Write.MessageFlush` yet. I left that for a separate PR, since the CLI only enables checkpoints when every destination supports flushing.
4. **Full syncs.** Checkpoints only help tables that keep cursors. Resuming long full (non-incremental) syncs, and the interaction with `delete-stale`, is a bigger question. I'd like to raise it in a separate issue.

#### Testing

- New tests in `tests/scheduler/checkpoints.py`, `tests/serve/checkpoints.py` and `tests/stateclient/keys.py`. They cover:
  - parent checkpoints following all child resources of the parents they cover, with randomized child latency
  - child checkpoints
  - per-key order under concurrent flushes
  - post-sync hook checkpoints
  - backend writes without checkpoints
  - failed-flush retry
  - the builder and server gating
  - `state_key` ordering, escaping and uniqueness
- Removing either the wait on child scopes or the per-key ordering makes the parent-checkpoint test fail.
- The full suite passes locally against `plugin-pb-python` main, except `test_plugin_package`, which builds a Docker image and fails the same way locally on `main`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli automerge Automatically merge once required checks pass

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants