Skip to content

Runtime "Sync Now" - #3326

Open
dgreer-dev wants to merge 5 commits into
masterfrom
daveg/3281-sync-now
Open

Runtime "Sync Now"#3326
dgreer-dev wants to merge 5 commits into
masterfrom
daveg/3281-sync-now

Conversation

@dgreer-dev

Copy link
Copy Markdown
Contributor

flowctl raw sync-now --task X (and POST /v1/task-control/sync-now) forces an
immediate commit of a V2 materialization's open transaction and returns once that
transaction is committed and queryable in the destination. Closes #3281.

The workflow we want to enable is a user wanting to run analytics on the freshest
data available, regardless of any sync schedule (or other transaction-holding
reason). Essentially we want to allow flowctl raw sync-now ... && ./my-analyitcs.py
to be a safe and useful command. See below for specifics of the new command.

Design points worth your attention

  • General commit barrier. Sync-now means "commit what you have, tell me when
    it's queryable", whatever is holding the transaction (schedule, min-duration
    floor, or just size). Worst case is a smaller transaction. "Nothing to do" is
    never an error: an idle task Acks IDLE, a capture or derivation
    NOT_APPLICABLE, both exit 0.
  • One wait rule. Every outcome but IDLE/NOT_APPLICABLE resolves at the
    target transaction's Tail::Done, so N concurrent callers park on one commit.
  • Server-side routing, no semantics in Go. The front door resolves shard
    zero with the caller's claims, proxies to the primary's peer via mayProxy,
    and relays 1:1 to the co-located sidecar.
  • Two transports, one dialect. /v1/task-control/sync-now exists as a second
    way to force the sync. Calls the same code as flowctl, just an HTTP different path.
  • Validation arm (first commit, independent of the runtime): an all-zero
    connector syncSchedule counts as unconfigured, so runtime-only pacing is
    expressible on every connector image already shipped; and a model-level
    syncSchedule without enable-runtime-v2 is now rejected rather than
    silently inert.

Verification

Rust e2e tests ensure transaction control works as advertised. Go tests stand up
two consumer test members against a stub sidecar, covering both transports
including the peer-proxied path. flowctl's response handling is covered against a
stub server over a real channel.

Also verified on a local stack against a schedule-held V2 materialization
(transcripts available). Highlights: three parallel flowctl pokes plus one
curl -N stream against one held transaction all exited 0 reporting identical
committed stats (one HELD_COLLAPSED, three ALREADY_CLOSING waiters);
destination row count equalled the pre-poke source count exactly on every run.

Example Usage

$ flowctl raw sync-now --task acmeCo/foo/materialize-bar
{"ack":{"outcome":"HELD_COLLAPSED","status":{"sourcedDocsTotal":"50","sourcedBytesTotal":"13691","openAgeMillis":"98573","headPhase":"Idle","tailPhase":"Done"}}}
{"done":{"committedDocsTotal":"50","committedBytesTotal":"13691","durationMillis":"99634"}}

Ack is immediate and carries the outcome plus a snapshot of the awaited
transaction. Done arrives when that transaction is committed and queryable in
the destination, with its committed stats. durationMillis is the
transaction's total open age, not the commit duration. Progress heartbeats
(~15s) are consumed for liveness but not printed unless --progress, since an
hour of them is a lot of lines unasked.

Any completed outcome exits 0, IDLE and NOT_APPLICABLE included - "nothing
to do" is success. Exit is non-zero for NOT_FOUND (not running here, or not
on the V2 runtime) and for --timeout elapsing.

Flags: --progress prints the heartbeats; --no-wait prints the Ack and hangs
up, which is harmless because the commit has already been forced; --timeout 10m bounds the whole invocation, which otherwise waits indefinitely and retries
leader restarts.

The same call over the REST transport, which is what the dashboard button will
use:

$ curl -N -H "Authorization: Bearer $TOKEN" \
    -d '{"task":"acmeCo/foo/materialize-bar"}' \
    https://$REACTOR/v1/task-control/sync-now
{"result":{"ack":{"outcome":"HELD_COLLAPSED","status":{...}}}}
{"result":{"done":{"committedDocsTotal":"49",...}}}

Commits

  1. Types and basic verification
  2. Wiring runtime-next
  3. Go -> runtime pathway
  4. e2e tests

