Skip to content

Commit 67ece8e

Browse files
committed
adapter: sequence read-then-write from the session task with OCC
DELETE, UPDATE and INSERT ... SELECT run on the coordinator, which holds a write lock on the target table across the read and the write. Every such statement therefore serializes against every other one on that table, and the coordinator loop is occupied for the duration. This sequences them from the session task instead, using optimistic concurrency control. The selection is read through an internal subscribe, which streams the mutation's diffs directly rather than peeking every matched row and recomputing them. The write is submitted at the timestamp the diffs were observed at, and the group committer refuses it if another writer got there first. A refusal is a retry with a fresh snapshot, not a lost update, and the retry budget is `max_occ_retries`. Concurrency is bounded by a semaphore of `max_concurrent_occ_writes` permits, acquired before the read holds so that queued operations do not pin compaction on their read dependencies while they wait. `statement_timeout` is enforced in one place, the `select!` that owns the whole operation, because every phase can block: permit acquisition, linearization against a far-future `as_of`, and the retry loop itself. The path is off by default and gated by `enable_adapter_frontend_occ_read_then_write`, which is read once at startup and fixed for the life of the process. A mixed-mode window would be unsound: the lock-based path excludes concurrent writers, the OCC path detects them afterwards, and the two do not synchronize. Large mutations get faster because the subscribe streams diffs, small ones get slower because each installs a dataflow where the old path used a fast-path peek. That trade is deliberate and recorded in the design doc.
1 parent 4a3266b commit 67ece8e

27 files changed

Lines changed: 6188 additions & 1242 deletions

File tree

doc/developer/design/20260210_incremental_occ_read_then_write.md

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

150149
A 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

153152
1. Checking if the target timestamp is still valid (hasn't been passed by the
154153
oracle)
@@ -193,7 +192,7 @@ subscribe.
193192
The 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
195194
active 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
242241
read 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

247246
Only one timestamped write is processed per group commit round. This is correct
248247
because:
@@ -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
284290
state to consider, no deferred operations, no lock merging. The correctness
285291
argument 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

