Skip to content

Commit 08a0b7b

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. The zero-row linearization workflow now parks its winner with a blind write rather than a timestamped one, so the answer is reached without a round trip through the group committer and the wait for the oracle is observable at all.
1 parent 09bb18d commit 08a0b7b

6 files changed

Lines changed: 945 additions & 441 deletions

File tree

doc/developer/design/20260210_incremental_occ_read_then_write.md

Lines changed: 104 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,18 +145,37 @@ 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
| |
155157
|-- DropInternalSubscribe -----------> |
156158
| |
157159
```
158160

161+
The target `T` comes from the oracle, not from the subscribe's frontier. A
162+
frontier certifies what the loop has a complete view of, which is a different
163+
question from which timestamp to write at, and the two coincide only when the
164+
selection's inputs are caught up with the oracle. An input can legitimately sit
165+
far in the future, a materialized view with a `REFRESH` schedule for instance, so
166+
taking `T` from the frontier would move the write timeline to that future and
167+
keep it there. The frontier gates the write, the oracle chooses it, and three
168+
invariants hold for every write the loop makes: the frontier is at or above `T`
169+
before it submits, the payload is every diff strictly below `T`, and `T` is above
170+
the statement's `as_of`, which is where the snapshot arrives.
171+
172+
Note that the frontier being at or above `T` does not make the two equal. The
173+
frontier is a minimum over the selection's inputs, so it bounds neither `T` nor
174+
the target table's upper. Where it runs above `T`, a selection that reads the
175+
target table sees diffs the payload excludes, the compare-and-append refuses, and
176+
the loop retries at a higher target. Persist arbitrates the timestamp, not the
177+
frontier.
178+
159179
### Timestamped writes
160180

161181
A timestamped write is a write that must be committed at a specific timestamp.
@@ -230,16 +250,20 @@ selection.
230250

231251
### The timestamped write ensures atomicity
232252

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
253+
The write is submitted at a timestamp taken from the timeline's oracle, once the
254+
subscribe's frontier has reached it so that the accumulated diffs below it are
255+
complete. The group commit machinery checks that this timestamp hasn't been
235256
passed by the oracle:
236257

237258
- If the timestamp is still valid: the write is committed at exactly that
238259
timestamp, and the oracle is advanced past it. Any concurrent OCC loops that
239260
were targeting the same timestamp will fail and retry.
240261
- 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.
262+
write also fails, and the reply names the next eligible timestamp. The OCC
263+
loop adopts that as its new target, waits for the subscribe's frontier to
264+
reach it, which folds the intervening writes' updates into the payload, and
265+
retries. The reported timestamp is always strictly above the rejected one, so
266+
the retries make progress.
243267

244268
This ensures that the write is always based on the state of the data at exactly
245269
the write timestamp. There is no window for lost updates: either the write
@@ -256,6 +280,22 @@ oracle read timestamp. However, actually applying the write bumps the oracle
256280
read timestamp to at least the write timestamp, so at write time it holds that
257281
`write_ts <= oracle_read_ts`. The linearization invariant is maintained.
258282

283+
A statement that matches no rows performs no write, so nothing advances the
284+
oracle for it. It reports the timestamp its view is complete through and waits
285+
for the oracle read timestamp to reach that. A selection already empty at `as_of`
286+
is complete through `as_of`, which the statement linearized before its subscribe
287+
started, so the wait is satisfied at once. A selection that empties later reports
288+
the later timestamp and waits for a group commit.
289+
290+
Reporting the frontier the subscribe observed would also be correct, but the
291+
subscribe follows Persist, which runs ahead of the oracle, so that frontier is
292+
usually a timestamp the oracle has not published and every zero-row answer would
293+
wait for a commit. The lower timestamp is not weaker: the answer describes the
294+
selection at the timestamp it reports, later reads land at or above it, and rows
295+
written after it are later in the serial order, as they would be for a SELECT
296+
there. Answering from state the oracle has not published is the hazard, and that
297+
is the direction the wait covers.
298+
259299
### Single timestamped write per group commit round
260300

261301
Only one timestamped write is processed per group commit round. This is correct
@@ -324,13 +364,19 @@ that the next reader does not take them for bugs.
324364
When inputs are caught up the lock path's window is milliseconds wide and also
325365
needs a materially conflicting write plus a reader inside it, which is
326366
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.
367+
- **A lagging dependency delays rather than being read stale.** This is the
368+
price of the strengthening above. The write timestamp comes from the oracle,
369+
and the loop waits for the subscribe's frontier to certify it before
370+
submitting, so a lagging selection dependency delays the statement by its lag.
371+
A dependency that catches up commits normally. One that persistently lags by
372+
more than about one `default_timestamp_interval` never lets an attempt land:
373+
every wait ends with the oracle already past the target, the committer refuses
374+
it and names a newer one, and the next wait is again bounded by the lagging
375+
input. Each round costs one of `max_occ_retries`, but the rounds are paced by
376+
frontier advances rather than spinning, so what ends the statement is
377+
`statement_timeout`, which it runs out while holding an OCC permit and its
378+
subscribe. The lock path's peek simply waited for the input to catch up and
379+
then committed.
334380
- **Statement lifecycle events.** The frontend path records an
335381
`optimization-finished` event for a DML, the coordinator path does not,
336382
because it hands the read-then-write's inner peek a trivial logging context
@@ -357,11 +403,19 @@ that the next reader does not take them for bugs.
357403
limit on the coordinator path and succeed on the frontend path. We keep the
358404
frontend's accounting: it matches what the write actually appends, one entry
359405
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.
406+
- **The write-timeline throttle.** A blind write's group commit sleeps in the
407+
committer until the wall clock catches up with the oracle's write timestamp,
408+
keeping the timeline from running ahead of the clock. A timestamped write
409+
cannot be throttled that way: its timestamp is fixed before it reaches the
410+
committer, so sleeping would only delay a write that already has to land at
411+
that timestamp. The committer instead refuses a target above
412+
`write_ts_upper_bound(now)` outright. That refusal is unreachable in normal
413+
operation, since the target is one step above the oracle's write timestamp and
414+
the oracle clamps itself to the clock. It fires only for a write timeline that
415+
has already run away from the clock, which is an environment-level invariant
416+
violation rather than something a statement can provoke. See the doc comment on
417+
`GroupCommitter::commit_timestamped` for the full list of what that path skips
418+
and why.
365419
- **Zero-row `INSERT ... RETURNING`.** Both paths report `INSERT 0 0` with no
366420
result set when no rows match, because the coordinator decides the response
367421
kind from the evaluated RETURNING rows and there are none. Postgres returns an
@@ -398,10 +452,10 @@ throughput (left) and latency (right). Key observations:
398452
a subscribe sees only progress from another table's write. They do still
399453
contend, in three ways: the concurrency semaphore is process-global across
400454
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.
455+
shared txns-shard upper, so two writers that took the same target timestamp
456+
refuse each other, and each timestamped write is its own committer round rather
457+
than merging into a shared group commit. Every write benchmark is single-table,
458+
so the cross-table case is unmeasured.
405459

406460
The chart above is from the PoC, which benchmarked `UPDATE t SET x = x + 1` over
407461
a larger table (the regime where OCC wins). It does not capture the small-write
@@ -417,16 +471,24 @@ three runs) and `Update` 1.4x slower (33-45%), and the scalability
417471
`ManySmallUpdates` is the worst case for this design, and the reason is worth
418472
recording. Its statements set every matched row's `f1` to one shared random
419473
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.
474+
roughly 90% of its 100 updates end up matching no rows. Such a statement has
475+
nothing to write, and the oracle advances only when a group commit applies, so
476+
linearizing a timestamp the oracle has not reached costs a commit that carries
477+
nothing. Asking for one rather than waiting for the periodic keepalive is the
478+
difference between 3.5x and 157x. A selection already empty at its `as_of` needs
479+
no commit at all, which is this workload's shape, and what remains is a selection
480+
that empties later. That residue is the price of the linearization guarantee
481+
rather than a defect, since only a commit advances the oracle.
482+
483+
Note: the figures above were measured before the write timestamp came from the
484+
oracle and a zero-row answer reported the timestamp its view is complete through.
485+
Re-measured on `ManySmallUpdates` with the path forced on for both builds, those
486+
two are worth 1.6x to 2.3x, 0.59-0.88s against 1.33-1.41s over four comparisons,
487+
with a control holding the path off on both sides at 0.336s against 0.337s. Group
488+
commits per statement fall from about 1.09 to between 0.13 and 0.37, which is the
489+
mechanism rather than a proxy for it. The scenario remains about 2x the lock path,
490+
the rest of the gap being the subscribe dataflow installed and torn down for every
491+
statement.
430492

431493
The PoC's large-write win does not survive here. `Update` is itself a large
432494
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
@@ -144,6 +144,10 @@ pub enum AdapterError {
144144
/// The write timestamp ran past what the write timeline may be advanced to,
145145
/// so nothing was appended. See `coord::timeline::write_ts_upper_bound` for
146146
/// the bound and why exceeding it is not recoverable.
147+
///
148+
/// The timestamp comes from the timeline's oracle, so reaching this means the
149+
/// oracle has run away from the wall clock rather than that the statement
150+
/// asked for anything unusual.
147151
ReadThenWriteTimestampTooFarAhead {
148152
target_timestamp: mz_repr::Timestamp,
149153
limit: mz_repr::Timestamp,
@@ -929,7 +933,10 @@ impl AdapterError {
929933
}
930934
AdapterError::ReadThenWriteContention => SqlState::T_R_SERIALIZATION_FAILURE,
931935
AdapterError::ReadThenWriteTimestampTooFarAhead { .. } => {
932-
SqlState::FEATURE_NOT_SUPPORTED
936+
// An invariant violation in the environment rather than a property of
937+
// the statement: the write timeline has run away from the wall clock.
938+
// Nothing the client sends can produce or avoid it.
939+
SqlState::INTERNAL_ERROR
933940
}
934941
AdapterError::CollectionUnreadable { .. } => SqlState::NO_DATA_FOUND,
935942
AdapterError::NoClusterReplicasAvailable { .. } => SqlState::FEATURE_NOT_SUPPORTED,

0 commit comments

Comments
 (0)