Skip to content

Commit d914c9c

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. Emptiness that already holds at `as_of` is reported against `as_of`, which the pre-read linearization covered, so the common `UPDATE ... WHERE <no match>` returns without a group commit. Emptiness that appears later is reported against the target it was concluded at rather than a frontier that can be days out. 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 229a3c6 commit d914c9c

6 files changed

Lines changed: 851 additions & 372 deletions

File tree

doc/developer/design/20260210_incremental_occ_read_then_write.md

Lines changed: 52 additions & 32 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

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: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,13 @@ pub enum AdapterError {
143143
/// A frontend read-then-write's write timestamp ran further ahead of the wall
144144
/// clock than the write timeline may be advanced.
145145
///
146-
/// Nothing was appended. Committing there would advance the timeline's oracle
147-
/// to that timestamp, and the oracle is monotone and durable, so every later
148-
/// write and strict-serializable read would block until the wall clock caught
149-
/// up, restarts included.
146+
/// Nothing was appended. The timestamp comes from the timeline's oracle, one
147+
/// step above its write timestamp, so hitting the bound means the oracle has
148+
/// run away from the wall clock rather than that the statement asked for
149+
/// anything unusual. Committing there would advance the oracle further, and
150+
/// the oracle is monotone and durable, so every later write and
151+
/// strict-serializable read would block until the wall clock caught up,
152+
/// restarts included.
150153
ReadThenWriteTimestampTooFarAhead {
151154
target_timestamp: mz_repr::Timestamp,
152155
limit: mz_repr::Timestamp,
@@ -848,10 +851,9 @@ impl AdapterError {
848851
committing. Retry the statement, or lower the write concurrency.".into()
849852
),
850853
AdapterError::ReadThenWriteTimestampTooFarAhead { .. } => Some(
851-
"The selection reads a collection whose contents are already settled far \
852-
into the future, for example a materialized view with a REFRESH option. \
853-
Read it into a table first, or select from it at a time it is still \
854-
changing.".into()
854+
"This environment's write timestamp is far ahead of the wall clock. Writes \
855+
and strict-serializable reads cannot proceed until the clock catches up \
856+
with it. The statement cannot be rewritten to avoid this.".into()
855857
),
856858
AdapterError::CollectionUnreadable { .. } => Some(
857859
"This could be because the collection has recently been dropped.".into()
@@ -918,7 +920,12 @@ impl AdapterError {
918920
}
919921
AdapterError::ReadThenWriteContention => SqlState::T_R_SERIALIZATION_FAILURE,
920922
AdapterError::ReadThenWriteTimestampTooFarAhead { .. } => {
921-
SqlState::FEATURE_NOT_SUPPORTED
923+
// An invariant violation in the environment rather than a property of
924+
// the statement: the write timeline has run away from the wall clock.
925+
// The same condition trips `check_runaway_write_ts`'s soft panic on the
926+
// next write the timeline applies, so a test build fails rather than
927+
// returning this at all.
928+
SqlState::INTERNAL_ERROR
922929
}
923930
AdapterError::CollectionUnreadable { .. } => SqlState::NO_DATA_FOUND,
924931
AdapterError::NoClusterReplicasAvailable { .. } => SqlState::FEATURE_NOT_SUPPORTED,

0 commit comments

Comments
 (0)