289326
The 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
295332
The benchmark varies concurrency (number of workers) on the x-axis and shows
296333
throughput (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

311380
The 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
318387
lifetime (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+
321396
Once the OCC path is fully rolled out and validated:
322397

323398
1. Remove the old `sequence_read_then_write` code path
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# QA findings: incremental OCC read-then-write
2+
3+
Findings from an adversarial QA pass over frontend OCC sequencing for DELETE,
4+
UPDATE and INSERT ... SELECT, gated by
5+
`enable_adapter_frontend_occ_read_then_write`. The properties recorded here are
6+
pinned by the `qa_occ_`-prefixed tests in `src/environmentd/tests/server.rs`:
7+
8+
```
9+
METADATA_BACKEND_URL=postgres://root@localhost:26257/materialize \
10+
cargo nextest run -p mz-environmentd -E 'test(/qa_occ_/)'
11+
```
12+
13+
## `statement_timeout` bounds the whole operation, not just the OCC loop
14+
15+
Enforcement sits in the `tokio::select!` of
16+
`SessionClient::try_frontend_read_then_write_with_cancel`
17+
(`src/adapter/src/client.rs`). That frame owns the operation's entire lifetime
18+
and already handles cancellation, so a deadline placed there covers every phase:
19+
planning, OCC permit acquisition, timestamp determination, read linearization,
20+
and the retry loop. A `statement_timeout` of zero means "no deadline" and is
21+
represented by `futures::future::pending`.
22+
23+
A deadline placed further in would leave phases unbounded, and the unbounded
24+
phases are the dangerous ones. `ensure_read_linearized` sleeps until the oracle
25+
reaches the read's `as_of`, so a read whose `as_of` lies far in the future, for
26+
example one depending on a `REFRESH AT '3000-01-01'` materialized view, parks
27+
there for years. Permit acquisition happens before the loop is entered at all,
28+
so a victim of such a parked operation would never reach an in-loop deadline
29+
either.
30+
31+
When the deadline fires, the statement reports `AdapterError::StatementTimeout`
32+
and forwards `Command::PrivilegedCancelRequest` to the coordinator to clean up
33+
coordinator-owned work, mirroring the cancellation arm beside it. Dropping the
34+
`try_frontend_read_then_write` future releases the OCC permit, the read holds,
35+
and the `SubscribeHandle`, whose `Drop` sends `DropInternalSubscribe`.
36+
37+
## Permit starvation has a wider blast radius than a per-table write lock
38+
39+
A parked read-then-write holds its OCC semaphore permit
40+
(`max_concurrent_occ_writes`, default 4) for its whole lifetime. A handful of
41+
parked operations exhaust the pool and stall every read-then-write in the
42+
process, including ones on unrelated tables, because a waiter blocks on permit
43+
acquisition before doing anything else. The lock-based coordinator path cannot
44+
do that: it takes a write lock on the target table, so it only blocks writes to
45+
that table.
46+
47+
That asymmetry is why the deadline above has to cover the permit wait. It bounds
48+
the victims of a starved pool, not just the operation that starves it.
49+
50+
`statement_timeout = 0` removes that bound, so the starvation becomes permanent:
51+
four sessions with no deadline, each reading a far-future `REFRESH` materialized
52+
view, park in `ensure_read_linearized` holding the whole pool, and every
53+
read-then-write in the process fails or hangs until one of them is cancelled.
54+
55+
Moving permit acquisition after linearization would fix that case, and we do not
56+
do it, because the permit is deliberately acquired before the read holds. A
57+
waiter that queued while holding read holds would pin compaction on its read
58+
dependencies for as long as it waits, which is what happens under ordinary write
59+
contention rather than only in the far-future case. So the ordering trades a
60+
rare unbounded case against a common bounded one. Sequencing it as read holds,
61+
then linearize, then permit would swap those, not remove the trade.
62+
63+
## `max_concurrent_occ_writes` must be at least 1
64+
65+
A value of 0 sizes the semaphore to zero permits, so every read-then-write in
66+
the process waits out its `statement_timeout` and then fails. The parameter
67+
carries a domain constraint requiring at least 1, which covers both ways of
68+
setting it, `ALTER SYSTEM SET` and `system_parameter_default`.
69+
70+
`ALTER SYSTEM SET`/`RESET` of the parameter is accepted, because the value is
71+
sampled once at boot and the running process cannot observe a later change. The
72+
statement warns that the change only takes effect when `environmentd` restarts.
73+
74+
## The coordinator path blocks on a far-future read until its own timeout
75+
76+
`INSERT INTO dst SELECT a FROM mv`, where `mv` is a `REFRESH AT '3000-01-01'`
77+
materialized view, blocks on the lock-based coordinator path while holding the
78+
target table's write lock. It does not block forever. That path arms
79+
`statement_timeout` around the row stream it reads the selection from, in
80+
`sequencer::inner`, with zero mapped to `Duration::MAX`, so the statement fails
81+
after the deadline and only `statement_timeout = 0` makes the block permanent.
82+
83+
The blast radius is the target table rather than the whole process, per the
84+
argument above, which is the one respect in which the coordinator path is better
85+
behaved here.
86+
87+
This was first recorded as an unconditional hang. If you see one with a non-zero
88+
`statement_timeout`, the cause is not a missing deadline and the note above is
89+
where to stop looking. The check is cheap: with the OCC flag off,
90+
`SET statement_timeout = '5s'` and then the INSERT above should fail in about
91+
five seconds.
92+
93+
## `RETURNING` is only parsed for `INSERT`
94+
95+
The parser rejects `RETURNING` on DELETE and UPDATE, so the DELETE and UPDATE
96+
arms of the RETURNING handling in `build_success_response` are unreachable. They
97+
exist because that code dispatches on `MutationKind`, but no test can exercise
98+
them.
99+
100+
## Reviewed without findings
101+
102+
The OCC retry and consolidation logic, the interaction between timestamped
103+
writes and the oracle, and the reasoning that distinguishes an empty snapshot
104+
from the initial subscribe progress were reviewed against the code and produced
105+
no finding.

doc/user/data/metrics.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,6 +1417,20 @@ metrics:
14171417
visibility: public
14181418
tags:
14191419
- environment
1420+
- name: mz_occ_read_then_write_retry_count_bucket
1421+
help: Number of OCC retries per read-then-write operation.
1422+
labels:
1423+
- le
1424+
source: src/adapter/src/metrics.rs
1425+
visibility: internal
1426+
- name: mz_occ_read_then_write_retry_count_count
1427+
help: Number of OCC retries per read-then-write operation.
1428+
source: src/adapter/src/metrics.rs
1429+
visibility: internal
1430+
- name: mz_occ_read_then_write_retry_count_sum
1431+
help: Number of OCC retries per read-then-write operation.
1432+
source: src/adapter/src/metrics.rs
1433+
visibility: internal
14201434
- name: mz_optimization_notices
14211435
help: Number of optimization notices per notice type.
14221436
labels:

misc/python/materialize/mzcompose/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,11 @@ def get_variable_system_parameters(
240240
"true",
241241
["true", "false"],
242242
),
243+
VariableSystemParameter(
244+
"enable_adapter_frontend_occ_read_then_write",
245+
"true" if version >= MzVersion.parse_mz("v26.36.0-dev") else "false",
246+
["true", "false"],
247+
),
243248
VariableSystemParameter(
244249
"enable_cast_elimination",
245250
"true",

0 commit comments

Comments
 (0)