Skip to content

Commit 7e2f679

Browse files
committed
adapter: let the oracle choose the read-then-write write timestamp
The OCC loop wrote at the frontier its subscribe reported, which conflates two jobs. A frontier certifies what the loop has a complete view of. Choosing the timestamp to write at is a separate decision, and the two coincide only because the target table is usually what pins the frontier. They come apart when another input does. A materialized view with a `REFRESH` option settles until its next refresh, so a selection over one reports a frontier hours or days ahead of the clock while the target table's upper is still near it. Writing there ratchets the timeline's oracle into the future, and the oracle is monotone and durable, so every later write and strict-serializable read blocks until the clock catches up. Under `serializable` the write is simply invisible. So the oracle chooses and the frontier certifies. The target `T` is one step above the oracle's write timestamp, the smallest value `commit_timestamped` accepts, and a conflict hands back the next eligible one. The loop writes at `T` once the frontier reaches `T`, with every diff from strictly below `T` as the payload and everything at or after it held back for a later target. Three boundaries carry the correctness. A progress message at `F` certifies completeness below `F`, so readiness is `F >= T`. The payload is `t < T` strictly, because a diff at `T` is concurrent with the write. And `T > as_of`, because the snapshot arrives at `as_of` and has to be in the payload, which the pre-read linearization guarantees by leaving the oracle at or above `as_of`. Readiness gives `T <= F`, not equality, so the payload can exclude diffs the subscribe already delivered in `[T, F)`. For a selection that reads the target table those say the table moved past `T`, the compare-and-append refuses, and the loop retries at the timestamp the refusal names. Persist arbitrates, not the frontier. This path produces that window itself: the committer appends at the target and applies it to the oracle only afterwards, so while one write sits in between, a second statement's target is a step behind the table's upper. Retries converge because both refusals name a strictly higher timestamp and the loop waits for the frontier to certify each one. A zero-row answer no longer waits on a frontier either. It reports the timestamp the emptiness holds as of, one below whichever of the frontier and the target certifies less. For the common `UPDATE ... WHERE <no match>` that is the statement's own `as_of`, already linearized before the subscribe started, so the answer costs no group commit, and the two cases the loop used to separate turn out to be the same rule. Tests: the far-future refresh-MV write commits near the clock instead of being refused, and a `serializable` session reads back its own such write, which pins the anomaly this closes. Unit tests cover the fold's boundaries, where the off-by-ones live.
1 parent 0b2779e commit 7e2f679

6 files changed

Lines changed: 866 additions & 411 deletions

File tree

‎doc/developer/design/20260210_incremental_occ_read_then_write.md‎

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ a subscribe that continually tracks the current state of the data.
4949
- Removing the in-process locks immediately. During rollout, the old lock-based
5050
path and the new OCC path coexist behind a feature flag. The locks can be
5151
removed once the OCC path is fully rolled out.
52-
- Mixed read/write transactions. A write that reads persisted state commits at
53-
the frontier it observed, which it cannot postpone until COMMIT, so it runs
54-
only as a single statement. A write that reads nothing does compose with
52+
- Mixed read/write transactions. A write that reads persisted state commits at a
53+
timestamp the oracle handed out while the statement ran, which it cannot
54+
postpone until COMMIT, so it runs only as a single statement. A write that reads nothing does compose with
5555
transactions: its diffs are frontier-independent, so they are buffered as
5656
session write ops and land when the transaction commits. That covers, for
5757
example, `INSERT INTO t SELECT generate_series(1, 20000)`, whose values are
@@ -65,12 +65,13 @@ subscribe-based OCC loop:
6565
1. Open a subscribe on the read expression (the `selection` from the
6666
`ReadThenWrite` plan), starting at the timestamp determined by the oracle
6767
2. Accumulate diffs from the subscribe
68-
3. When the subscribe frontier advances to T (meaning we have a consistent
69-
snapshot), attempt to write the accumulated diffs at timestamp T
70-
4. If the write succeeds, done
71-
5. If the write fails because another writer already committed at timestamp T,
72-
the subscribe will deliver the new state; go back to step 3 with the updated
73-
diffs
68+
3. Take the write timestamp T from the timeline's oracle, one step above its
69+
write timestamp, which is the smallest value the group committer accepts
70+
4. Once the subscribe frontier has advanced to T, so the accumulated diffs below
71+
T are complete, attempt to write those diffs at T
72+
5. If the write succeeds, done
73+
6. If the write fails because another writer already took T, adopt the timestamp
74+
the committer reports as next eligible and go back to step 4 with it
7475

