Skip to main content

Module frontend_read_then_write

Module frontend_read_then_write 

Source
Expand description

Frontend sequencing for read-then-write operations.

This module implements INSERT […] SELECT FROM […], DELETE and UPDATE operations using a subscribe with optimistic concurrency control (OCC), sequenced from the session task rather than the Coordinator.

The motivation is correctness with concurrent writers, including writers in different environmentd processes, which the coordinator’s in-process write locks cannot provide. The OCC path also fixes a serializability defect of the lock path, which reads at one timestamp and commits at a later one while locking only the selection’s direct dependency items. See the design doc, doc/developer/design/20260210_incremental_occ_read_then_write.md, and the comment on the retry arm in run_occ_loop. Relieving the coordinator loop is a side benefit, and only sequencing moves off it. The subscribe’s data path still runs through the coordinator.

§Whether the write reads persisted state

Two predicates answer that one question, and they have to agree. Before anything runs, SessionClient::try_frontend_read_then_write decides it syntactically, from depends_on() on the planned selection, because inside a transaction a read-dependent write has to be refused while refusing is still possible. Once the dataflow runs, the subscribe answers it dynamically: the channel closes on its own only once the sink’s output frontier reaches the empty antichain, which means the selection can never change again.

Reading nothing persisted is the common case for a clean close, not the guarantee. An input whose frontier seals closes cleanly too, despite reading persisted state, for example a REFRESH AT materialized view past its last refresh, whose write frontier advances to the empty antichain. What holds in either case is the property the write side actually needs: past the close, the consolidated diffs are frontier-independent. The syntactic predicate is correspondingly stricter than the dynamic one, since it refuses a sealed-MV INSERT ... SELECT in a transaction that would technically be bufferable.

The two answers are used for different things, and that separation matters.

The syntactic answer decides whether the statement can belong to a transaction. A selection that reads nothing produces diffs that are valid at any timestamp, so they are staged as session write ops and land when the transaction commits, which is what makes the statement atomic with whatever surrounds it. A selection that reads persisted state cannot belong to a transaction, and we refuse it in an explicit one. An extended-protocol pipeline is an implicit transaction, so it must not quietly join one either: it ends its own transaction instead of spanning the rest of the pipeline, which is how PostgreSQL treats statements that cannot run in a transaction block. It is durable once it reports success, and a later failure in the pipeline does not undo it.

The dynamic answer only decides how the write is submitted, a timestamped write from inside the loop or a blind submission after it. It cannot decide transaction membership, because it is a property of the inputs rather than of the statement. A sealed input closes the subscribe cleanly, so an INSERT ... SELECT over a REFRESH AT materialized view past its last refresh takes the blind exit while still reading persisted state. Its diffs really are frontier-independent and staging them would be safe, and we still do not stage them. Otherwise whether a statement’s rows survive a failure later in the pipeline would depend on whether one of its inputs happened to pass its last refresh, which no one reading the statement could predict.

Staging is also what earns the right to span a pipeline in the first place. TransactionStatus::may_span_pipeline lets an implicit transaction stay open only for writes, precisely because they are merely staged. A statement that committed on its own has no business claiming it.

Disagreement is caught on both sides, and only one side can still refuse. frontend_read_then_write re-checks the syntactic predicate before running a dataflow, which catches a caller that skipped the gate. If the syntactic predicate were laxer than the dynamic one, that check would pass and a write meant for staging would commit on its own, so the loop asserts wherever the two answers can be compared. The Committed arm catches a write timestamp for a statement we meant to stage, and the zero-row arm catches a read timestamp for one. Neither can undo anything by then, the write is already durable in the first case and there was never anything to write in the second, so all they do is make the disagreement loud.

§The frontier certifies, the oracle chooses

The target T comes from the oracle, and a progress message at F only certifies completeness below F, so it gates the write rather than choosing its timestamp. The design doc’s “The OCC loop” says why. Three invariants hold for every write this path makes:

  • F >= T before it submits, so the payload is a complete view of T - 1.
  • The payload is every diff below T, strictly. A diff at T is concurrent with the write and waits for a later target.
  • T > as_of, so the snapshot, which arrives at as_of, is in the payload.

NOTE: F >= T does not make the two equal, because F is a minimum over the selection’s inputs. Where F runs above T, a selection that reads the target table makes the compare-and-append refuse, and retrying higher is the design rather than a failure.

§A zero-row answer

A write linearizes itself, because group commit advances the oracle before it acknowledges. An answer of “no rows” performs no write, so the loop reports the timestamp its view is complete through and the caller waits for the oracle to reach it. A selection empty at as_of is complete through as_of, which the caller put behind the oracle before the subscribe started, so that answer waits for nothing, while reporting the frontier the subscribe observed would cost a group commit every time. The design doc’s “Linearization” argues why the lower timestamp is not the weaker guarantee.

§Rollout note

The FRONTEND_READ_THEN_WRITE dyncfg is read once at process startup and fixed for the lifetime of the environmentd process. This avoids a mixed-mode window where both the lock-based coordinator path and this OCC path are active concurrently. The coordinator path acquires write locks to prevent concurrent writes between its read and write phases, but this OCC path does not use write locks, so concurrent operation of both paths could allow an OCC write to slip between a coordinator-path reader’s read and write.

Structs§

FrontendWriteAttemptState 🔒
State shared between an in-flight frontend write attempt and its cancellation wrapper, SessionClient::try_frontend_read_then_write_with_cancel.
OccState 🔒
Accumulated state for the OCC loop in run_occ_loop.
SubscribeHandle 🔒
A handle to an internal subscribe, meaning one that writes no mz_subscriptions row. A Drop impl ensures the subscribe’s dataflow is cleaned up when dropped.
ValidationResult 🔒
Result of validating a read-then-write operation.

Enums§

FrontendWriteCancellation 🔒
Reason a frontend write attempt is being torn down early.
OccOutcome 🔒
What the OCC loop produced.
ProcessResult 🔒
Result of processing a single subscribe message in the OCC loop.
WriteOutcome 🔒
What the coordinator’s answer to a submitted write means for the statement.

Functions§

apply_mutation_to_mir 🔒
Transform a MIR expression to produce the appropriate diffs for a mutation.
build_no_rows_response 🔒
Build the response returned when no rows matched the selection.
build_success_response 🔒
Builds the response for a write that is about to be submitted.
classify_write_result 🔒
Maps a WriteResult to the outcome the statement reports, or to the one conflict the OCC loop can retry.
contains_mz_now 🔒
Whether a read-then-write mentions mz_now() anywhere.
empty_as_of 🔒
The timestamp an answer holds as of, given a view complete strictly below complete_below.
end_own_transaction 🔒
Ends the implicit transaction that a statement which cannot run in a transaction block opened for itself.
governing_timeline 🔒
The timeline whose oracle governs a read-then-write with the given read-side TimelineContext.
process_message 🔒
Process one subscribe message, updating state in place.
validate_read_then_write 🔒
Validates a read-then-write and resolves the context the rest of the pipeline runs against.
validate_selection_dependencies 🔒
Checks that a read-then-write may read what its selection depends on.