@@ -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:
65651 . Open a subscribe on the read expression (the ` selection ` from the
6666 ` ReadThenWrite ` plan), starting at the timestamp determined by the oracle
67672 . 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
7576This approach is correct by construction: the subscribe always reflects the
7677committed 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
161181A 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
235256passed 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
244268This ensures that the write is always based on the state of the data at exactly
245269the 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
256280read 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
261301Only 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
406460The chart above is from the PoC, which benchmarked ` UPDATE t SET x = x + 1 ` over
407461a 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
418472recording. Its statements set every matched row's ` f1 ` to one shared random
419473value, 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
431493The PoC's large-write win does not survive here. ` Update ` is itself a large
432494mutation, a full-table update over 10^6 rows, and it is 1.4x slower. An ` UPDATE `
0 commit comments