7576
This approach is correct by construction: the subscribe always reflects the
7677
committed state of the data, and the timestamped write mechanism ensures that
@@ -144,11 +145,12 @@ Session Task Coordinator
144145
| |
145146
| +-- OCC Loop ------------------+ |
146147
| | receive diffs from subscribe | |
147-
| | on frontier advance: | |
148-
| | consolidate diffs | |
148+
| | target T from the oracle | |
149+
| | once frontier >= T: | |
150+
| | consolidate diffs below T | |
149151
| | AttemptTimestampedWrite -> |-->|-- group_commit()
150152
| | <-- Success/Failed --------|<--|
151-
| | if Failed: continue loop | |
153+
| | if Failed: T = next, loop | |
152154
| | if Success: break | |
153155
| +------------------------------+ |
154156
| |
@@ -230,16 +232,20 @@ selection.
230232

231233
### The timestamped write ensures atomicity
232234

233-
The write is submitted at the timestamp corresponding to the subscribe's
234-
frontier. The group commit machinery checks that this timestamp hasn't been
235+
The write is submitted at a timestamp taken from the timeline's oracle, once the
236+
subscribe's frontier has reached it so that the accumulated diffs below it are
237+
complete. The group commit machinery checks that this timestamp hasn't been
235238
passed by the oracle:
236239

237240
- If the timestamp is still valid: the write is committed at exactly that
238241
timestamp, and the oracle is advanced past it. Any concurrent OCC loops that
239242
were targeting the same timestamp will fail and retry.
240243
- If the timestamp has already passed (another write committed first): the
241-
write also fails. The OCC loop continues, the subscribe delivers the updates
242-
from the intervening write, and the loop retries at the new frontier.
244+
write also fails, and the reply names the next eligible timestamp. The OCC
245+
loop adopts that as its new target, waits for the subscribe's frontier to
246+
reach it, which folds the intervening writes' updates into the payload, and
247+
retries. The reported timestamp is always strictly above the rejected one, so
248+
the retries make progress.
243249