@williamhbaker williamhbaker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some directional comments! I didn't get into the gritty details just yet since I am hoping things can be simplified quite a bit.

Comment thread crates/flowctl/src/raw/sync_now.rs Outdated
Comment thread crates/proto-flow/src/runtime.rs Outdated
Comment thread crates/proto-flow/src/runtime.rs Outdated
Comment thread crates/runtime-next/src/leader/materialize/actor.rs Outdated
Comment thread crates/runtime-next/src/leader/materialize/actor.rs Outdated
Comment thread crates/validation/src/materialization.rs Outdated
Comment thread crates/validation/src/materialization.rs Outdated
Comment thread go/runtime/task_control.go Outdated
@dgreer-dev
dgreer-dev marked this pull request as draft August 10, 2026 23:37
@dgreer-dev

Copy link
Copy Markdown
Contributor Author

Pushed a commit with the four resolved convo fixes.

  • Dropped --progress, --no-wait, --timeout. flowctl now prints nothing and exits 0; the exit status is the whole contract.
  • SyncNowResponse is down to three empty messages: Ack, Heartbeat, Done. Status and the outcome enum are gone, and so are Done's committed stats (same argument as Status).
  • "Nothing to await" needs no label of its own: the leader acks and Done follows immediately, which is what IDLE and NOT_APPLICABLE already did.
  • Dropped the validation error on syncSchedule without enable-runtime-v2.

Remove the check which rejected a materialization configuring a
connector-side `syncSchedule` alongside a model-level one, and with it
`connector_config_has_sync_schedule` -- which sniffed the undecrypted
endpoint config for the key, treating an empty object or all-null / all
empty-string values as absent so that a UI clearing the schedule didn't
leave a tripwire behind.

Configuring both is not something we want users to do, but it doesn't
break: the two pacing mechanisms just interleave chaotically, and we plan
to migrate extant configs automatically. That isn't worth the complexity,
nor the guesswork of deciding what a disabled connector schedule looks
like.

It also stood in the way of runtime-only commit pacing, where a
model-level `syncSchedule` is the sole cadence control and the connector's
is left at its documented all-zero off switch. The connector parses a Go
duration, which accepts "0m0s" and a bare "0" as readily as "0s", so the
check would have had to enumerate zero spellings to let that through.

`SyncScheduleInvalid` stays: a malformed schedule is still an error.
@dgreer-dev
dgreer-dev force-pushed the daveg/3281-sync-now branch from 731ba0e to c71883f Compare August 12, 2026 15:37
@dgreer-dev
dgreer-dev marked this pull request as ready for review August 12, 2026 15:37
Sync-now forces an immediate commit of a materialization's open
transaction -- collapsing any sync-schedule hold -- and resolves once that
transaction is fully acknowledged: committed and queryable in the endpoint.

Its mechanism is a commit barrier built from two session messages.
CloseNow, which the leader already honors as its FSM's `close_requested`
input, gains a controller-assigned sequence. The new Synced answers it:
the leader reports its count of fully-acknowledged transactions of this
session, the transactions still pending ahead of that count, and the
highest CloseNow sequence received, broadcasting whenever any of the three
change. Shards relay Synced to their controllers unmodified, exactly as
CloseNow flows the other way.

A controller sends CloseNow with sequence S, waits for a Synced echoing
`close_request_seq >= S`, and then awaits `acknowledged_count` reaching
that same message's acknowledged + pending. The echo is what makes the
barrier exact rather than best-effort: counts travel leader -> shard ->
controller, so counts a controller already holds can predate a transaction
which has since opened, while the echoing report was taken after the
request landed and covers everything the leader then held.

The leader keeps no waiters, no timers, and no notion of a caller:
repeated CloseNow is idempotent, and a session which exits simply stops
reporting. Counts restart from zero with the next session, so a controller
must discard a target recorded under a session which ended.

Also declares the user-facing TaskControl service and its SyncNow
request/response messages. The reactor front door serves them by driving
this barrier, in the following commit.

