@@ -36,15 +36,14 @@ 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 on this path commits at the frontier it
45+ observed, which it cannot postpone until COMMIT, so it runs only as a single
46+ statement.
4847
4948## Overview
5049
@@ -128,7 +127,7 @@ Session Task Coordinator
128127 | |
129128 |-- acquire OCC semaphore |
130129 | |
131- |-- CreateReadThenWriteSubscribe ----> |
130+ |-- CreateInternalSubscribe ----- ----> |
132131 | <------------ subscribe channel -----|
133132 | |
134133 | +-- OCC Loop ------------------+ |
@@ -141,14 +140,14 @@ Session Task Coordinator
141140 | | if Success: break | |
142141 | +------------------------------+ |
143142 | |
144- |-- DropReadThenWriteSubscribe ------> |
143+ |-- DropInternalSubscribe ----- ------> |
145144 | |
146145```
147146
148147### Timestamped writes
149148
150149A 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:
150+ The group commit machinery has to be extended to support this by:
152151
1531521 . Checking if the target timestamp is still valid (hasn't been passed by the
154153 oracle)
@@ -193,7 +192,7 @@ subscribe.
193192The subscribes created for read-then-write are internal: they do not appear in
194193` mz_subscriptions ` or other introspection tables, and they don't increment the
195194active subscribes metric. They are created and dropped via dedicated ` Command `
196- variants (` CreateReadThenWriteSubscribe ` , ` DropReadThenWriteSubscribe ` ).
195+ variants (` CreateInternalSubscribe ` , ` DropInternalSubscribe ` ).
197196
198197## Correctness
199198
@@ -242,7 +241,7 @@ oracle read timestamp. However, actually applying the write bumps the oracle
242241read timestamp to at least the write timestamp, so at write time it holds that
243242` write_ts <= oracle_read_ts ` . The linearization invariant is maintained.
244243
245- ### Single timestamped write write per group commit round
244+ ### Single timestamped write per group commit round
246245
247246Only one timestamped write is processed per group commit round. This is correct
248247because:
@@ -257,15 +256,22 @@ because:
257256
258257### Timeouts
259258
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.
259+ The lifetime of the OCC loop has to be bounded, both in wallclock time and in
260+ number of retries. With the lock-based approach, a read-then-write could take
261+ arbitrarily long and block the rest of the system. With OCC it can retry
262+ arbitrarily long without ever succeeding, but it does not block the rest of the
263+ system, which is a big benefit.
265264
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.
265+ ` statement_timeout ` provides the wallclock bound. It is enforced in the session
266+ task, around the whole operation rather than around the loop alone, so it also
267+ covers planning, OCC permit acquisition, timestamp determination, and read
268+ linearization. Any of those can park indefinitely, and a parked operation holds
269+ an OCC permit, so a bound on the loop alone would leave the permit pool
270+ starvable.
271+
272+ ` max_occ_retries ` provides the retry bound. A statement that keeps losing the
273+ race for its write timestamp fails with a contention error instead of retrying
274+ forever.
269275
270276### Comparison with the old approach
271277
@@ -284,6 +290,37 @@ The new approach is arguably easier to reason about: there is no global lock
284290state to consider, no deferred operations, no lock merging. The correctness
285291argument is local to the OCC loop and the group commit mechanism.
286292
293+ ## Deliberate differences from the lock-based path
294+
295+ A user must not be able to tell which path sequenced their statement. These are
296+ the places where the two paths do differ, on purpose. They are listed here so
297+ that the next reader does not take them for bugs.
298+
299+ - ** Statement lifecycle events.** The frontend path records an
300+ ` optimization-finished ` event for a DML, the coordinator path does not,
301+ because it hands the read-then-write's inner peek a trivial logging context
302+ and so logs nothing for it. We keep the extra event, it is real information
303+ about a statement the user did run.
304+ - ** ` max_result_size ` accounting.** The coordinator sums one row length per diff
305+ entry before consolidation. The frontend recomputes the total from the
306+ consolidated set, which counts one row length per distinct row and ignores
307+ multiplicity. So a ` DELETE ` of a million copies of one row can exceed the
308+ limit on the coordinator path and succeed on the frontend path. We keep the
309+ frontend's accounting: it matches what the write actually appends, one entry
310+ with a large diff.
311+ - ** The write-timeline throttle.** A timestamped write does not go through the
312+ throttle that a blind write's group commit applies, because its timestamp
313+ comes from an observed subscribe frontier rather than from the clock. See the
314+ doc comment on ` GroupCommitter::commit_timestamped ` for the full list of what
315+ that path skips and why.
316+ - ** Zero-row ` INSERT ... RETURNING ` .** Both paths report ` INSERT 0 0 ` with no
317+ result set when no rows match, because the coordinator decides the response
318+ kind from the evaluated RETURNING rows and there are none. Postgres returns an
319+ empty result set here, with a row description. The frontend path is
320+ deliberately bug-compatible with the coordinator rather than correct on its
321+ own: fixing it changes the behavior of the path that ships today, which is a
322+ separate decision from this change.
323+
287324## Performance
288325
289326The goal is not to make writes faster, but to not regress significantly.
@@ -295,17 +332,49 @@ Benchmarking a PoC-level implementation of the OCC approach against `main` for
295332The benchmark varies concurrency (number of workers) on the x-axis and shows
296333throughput (left) and latency (right). Key observations:
297334
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.
335+ - At low concurrency (1-7 workers), the result depends on write size. A single
336+ large ` UPDATE ` /` DELETE ` is comparable or _ better_ than ` main ` , because the
337+ subscribe streams the mutation diffs directly whereas the old path peeks every
338+ matched row and then recomputes the diffs. Small writes, however, _ regress_ :
339+ every operation installs a subscribe dataflow, waits for its snapshot, and
340+ tears it down, where the old path uses a cheap fast-path peek. This
341+ per-operation subscribe overhead makes tiny ` UPDATE ` s roughly 1.5-2x slower at
342+ low/no concurrency (observed in the nightly feature benchmark
343+ ` ManySmallUpdates ` and the scalability ` UpdateWorkload ` ).
302344- At higher concurrency, performance degrades as expected due to the O(N^2)
303345 retry behavior: with more concurrent writers, more retries are needed. The
304346 concurrency semaphore (default 4 permits) bounds this in practice.
305347- The benchmark is for a worst-case workload (all writers updating the same
306348 table). Real workloads with writes to different tables won't experience the
307349 contention.
308350
351+ The chart above is from the PoC, which benchmarked ` UPDATE t SET x = x + 1 ` over
352+ a larger table (the regime where OCC wins). It does not capture the small-write
353+ regression noted above, which is an accepted cost: high write throughput is a
354+ non-goal (see Non-Goals).
355+
356+ Measured on the full implementation, with the OCC path on for every mzcompose
357+ suite, the small-write regression is at the bad end of that range. Across nightly
358+ runs the feature benchmark ` ManySmallUpdates ` is 1.7-1.9x slower and ` Update `
359+ 1.4x slower, and the scalability ` UpdateWorkload ` loses 36-39% throughput at
360+ concurrency 1 and about 22% at 8 and 32.
361+
362+ ` ManySmallUpdates ` also steps ` memory_clusterd ` up by about 56%, from 56.8 MB to
363+ 88.5 MB. Same cause as the wallclock step, from the other side: the subscribe
364+ dataflow each operation installs is arranged on the cluster, where the fast-path
365+ peek it replaces holds nothing. The absolute figures stay small because the
366+ dataflow lives only as long as the operation.
367+
368+ The performance suites run the OCC path, because that is the configuration we
369+ intend to ship. The write benchmarks therefore record a one-time step, which we
370+ accept for the reasons above. Registering it is a follow-up once the change has
371+ landed and has a commit hash: ` ManySmallUpdates ` and ` Update ` go in
372+ ` get_ancestor_overrides_for_performance_regressions ` and ` UpdateWorkload ` in
373+ ` ANCESTOR_OVERRIDES_FOR_SCALABILITY_REGRESSIONS ` , both in
374+ ` misc/python/materialize/version_ancestor_overrides.py ` . That justification only
375+ applies when the comparison is against a released version, so until the step is
376+ inside the baseline these scenarios report a regression against ` main ` .
377+
309378## Rollout
310379
311380The new path is controlled by a ` enable_adapter_frontend_occ_read_then_write `
@@ -318,6 +387,12 @@ write locks). We therefore must make the flag sticky per `environmentd` process
318387lifetime (check on bootstrap only) to avoid this, and keep the current
319388` confirm_leadership ` checks.
320389
390+ In CI the flag defaults to enabled for versions that carry it, so the mzcompose
391+ suites exercise the OCC path even though production keeps it off. The version
392+ gate leaves it disabled for the older versions an upgrade test runs, and
393+ ` CI_SYSTEM_PARAMETERS=random ` can pick either value, which is how both paths
394+ stay covered.
395+
321396Once the OCC path is fully rolled out and validated:
322397
3233981 . Remove the old ` sequence_read_then_write ` code path
0 commit comments