244250
This ensures that the write is always based on the state of the data at exactly
245251
the write timestamp. There is no window for lost updates: either the write
@@ -324,13 +330,19 @@ that the next reader does not take them for bugs.
324330
When inputs are caught up the lock path's window is milliseconds wide and also
325331
needs a materially conflicting write plus a reader inside it, which is
326332
presumably why it went unnoticed.
327-
- **A lagging dependency blocks rather than waits.** This is the price of the
328-
strengthening above. A selection dependency that persistently lags by more
329-
than about one `default_timestamp_interval` makes every attempt conflict,
330-
because the observed frontier is bounded by the lagging input while the write
331-
timestamp keeps advancing with the oracle. The statement then burns retries
332-
until `statement_timeout` instead of committing, where the lock path's peek
333-
simply waited for the input to catch up.
333+
- **A lagging dependency delays rather than being read stale.** This is the
334+
price of the strengthening above. The write timestamp comes from the oracle,
335+
and the loop waits for the subscribe's frontier to certify it before
336+
submitting, so a lagging selection dependency delays the statement by its lag.
337+
A dependency that catches up commits normally. One that persistently lags by
338+
more than about one `default_timestamp_interval` never lets an attempt land:
339+
every wait ends with the oracle already past the target, the committer refuses
340+
it and names a newer one, and the next wait is again bounded by the lagging
341+
input. Each round costs one of `max_occ_retries`, but the rounds are paced by
342+
frontier advances rather than spinning, so what ends the statement is
343+
`statement_timeout`, which it runs out while holding an OCC permit and its
344+
subscribe. The lock path's peek simply waited for the input to catch up and
345+
then committed.
334346
- **Statement lifecycle events.** The frontend path records an
335347
`optimization-finished` event for a DML, the coordinator path does not,
336348
because it hands the read-then-write's inner peek a trivial logging context
@@ -357,11 +369,19 @@ that the next reader does not take them for bugs.
357369
limit on the coordinator path and succeed on the frontend path. We keep the
358370
frontend's accounting: it matches what the write actually appends, one entry
359371
with a large diff.
360-
- **The write-timeline throttle.** A timestamped write does not go through the
361-
throttle that a blind write's group commit applies, because its timestamp
362-
comes from an observed subscribe frontier rather than from the clock. See the
363-
doc comment on `GroupCommitter::commit_timestamped` for the full list of what
364-
that path skips and why.
372+
- **The write-timeline throttle.** A blind write's group commit sleeps in the
373+
committer until the wall clock catches up with the oracle's write timestamp,
374+
keeping the timeline from running ahead of the clock. A timestamped write
375+
cannot be throttled that way: its timestamp is fixed before it reaches the
376+
committer, so sleeping would only delay a write that already has to land at
377+
that timestamp. The committer instead refuses a target above
378+
`write_ts_upper_bound(now)` outright. That refusal is unreachable in normal
379+
operation, since the target is one step above the oracle's write timestamp and
380+
the oracle clamps itself to the clock. It fires only for a write timeline that
381+
has already run away from the clock, which is an environment-level invariant
382+
violation rather than something a statement can provoke. See the doc comment on
383+
`GroupCommitter::commit_timestamped` for the full list of what that path skips
384+
and why.
365385
- **Zero-row `INSERT ... RETURNING`.** Both paths report `INSERT 0 0` with no
366386
result set when no rows match, because the coordinator decides the response
367387
kind from the evaluated RETURNING rows and there are none. Postgres returns an
@@ -398,10 +418,10 @@ throughput (left) and latency (right). Key observations:
398418
a subscribe sees only progress from another table's write. They do still
399419
contend, in three ways: the concurrency semaphore is process-global across
400420
tables and clusters, the conflict predicate is the global oracle plus the
401-
shared txns-shard upper, so two writers that observed the same frontier refuse
402-
each other, and each timestamped write is its own committer round rather than
403-
merging into a shared group commit. Every write benchmark is single-table, so
404-
the cross-table case is unmeasured.
421+
shared txns-shard upper, so two writers that took the same target timestamp
422+
refuse each other, and each timestamped write is its own committer round rather
423+
than merging into a shared group commit. Every write benchmark is single-table,
424+
so the cross-table case is unmeasured.
405425

406426
The chart above is from the PoC, which benchmarked `UPDATE t SET x = x + 1` over
407427
a larger table (the regime where OCC wins). It does not capture the small-write
@@ -417,16 +437,31 @@ three runs) and `Update` 1.4x slower (33-45%), and the scalability
417437
`ManySmallUpdates` is the worst case for this design, and the reason is worth
418438
recording. Its statements set every matched row's `f1` to one shared random
419439
value, which merges a whole residue class, so the class count only shrinks and
420-
roughly 90% of its 100 updates end up matching no rows. A statement that matches
421-
nothing still has to linearize its read, and the oracle advances only when a
422-
group commit applies, so each of those statements needs a commit that has nothing
423-
to write. We ask for one rather than waiting for the periodic keepalive, which
424-
costs a commit round trip per statement instead of up to a full
425-
`default_timestamp_interval`. That is the difference between 3.5x and 157x, but
426-
it is not free, and a workload dominated by zero-row writes pays it on every
427-
statement. The residue is the price of the linearization guarantee rather than a
428-
defect: correctness requires the oracle to advance, and only a commit advances
429-
it.
440+
roughly 90% of its 100 updates end up matching no rows. A zero-row answer has to
441+
be linearized, and the oracle advances only when a group commit applies, so such
442+
a statement needs a commit that has nothing to write. The numbers above are
443+
measured with every zero-row statement paying for one, and they are the
444+
difference between asking for that commit and waiting for the periodic keepalive
445+
instead, 3.5x against 157x.
446+
447+
Most of those commits are avoidable, and this design avoids them. A selection
448+
that is already empty at the statement's `as_of` gives an answer that holds at
449+
`as_of`, which was linearized before the subscribe started, so it needs no commit
450+
at all. That is this workload's shape. What still pays is a selection that becomes
451+
empty only after its `as_of`, and that residue is the price of the linearization
452+
guarantee rather than a defect, since only a commit advances the oracle.
453+
454+
Measured on `ManySmallUpdates`, with the path forced on for both builds, that is
455+
worth 1.6x to 2.3x: 0.59-0.88s against 1.33-1.41s over four comparisons, with a
456+
control run holding the path off on both sides landing at 0.336s against 0.337s.
457+
Group commits per statement fall from about 1.09 to between 0.13 and 0.37, which
458+
is the mechanism itself rather than a proxy for it, since what remains is roughly
459+
the statements that do match rows.
460+
461+
The scenario is still about 2x the lock path on the same machine, so the rest of
462+
its gap is the per-statement work this design cannot avoid, above all a subscribe
463+
dataflow installed and torn down for every statement. The zero-row commit was the
464+
part that could be removed.
430465

