Skip to content

Commit 5a6be37

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 a722968 commit 5a6be37

27 files changed

Lines changed: 6186 additions & 1242 deletions

File tree

doc/developer/design/20260210_incremental_occ_read_then_write.md

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

150153
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:
154+
The group commit machinery has to be extended to support this by:
152155

153156
1. Checking if the target timestamp is still valid (hasn't been passed by the
154157
oracle)
@@ -193,7 +196,7 @@ subscribe.
193196
The 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
195198
active 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
242245
read 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

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

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

311384
The new path is controlled by a `enable_adapter_frontend_occ_read_then_write`
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)