The sync_now_e2e test drives the barrier against a real leader session --
a real tonic server behind an armed LEAD interceptor, with real journal IO
gating Tail::Done -- playing both the shard and controller sides itself.
Covered: a schedule-held transaction collapsing on CloseNow and resolving
only on the released acknowledgement; repeated CloseNow yielding one
commit; a barrier taken in the post-commit drain window resolving at that
same acknowledgement; the min-duration floor bypass; and the pipelined
case of an open Head transaction behind a draining Tail, which is what
pending_count's arithmetic exists for.
…in the CLI

The reactor front door serves TaskControl.SyncNow by driving the CloseNow /
Synced commit barrier of the prior commit.

materializeAppV2 tracks the leader's Synced counts per session, and its
session loop sends CloseNow when woken. syncNow claims a sequence, wakes
the loop, waits for the leader's echo, takes acknowledged + pending from
the echoing report as its target, and awaits acknowledged_count reaching
it. Concurrent callers coalesce onto one CloseNow bearing the highest
claimed sequence. A session which ends fences its counts and fails parked
callers with Unavailable; sync-now is idempotent, and re-invoking
re-establishes the contract.

The front door resolves the task's shard zero and answers where it is
primary, taking the shard's Store as a syncNower. A non-primary front door
forwards the RPC to the one which is, gazette mayProxy style, with the
proxied Header riding gRPC metadata (SyncNowRequest is user-facing and
deliberately has no Header field) and the caller's Authorization forwarded
verbatim for independent verification. Captures and derivations hold no
open transaction and are answered immediately from the shard's type; a
materialization whose Store is no syncNower runs the V1 runtime, which has
no leader to ask: NOT_FOUND.

Users reach the service as HTTP/NDJSON (task_control_http.go), sharing the
CORS treatment of gazette's grpc-gateway /v1/ mux; gRPC carries only the
front-door-to-front-door proxy hop. Each response line is flushed as it's
sent, and 15s heartbeats hold an hour-long wait open through load-balancer
idle timeouts. This endpoint is the UI contract of the dashboard's future
"Sync Now" button.

flowctl raw sync-now calls the HTTP endpoint bearing the user's task
authorization, printing the ack and done JSON lines and exiting zero on
completion. Attempts are retried while failures are retryable -- a broken
stream, a 5xx, or the leader's own Unavailable -- re-reading the front
door address and token each time, as an hour-long wait may outlive both.

TestSyncNowBarrier covers the barrier arithmetic over a bare
materializeAppV2: ack withheld until the leader's echo, the echo catching
a transaction opened behind stale counts, session fencing before and after
the ack, and coalescing of concurrent callers. TestTaskControlSyncNow
drives both transports over real consumertest members: per-message NDJSON
flushing, proxying from a non-primary front door, error passthrough, task
resolution, claim scoping, and V1 / capture / derivation answers.
- Resolve the task's type from the shard spec's task-type label rather
  than re-parsing the shard ID prefix, with taskShardZero returning the
  spec it already decoded.
- Encode the Begin->Done stopping-shortcut exclusion of
  acknowledged_count as an exhaustive Tail::resolves_on_done, and pin it
  with a unit test.
- Give the flowctl HTTP client a per-read timeout, so a stalled stream
  fails as a retryable Transport error instead of trusting heartbeats
  nothing enforced.
- Document that the Synced barrier also awaits trigger delivery.
- Move flowctl's stub-server sync-now tests to crates/flowctl/tests/,
  leaving the pure retry-classification table in-crate.
- Inline the single-caller reactor_front_door helper and drop redundant
  #[serde(default)] attributes.
@dgreer-dev
dgreer-dev force-pushed the daveg/3281-sync-now branch from c71883f to d5ca710 Compare August 24, 2026 14:53

@williamhbaker williamhbaker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking pretty good with the updates. I had a couple of pending comments that I think are still relevant, which will hopefully show up below this...

Comment thread go/runtime/task_control.go Outdated
Comment thread crates/flowctl/src/raw/sync_now.rs Outdated
… TLS client

- taskShardZero now checks the caller's claims and locates shard zero's
  primary under its existing keyspace lock, returning a one-member Route.
  syncNow forwards to a peer primary via that Route, or calls Resolve
  (MayProxy: false) only to obtain the local Store once recovered. The
  gRPC-metadata proxy Header and its helpers go away.
- flowctl no longer honors SSL_CERT_FILE with a per-command reqwest
  client; it keeps only the per-read timeout the shared client lacks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

synchronous "sync now" API

2 participants