431466
The PoC's large-write win does not survive here. `Update` is itself a large
432467
mutation, a full-table update over 10^6 rows, and it is 1.4x slower. An `UPDATE`

‎src/adapter/src/coord/appends.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -445,11 +445,11 @@ impl GroupCommitter {
445445
///
446446
/// What [`Self::commit`] does that this skips, and why that is safe:
447447
///
448-
/// * The wall-clock throttle. `target_timestamp` is the caller's to choose, so
449-
/// instead of sleeping until the clock catches up we refuse a target above
450-
/// [`write_ts_upper_bound`] outright. Sleeping is the wrong answer for a caller
451-
/// whose target can be hours out, and committing there would advance the oracle
452-
/// with it.
448+
/// * The wall-clock throttle. `target_timestamp` is the caller's to choose, and a
449+
/// target above [`write_ts_upper_bound`] is refused rather than slept off.
450+
/// Committing there would advance the oracle with it, and a caller that took its
451+
/// target from the oracle cannot exceed the bound unless the timeline has already
452+
/// run away, which sleeping would not resolve.
453453
/// * A [`GroupCommitPermit`]. The caller bounds how many of these are in
454454
/// flight, and that is the backpressure for this path.
455455
/// * Merging queued commits. There is nothing to merge into: these diffs

‎src/adapter/src/error.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ pub enum AdapterError {
143143
/// The write timestamp ran past what the write timeline may be advanced to,
144144
/// so nothing was appended. See `coord::timeline::write_ts_upper_bound` for
145145
/// the bound and why exceeding it is not recoverable.
146+
///
147+
/// The timestamp comes from the timeline's oracle, so reaching this means the
148+
/// oracle has run away from the wall clock rather than that the statement
149+
/// asked for anything unusual.
146150
ReadThenWriteTimestampTooFarAhead {
147151
target_timestamp: mz_repr::Timestamp,
148152
limit: mz_repr::Timestamp,
@@ -913,7 +917,10 @@ impl AdapterError {
913917
}
914918
AdapterError::ReadThenWriteContention => SqlState::T_R_SERIALIZATION_FAILURE,
915919
AdapterError::ReadThenWriteTimestampTooFarAhead { .. } => {
916-
SqlState::FEATURE_NOT_SUPPORTED
920+
// An invariant violation in the environment rather than a property of
921+
// the statement: the write timeline has run away from the wall clock.
922+
// Nothing the client sends can produce or avoid it.
923+
SqlState::INTERNAL_ERROR
917924
}
918925
AdapterError::CollectionUnreadable { .. } => SqlState::NO_DATA_FOUND,
919926
AdapterError::NoClusterReplicasAvailable { .. } => SqlState::FEATURE_NOT_SUPPORTED,

0 commit comments

Comments
 (0)