@@ -36,15 +36,18 @@ a subscribe that continually tracks the current state of the data.
3636## Non-Goals
3737
3838- High-performance writes under heavy contention. The current implementation
39- serializes writes behind a global lock; the new implementation serializes
40- them via OCC retries. Neither is designed for high write throughput.
39+ serializes writes behind a global lock. The OCC implementation serializes
40+ them via retries. Neither is designed for high write throughput.
4141- Removing the in-process locks immediately. During rollout, the old lock-based
4242 path and the new OCC path coexist behind a feature flag. The locks can be
4343 removed once the OCC path is fully rolled out.
44- - Multi-statement transactions. The OCC approach as described here applies to
45- single-statement implicit transactions. Explicit multi-statement write
46- transactions continue to use the existing path. And there are not plans to
47- support mixed read/write transactions.
44+ - Mixed read/write transactions. A write that reads persisted state commits at
45+ the frontier it observed, which it cannot postpone until COMMIT, so it runs
46+ only as a single statement. A write that reads nothing does compose with
47+ transactions: its diffs are frontier-independent, so they are buffered as
48+ session write ops and land when the transaction commits. That covers, for
49+ example, ` INSERT INTO t SELECT generate_series(1, 20000) ` , whose values are
50+ constant but too large to fold into a literal.
4851
4952## Overview
5053
@@ -128,7 +131,7 @@ Session Task Coordinator
128131 | |
129132 |-- acquire OCC semaphore |
130133 | |
131- |-- CreateReadThenWriteSubscribe ----> |
134+ |-- CreateInternalSubscribe ----- ----> |
132135 | <------------ subscribe channel -----|
133136 | |
134137 | +-- OCC Loop ------------------+ |
@@ -141,14 +144,14 @@ Session Task Coordinator
141144 | | if Success: break | |
142145 | +------------------------------+ |
143146 | |
144- |-- DropReadThenWriteSubscribe ------> |
147+ |-- DropInternalSubscribe ----- ------> |
145148 | |
146149```
147150
148151### Timestamped writes
149152
150153A timestamped write is a write that must be committed at a specific timestamp.
151- The group commit machinery has to be extended to supports this by:
154+ The group commit machinery has to be extended to support this by:
152155
1531561 . Checking if the target timestamp is still valid (hasn't been passed by the
154157 oracle)
@@ -193,7 +196,7 @@ subscribe.
193196The subscribes created for read-then-write are internal: they do not appear in
194197` mz_subscriptions ` or other introspection tables, and they don't increment the
195198active subscribes metric. They are created and dropped via dedicated ` Command `
196- variants (` CreateReadThenWriteSubscribe ` , ` DropReadThenWriteSubscribe ` ).
199+ variants (` CreateInternalSubscribe ` , ` DropInternalSubscribe ` ).
197200
198201## Correctness
199202
@@ -242,7 +245,7 @@ oracle read timestamp. However, actually applying the write bumps the oracle
242245read timestamp to at least the write timestamp, so at write time it holds that
243246` write_ts <= oracle_read_ts ` . The linearization invariant is maintained.
244247
245- ### Single timestamped write write per group commit round
248+ ### Single timestamped write per group commit round
246249
247250Only one timestamped write is processed per group commit round. This is correct
248251because:
@@ -257,15 +260,22 @@ because:
257260
258261### Timeouts
259262
260- We have to be careful about bounding the lifetime of the occ loop, both in
261- wallclock time and number of retries. With the old approach, a read-then-write
262- could take arbitrarily long, and block the rest of the system. With the new
263- approach, the occ loop might try arbitrarily long, without ever succeeding. It
264- will not block the rest of the system, though , which is a big benefit.
263+ The lifetime of the OCC loop has to be bounded, both in wallclock time and in
264+ number of retries. With the lock-based approach, a read-then-write could take
265+ arbitrarily long and block the rest of the system. With OCC it can retry
266+ arbitrarily long without ever succeeding, but it does not block the rest of the
267+ system, which is a big benefit.
265268
266- As a safety net, we should bound the lifetime of the occ loop with our existing
267- statement timeout, and potentially add a hard upper limit on the number of
268- attempts per occ loop.
269+ ` statement_timeout ` provides the wallclock bound. It is enforced in the session
270+ task, around the whole operation rather than around the loop alone, so it also
271+ covers planning, OCC permit acquisition, timestamp determination, and read
272+ linearization. Any of those can park indefinitely, and a parked operation holds
273+ an OCC permit, so a bound on the loop alone would leave the permit pool
274+ starvable.
275+
276+ ` max_occ_retries ` provides the retry bound. A statement that keeps losing the
277+ race for its write timestamp fails with a contention error instead of retrying
278+ forever.
269279
270280### Comparison with the old approach
271281
@@ -284,6 +294,37 @@ The new approach is arguably easier to reason about: there is no global lock
284294state to consider, no deferred operations, no lock merging. The correctness
285295argument is local to the OCC loop and the group commit mechanism.
286296
297+ ## Deliberate differences from the lock-based path
298+
299+ A user must not be able to tell which path sequenced their statement. These are
300+ the places where the two paths do differ, on purpose. They are listed here so
301+ that the next reader does not take them for bugs.
302+
303+ - ** Statement lifecycle events.** The frontend path records an
304+ ` optimization-finished ` event for a DML, the coordinator path does not,
305+ because it hands the read-then-write's inner peek a trivial logging context
306+ and so logs nothing for it. We keep the extra event, it is real information
307+ about a statement the user did run.
308+ - ** ` max_result_size ` accounting.** The coordinator sums one row length per diff
309+ entry before consolidation. The frontend recomputes the total from the
310+ consolidated set, which counts one row length per distinct row and ignores
311+ multiplicity. So a ` DELETE ` of a million copies of one row can exceed the
312+ limit on the coordinator path and succeed on the frontend path. We keep the
313+ frontend's accounting: it matches what the write actually appends, one entry
314+ with a large diff.
315+ - ** The write-timeline throttle.** A timestamped write does not go through the
316+ throttle that a blind write's group commit applies, because its timestamp
317+ comes from an observed subscribe frontier rather than from the clock. See the
318+ doc comment on ` GroupCommitter::commit_timestamped ` for the full list of what
319+ that path skips and why.
320+ - ** Zero-row ` INSERT ... RETURNING ` .** Both paths report ` INSERT 0 0 ` with no
321+ result set when no rows match, because the coordinator decides the response
322+ kind from the evaluated RETURNING rows and there are none. Postgres returns an
323+ empty result set here, with a row description. The frontend path is
324+ deliberately bug-compatible with the coordinator rather than correct on its
325+ own: fixing it changes the behavior of the path that ships today, which is a
326+ separate decision from this change.
327+
287328## Performance
288329
289330The goal is not to make writes faster, but to not regress significantly.
@@ -295,17 +336,49 @@ Benchmarking a PoC-level implementation of the OCC approach against `main` for
295336The benchmark varies concurrency (number of workers) on the x-axis and shows
296337throughput (left) and latency (right). Key observations:
297338
298- - At low concurrency (1-7 workers), the OCC approach is comparable or _ better_
299- than ` main ` . This is because the OCC path begins preparing the write (opening
300- the subscribe, receiving the snapshot) before the write timestamp is claimed,
301- whereas the old path only starts the peek after acquiring the lock.
339+ - At low concurrency (1-7 workers), the result depends on write size. A single
340+ large ` UPDATE ` /` DELETE ` is comparable or _ better_ than ` main ` , because the
341+ subscribe streams the mutation diffs directly whereas the old path peeks every
342+ matched row and then recomputes the diffs. Small writes, however, _ regress_ :
343+ every operation installs a subscribe dataflow, waits for its snapshot, and
344+ tears it down, where the old path uses a cheap fast-path peek. This
345+ per-operation subscribe overhead makes tiny ` UPDATE ` s roughly 1.5-2x slower at
346+ low/no concurrency (observed in the nightly feature benchmark
347+ ` ManySmallUpdates ` and the scalability ` UpdateWorkload ` ).
302348- At higher concurrency, performance degrades as expected due to the O(N^2)
303349 retry behavior: with more concurrent writers, more retries are needed. The
304350 concurrency semaphore (default 4 permits) bounds this in practice.
305351- The benchmark is for a worst-case workload (all writers updating the same
306352 table). Real workloads with writes to different tables won't experience the
307353 contention.
308354
355+ The chart above is from the PoC, which benchmarked ` UPDATE t SET x = x + 1 ` over
356+ a larger table (the regime where OCC wins). It does not capture the small-write
357+ regression noted above, which is an accepted cost: high write throughput is a
358+ non-goal (see Non-Goals).
359+
360+ Measured on the full implementation, with the OCC path on for every mzcompose
361+ suite, the small-write regression is at the bad end of that range. Across nightly
362+ runs the feature benchmark ` ManySmallUpdates ` is 1.7-1.9x slower and ` Update `
363+ 1.4x slower, and the scalability ` UpdateWorkload ` loses 36-39% throughput at
364+ concurrency 1 and about 22% at 8 and 32.
365+
366+ ` ManySmallUpdates ` also steps ` memory_clusterd ` up by about 56%, from 56.8 MB to
367+ 88.5 MB. Same cause as the wallclock step, from the other side: the subscribe
368+ dataflow each operation installs is arranged on the cluster, where the fast-path
369+ peek it replaces holds nothing. The absolute figures stay small because the
370+ dataflow lives only as long as the operation.
371+
372+ The performance suites run the OCC path, because that is the configuration we
373+ intend to ship. The write benchmarks therefore record a one-time step, which we
374+ accept for the reasons above. Registering it is a follow-up once the change has
375+ landed and has a commit hash: ` ManySmallUpdates ` and ` Update ` go in
376+ ` get_ancestor_overrides_for_performance_regressions ` and ` UpdateWorkload ` in
377+ ` ANCESTOR_OVERRIDES_FOR_SCALABILITY_REGRESSIONS ` , both in
378+ ` misc/python/materialize/version_ancestor_overrides.py ` . That justification only
379+ applies when the comparison is against a released version, so until the step is
380+ inside the baseline these scenarios report a regression against ` main ` .
381+
309382## Rollout
310383
311384The new path is controlled by a ` enable_adapter_frontend_occ_read_then_write `
0 commit comments