Skip to content

Commit 9a68d51

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 628c455 commit 9a68d51

26 files changed

Lines changed: 6093 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

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",

misc/python/materialize/parallel_workload/action.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
MAX_TABLES,
6363
MAX_VIEWS,
6464
MAX_WEBHOOK_SOURCES,
65+
OCC_CONTENTION_EXHAUSTED_ERROR,
6566
Cluster,
6667
ClusterReplica,
6768
Column,
@@ -773,7 +774,6 @@ def run(self, exe: Executor) -> bool:
773774
)
774775
all_column_values = ", ".join(f"({v})" for v in column_values)
775776
query = f"INSERT INTO {table} ({column_names}) VALUES {all_column_values}"
776-
# TODO: Use INSERT INTO {} SELECT {} (only works for tables)
777777
if self.rng.choice([True, False]):
778778
self.stmt_id += 1
779779
self.exe_prepared(query, f"insert{self.stmt_id}", exe)
@@ -796,6 +796,7 @@ def errors_to_ignore(self, exe: Executor) -> list[str]:
796796
result.extend(
797797
[
798798
"canceling statement due to statement timeout",
799+
OCC_CONTENTION_EXHAUSTED_ERROR,
799800
# A random expression can evaluate to NULL (e.g. a map-key
800801
# miss) even for a NOT NULL column, which is a legitimate
801802
# rejection. The base list only ignores it for DDL complexity.
@@ -909,6 +910,11 @@ def run(self, exe: Executor) -> bool:
909910

910911

911912
class InsertReturningAction(Action):
913+
def errors_to_ignore(self, exe: Executor) -> list[str]:
914+
# A constant INSERT is a blind write, but RETURNING takes it off that
915+
# fast path and makes it a read-then-write.
916+
return [OCC_CONTENTION_EXHAUSTED_ERROR] + super().errors_to_ignore(exe)
917+
912918
def run(self, exe: Executor) -> bool:
913919
table = None
914920
if exe.insert_table is not None:
@@ -946,7 +952,6 @@ def run(self, exe: Executor) -> bool:
946952
)
947953
all_column_values = ", ".join(f"({v})" for v in column_values)
948954
query = f"INSERT INTO {table} ({column_names}) VALUES {all_column_values}"
949-
# TODO: Use INSERT INTO {} SELECT {} (only works for tables)
950955
returning_exprs = []
951956
if self.rng.random() < 0.5:
952957
returning_exprs += [
@@ -1012,6 +1017,7 @@ def errors_to_ignore(self, exe: Executor) -> list[str]:
10121017
result.extend(
10131018
[
10141019
"canceling statement due to statement timeout",
1020+
OCC_CONTENTION_EXHAUSTED_ERROR,
10151021
# A random SET expression can evaluate to NULL (e.g. a map-key
10161022
# miss) even for a NOT NULL column. That is a legitimate
10171023
# rejection, not a bug, and the column type can't be coerced
@@ -1070,6 +1076,9 @@ class ReadThenWriteCounterUpdateAction(Action):
10701076
def errors_to_ignore(self, exe: Executor) -> list[str]:
10711077
return [
10721078
"canceling statement due to statement timeout",
1079+
# Extreme contention on one row is what this action creates, so
1080+
# exhausting the retry budget is an expected outcome here.
1081+
OCC_CONTENTION_EXHAUSTED_ERROR,
10731082
] + super().errors_to_ignore(exe)
10741083

10751084
def run(self, exe: Executor) -> bool:
@@ -1096,6 +1105,7 @@ class DeleteAction(Action):
10961105
def errors_to_ignore(self, exe: Executor) -> list[str]:
10971106
errors = [
10981107
"canceling statement due to statement timeout",
1108+
OCC_CONTENTION_EXHAUSTED_ERROR,
10991109
] + super().errors_to_ignore(exe)
11001110
if exe.db.scenario == Scenario.Rename:
11011111
errors += ["does not exist"]
@@ -2038,6 +2048,10 @@ def __init__(
20382048
# behavior, you should add it. Feature flags which turn on/off
20392049
# externally visible features should not be flipped.
20402050
self.uninteresting_flags: list[str] = [
2051+
# Read once at environmentd startup, so an ALTER SYSTEM SET only
2052+
# takes effect after a restart. Flipping it here would be a no-op
2053+
# for the running process.
2054+
"enable_adapter_frontend_occ_read_then_write",
20412055
"enable_compute_half_join2",
20422056
"enable_mz_join_core",
20432057
"enable_compute_correction_v2",

misc/python/materialize/parallel_workload/database.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -899,11 +899,24 @@ def __str__(self) -> str:
899899
# end-of-run check is guaranteed to find the table.
900900
READ_THEN_WRITE_COUNTER_NAME = "materialize.public.pw_rtw_counter"
901901

902+
# The frontend read-then-write path gives up after its OCC retry budget when a
903+
# statement keeps losing the race for the write timestamp. That is a
904+
# user-visible consequence of contention, not a bug, so every action whose
905+
# statement is a read-then-write (DELETE, UPDATE, INSERT ... SELECT,
906+
# INSERT ... RETURNING) has to tolerate it. It is still counted in the error
907+
# statistics.
908+
OCC_CONTENTION_EXHAUSTED_ERROR = (
909+
"read-then-write exceeded maximum retry attempts under contention"
910+
)
911+
902912
# Error texts that prove an increment did not land.
903913
#
904-
# A concurrently modified dependency is what the coordinator reports when it
905-
# revalidates a plan before sequencing it, which is before any write. The other
906-
# is a cluster-resolution failure during planning, which the workload provokes
914+
# An exhausted retry budget is checked right after an attempt the group
915+
# committer rejected, so nothing was appended. A concurrently modified
916+
# dependency is what the coordinator reports when it revalidates a plan before
917+
# sequencing it, and what the frontend path reports for a changed write target,
918+
# both before any write. The last is a cluster-resolution failure during
919+
# planning, which the workload provokes
907920
# on purpose by pointing the default cluster at a nonexistent one, so the
908921
# statement never reaches a write path at all. The trailing quote keeps it from
909922
# matching "unknown cluster replica size" errors.
@@ -912,6 +925,7 @@ def __str__(self) -> str:
912925
# included, because either can race a commit that did happen. A wrong entry here
913926
# makes healthy runs fail, an unnecessary unknown only widens the upper bound.
914927
DEFINITELY_NOT_COMMITTED_ERRORS = (
928+
OCC_CONTENTION_EXHAUSTED_ERROR,
915929
"was concurrently modified",
916930
"unknown cluster '",
917931
)

src/adapter-types/src/dyncfgs.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,13 @@ pub const DEFAULT_HYDRATION_BURST_LINGER: Config<Duration> = Config::new(
422422
"The burst-replica linger duration written when an AUTO SCALING STRATEGY omits LINGER DURATION.",
423423
);
424424

425+
pub const FRONTEND_READ_THEN_WRITE: Config<bool> = Config::new(
426+
"enable_adapter_frontend_occ_read_then_write",
427+
false,
428+
"Use frontend sequencing (with optimistic concurrency control) for \
429+
DELETE, UPDATE, and INSERT operations.",
430+
);
431+
425432
/// Adds the full set of all adapter `Config`s.
426433
pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
427434
configs
@@ -474,4 +481,5 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
474481
.add(&CATALOG_INFO_METRICS_RECONCILE_INTERVAL)
475482
.add(&PG_TIMESTAMP_ORACLE_STATEMENT_TIMEOUT)
476483
.add(&ENABLE_SCOPED_SYSTEM_PARAMETERS)
484+
.add(&FRONTEND_READ_THEN_WRITE)
477485
}

src/adapter/src/catalog/open.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,8 +317,9 @@ impl Catalog {
317317
// `SystemConfiguration` values applied via `pre_item_updates` live in
318318
// the `SystemVars` value map by now. Mirror their effective values into
319319
// the dyncfg `ConfigSet`, so that startup-only reads observe configured
320-
// values rather than compile-time defaults, `ENABLE_EXPRESSION_CACHE`
321-
// just below being one of them. `apply_updates` only
320+
// values rather than compile-time defaults. Those reads are the
321+
// `ENABLE_EXPRESSION_CACHE` read just below and
322+
// `FRONTEND_READ_THEN_WRITE` in coord bootstrap. `apply_updates` only
322323
// syncs the `ConfigSet` when a `SystemConfiguration` update is present,
323324
// and a deployment configured purely via `system_parameter_default` has
324325
// none, so sync explicitly here.

0 commit comments

Comments
 (0)