Sync Now: force an immediate commit of a materialization
Changes at a glance
- New Flow capability bit (
1 << 21), minted by /authorize/user/task at
Write+.
- New verified gRPC service on the reactor front door: resolve shard zero,
self-proxy to its primary, relay to the co-located sidecar. No semantics in
Go.
- New unary-in/stream-out service in
runtime.proto, served by the sidecar
behind its own capability interceptor.
- Leader FSM: sync-now poke =
close_requested + completion waiter, resolved
at Tail::Done.
- New command:
flowctl raw sync-now --task ....
- Open question: restrict to schedule-held transactions, or a general
"commit what you have" barrier?
Motivation
The runtime sync schedule (#3229) holds a materialization's open transaction
(which continues combining new documents) until the next scheduled commit
instant. That's the right default for cost control, but it conflicts with a
common workflow: "materialize everything you have, then run my analytics." A
user with a nightly-paced warehouse materialization should be able to write
flowctl raw sync-now --task acmeCo/foo/materialize-bar && run-analytics.sh
and know that when the first command exits, everything the materialization had
read at the moment of the call is committed and queryable in the destination.
This ticket delivers the data-plane RPC plus the flowctl raw sync-now
command. This is a runtime-next (V2) feature only, materializations only: V1
tasks are entirely out of scope.
Semantics
Sync-now is a one-shot poke delivered to the task's leader actor. Mechanically
it sets the FSM's existing close_requested input and registers a completion
waiter. close_requested bypasses policy floors (the sync-schedule hold,
configured minimum transaction duration; see the lifecycle-flag rules in
crates/runtime-next/src/leader/close_policy.rs) but never structure:
tail_done gating and unresolved-hint coherence still hold, so a caller
cannot force an incoherent or partial commit. The worst achievable outcome is
more, smaller transactions.
- Open transaction held by the schedule: the hold collapses and it proceeds to
commit (HELD_COLLAPSED).
- Open transaction already closing: no-op (
ALREADY_CLOSING).
- No open transaction: no-op, the task is already current (
IDLE).
- Open transaction, no schedule hold in effect: see the open question below.
- Derivations/captures: not applicable (
NOT_APPLICABLE); never an error for
"nothing to do".
- Concurrent and repeated calls are explicitly allowed and must be safe: every
call is an independent idempotent poke, and N concurrent calls become N
waiters on the same commit, all resolving together. No rate limiting, no
queuing, no coalescing machinery. A cron job, a retrying script, and an
impatient human can all overlap without harm.
Definition of done. The call completes when the transaction that was open
at poke time is fully acknowledged - its contents are committed and visible in
the destination. Data arriving after the poke rides the next transaction and
is out of luck. Done does NOT mean "caught up to the capture's frontier": if
the task is behind on reading, sync-now commits what has been combined so far.
A "wait until caught up to clock X" semantic is a different feature and out of
scope.
Open question: schedule override, or a new screwdriver in the toolbox?
What should sync-now do when the open transaction is not schedule-held -
it's just a big transaction, or one pinned by a configured minimum duration?
Option A - restrict to schedules: sync-now only collapses a
sync-schedule hold; anything else is a no-op with an explanatory outcome. The
feature stays exactly as motivated, and the blast radius is limited to tasks
that opted into scheduling.
Option B - general commit barrier: sync-now means "commit what you have
and tell me when it's queryable", regardless of why the transaction is open
(new outcome CLOSE_REQUESTED).
RPC shape: server-streaming with two messages
The RPC streams exactly two messages:
- Ack (immediate): the outcome enum -
HELD_COLLAPSED | ALREADY_CLOSING | IDLE | NOT_APPLICABLE, plus CLOSE_REQUESTED under Option B - and a
snapshot of the open transaction's extents (sourced docs, bytes, open age),
which the FSM already tracks. Immediate confirmation of what the call is
waiting on.
- Done (on completion): committed stats for the transaction. Sent when
the leader FSM's Tail reaches Done for the target clock recorded at
poke time.
A caller that doesn't want to wait just hangs up after the ack; the poke has
already landed server-side, so early hangup is harmless. This avoids two
server-side code paths for wait/no-wait.
Dependency: connector-side ack schedules must not stack
This is the one external blocker, and it needs a decision. Eleven
warehouse-family connectors (snowflake, bigquery, databricks, redshift,
motherduck, clickhouse, fabric-warehouse, both icebergs, filesink) implement
the legacy sync schedule by delaying their Acknowledged response - and it
is default-on: an absent connector syncSchedule means a 30-minute fixed
ack delay (defaultSyncFrequency in
connectors/go/materialize/schedule_config.go). That delay lives inside the
connector, beyond the runtime's reach. Sync-now can collapse the runtime hold,
but the commit then sits in the connector's ack delay for up to 30 minutes:
the wait stays correct (it resolves on true completion), but the caller's
script blocks on a delay the runtime cannot collapse, which defeats the
feature.
The build-time SyncScheduleConflict check rejects a configured connector
schedule alongside a model schedule, but today:
- absent connector config + model schedule passes validation and silently
stacks with the 30m default;
- the connector's documented "off" state (
syncFrequency: "0s") is counted as
configured and rejected - so there is currently no expressible config
state that yields runtime-only pacing on these connectors.
Proposal:
- Validator carve-out: treat an all-zero connector schedule
(syncFrequency: "0s", no fast-sync fields) as unconfigured in
connector_config_has_sync_schedule. Explicit 0s becomes the sanctioned
pairing and works on every connector image already shipped.
- Gate model
syncSchedule on the V2 runtime flag at validation. Today a
V1 task can carry a model schedule that is silently inert; after the
deferral change below it would be actively harmful (no pacing at all).
- Boilerplate deferral: the connector receives the built
MaterializationSpec, which now carries sync_schedule_json; change
shared boilerplate so a spec-level schedule forces the connector ack
schedule off (config and default both). One change covers all eleven
connectors; V1 tasks are untouched. Ships on the normal image cadence;
explicit 0s is the bridge until a task's image has it.
- Eventual removal of the connector-side machinery once conversion is
complete and a census shows no remaining users; the config field stays as a
tolerated no-op for stored-config compatibility.
If the open question resolves to Option B, this dependency has a second face:
an unscheduled warehouse task has no model schedule, so the deferral never
triggers and the connector's default 30m ack delay is in play - a sync-now
wait on such a task can legitimately block on it. Correct, never early, but
slow. The mitigation is the same explicit syncFrequency: "0s" (no conflict
arises without a model schedule); document it.
Sync-now itself builds no detection machinery for this: it's a documented
dependency, not a runtime check.
Failure modes
- No live leader session (task disabled, V1 task, mid-restart, shard zero
relocated): sidecar returns NOT_FOUND; the Go handler surfaces it; flowctl
retries Shard.List once to catch a relocation, then errors with a hint
that the task may not be running on the V2 runtime.
- Session dies mid-wait (shard reassignment, leader restart): parked
waiters get an error; re-invoking is idempotent and re-establishes the
contract ("all data as of the retry").
flowctl
flowctl raw sync-now --task acmeCo/foo/materialize-bar, reusing the
TaskSelector and user_task_authorization plumbing from raw list-shards.
Waits for done by default (the script use case); a --no-wait flag hangs up
after the ack.
Out of scope / follow-on
- A dashboard "Sync Now" affordance calling this same RPC. Note for that work:
browsers can't speak raw gRPC, so the UI needs a grpc-web/CORS layer on the
reactor path or a proxy; recorded here so the dashboard team knows the gap.
- Derivations (coherent under Option B; natural follow-on if wanted).
- Any V1-runtime behavior.
- "Caught up to frontier" wait semantics.
- Hold-suppression windows, "skip next hold" arming, rate limiting.
Sync Now: force an immediate commit of a materialization
Changes at a glance
1 << 21), minted by/authorize/user/taskatWrite+.
self-proxy to its primary, relay to the co-located sidecar. No semantics in
Go.
runtime.proto, served by the sidecarbehind its own capability interceptor.
close_requested+ completion waiter, resolvedat
Tail::Done.flowctl raw sync-now --task ...."commit what you have" barrier?
Motivation
The runtime sync schedule (#3229) holds a materialization's open transaction
(which continues combining new documents) until the next scheduled commit
instant. That's the right default for cost control, but it conflicts with a
common workflow: "materialize everything you have, then run my analytics." A
user with a nightly-paced warehouse materialization should be able to write
flowctl raw sync-now --task acmeCo/foo/materialize-bar && run-analytics.shand know that when the first command exits, everything the materialization had
read at the moment of the call is committed and queryable in the destination.
This ticket delivers the data-plane RPC plus the
flowctl raw sync-nowcommand. This is a runtime-next (V2) feature only, materializations only: V1
tasks are entirely out of scope.
Semantics
Sync-now is a one-shot poke delivered to the task's leader actor. Mechanically
it sets the FSM's existing
close_requestedinput and registers a completionwaiter.
close_requestedbypasses policy floors (the sync-schedule hold,configured minimum transaction duration; see the lifecycle-flag rules in
crates/runtime-next/src/leader/close_policy.rs) but never structure:tail_donegating and unresolved-hint coherence still hold, so a callercannot force an incoherent or partial commit. The worst achievable outcome is
more, smaller transactions.
commit (
HELD_COLLAPSED).ALREADY_CLOSING).IDLE).NOT_APPLICABLE); never an error for"nothing to do".
call is an independent idempotent poke, and N concurrent calls become N
waiters on the same commit, all resolving together. No rate limiting, no
queuing, no coalescing machinery. A cron job, a retrying script, and an
impatient human can all overlap without harm.
Definition of done. The call completes when the transaction that was open
at poke time is fully acknowledged - its contents are committed and visible in
the destination. Data arriving after the poke rides the next transaction and
is out of luck. Done does NOT mean "caught up to the capture's frontier": if
the task is behind on reading, sync-now commits what has been combined so far.
A "wait until caught up to clock X" semantic is a different feature and out of
scope.
Open question: schedule override, or a new screwdriver in the toolbox?
What should sync-now do when the open transaction is not schedule-held -
it's just a big transaction, or one pinned by a configured minimum duration?
Option A - restrict to schedules: sync-now only collapses a
sync-schedule hold; anything else is a no-op with an explanatory outcome. The
feature stays exactly as motivated, and the blast radius is limited to tasks
that opted into scheduling.
Option B - general commit barrier: sync-now means "commit what you have
and tell me when it's queryable", regardless of why the transaction is open
(new outcome
CLOSE_REQUESTED).RPC shape: server-streaming with two messages
The RPC streams exactly two messages:
HELD_COLLAPSED | ALREADY_CLOSING | IDLE | NOT_APPLICABLE, plusCLOSE_REQUESTEDunder Option B - and asnapshot of the open transaction's extents (sourced docs, bytes, open age),
which the FSM already tracks. Immediate confirmation of what the call is
waiting on.
the leader FSM's
TailreachesDonefor the target clock recorded atpoke time.
A caller that doesn't want to wait just hangs up after the ack; the poke has
already landed server-side, so early hangup is harmless. This avoids two
server-side code paths for wait/no-wait.
Dependency: connector-side ack schedules must not stack
This is the one external blocker, and it needs a decision. Eleven
warehouse-family connectors (snowflake, bigquery, databricks, redshift,
motherduck, clickhouse, fabric-warehouse, both icebergs, filesink) implement
the legacy sync schedule by delaying their
Acknowledgedresponse - and itis default-on: an absent connector
syncSchedulemeans a 30-minute fixedack delay (
defaultSyncFrequencyinconnectors/go/materialize/schedule_config.go). That delay lives inside theconnector, beyond the runtime's reach. Sync-now can collapse the runtime hold,
but the commit then sits in the connector's ack delay for up to 30 minutes:
the wait stays correct (it resolves on true completion), but the caller's
script blocks on a delay the runtime cannot collapse, which defeats the
feature.
The build-time
SyncScheduleConflictcheck rejects a configured connectorschedule alongside a model schedule, but today:
stacks with the 30m default;
syncFrequency: "0s") is counted asconfigured and rejected - so there is currently no expressible config
state that yields runtime-only pacing on these connectors.
Proposal:
(
syncFrequency: "0s", no fast-sync fields) as unconfigured inconnector_config_has_sync_schedule. Explicit0sbecomes the sanctionedpairing and works on every connector image already shipped.
syncScheduleon the V2 runtime flag at validation. Today aV1 task can carry a model schedule that is silently inert; after the
deferral change below it would be actively harmful (no pacing at all).
MaterializationSpec, which now carriessync_schedule_json; changeshared boilerplate so a spec-level schedule forces the connector ack
schedule off (config and default both). One change covers all eleven
connectors; V1 tasks are untouched. Ships on the normal image cadence;
explicit
0sis the bridge until a task's image has it.complete and a census shows no remaining users; the config field stays as a
tolerated no-op for stored-config compatibility.
If the open question resolves to Option B, this dependency has a second face:
an unscheduled warehouse task has no model schedule, so the deferral never
triggers and the connector's default 30m ack delay is in play - a sync-now
wait on such a task can legitimately block on it. Correct, never early, but
slow. The mitigation is the same explicit
syncFrequency: "0s"(no conflictarises without a model schedule); document it.
Sync-now itself builds no detection machinery for this: it's a documented
dependency, not a runtime check.
Failure modes
relocated): sidecar returns NOT_FOUND; the Go handler surfaces it; flowctl
retries
Shard.Listonce to catch a relocation, then errors with a hintthat the task may not be running on the V2 runtime.
waiters get an error; re-invoking is idempotent and re-establishes the
contract ("all data as of the retry").
flowctl
flowctl raw sync-now --task acmeCo/foo/materialize-bar, reusing theTaskSelectoranduser_task_authorizationplumbing fromraw list-shards.Waits for done by default (the script use case); a
--no-waitflag hangs upafter the ack.
Out of scope / follow-on
browsers can't speak raw gRPC, so the UI needs a grpc-web/CORS layer on the
reactor path or a proxy; recorded here so the dashboard team knows the gap.