doc: design durable subscribe - #38468
Draft
antiguru wants to merge 4 commits into
Draft
Conversation
A `SUBSCRIBE` cannot be resumed today. A client that loses its connection re-runs the subscribe and processes a fresh snapshot before it sees a new update. `AS OF` already expresses the resume, but nothing keeps the timestamp readable, and the default compaction window is one second, so a client that remembers a position finds it compacted away. This design proposes a durable subscription: a named catalog object holding a read hold on a storage collection, which the consumer advances by acknowledging what it has committed. The hold defines a window of readable time, the acknowledgement moves its lower edge, and a wall-clock time to live bounds how long the window stays open without progress. Two decisions carry most of the weight. The mechanism is a read hold rather than a read policy, because `ReadPolicy::Multiple` has no construction sites, policy installation only ratchets capabilities upward, and an ordinary `ALTER ... RETAIN HISTORY` would discard the contribution silently. And the subscription's hold is the attached dataflow's input floor rather than a second hold, because the compute controller never relaxes a sink's input hold, so a continuously connected reader would otherwise pin history from its attach point forever. The user documentation is included. It documents a feature that does not exist yet, and was written as part of the design to expose awkwardness in the SQL surface. It earned its place: writing it is what surfaced that progress messages are mandatory rather than optional, that the time to live must be required rather than defaulted, that retention is the minimum over readers, and that at-least-once delivery is a step back from what the existing manual pattern achieves for consumers with a transactional sink. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository's documentation review surfaced four decisions that the design had left to the docs, plus a set of contradictions between them. Resolving them changed the SQL surface. `ACKNOWLEDGE ... AT <t>` becomes `ACKNOWLEDGE ... UP TO <t>`. The statement always meant "everything strictly before this timestamp is processed", which is exactly the convention `UP TO` already carries on `SUBSCRIBE`. Spelling it `AT` read as inclusive while meaning exclusive, and needed a paragraph plus a warning box to keep straight. Reusing `UP TO` also makes the batch pattern symmetric: read up to a timestamp, acknowledge up to the same timestamp. `WITH (TTL = <interval>)` becomes `WITH (ACKNOWLEDGE WITHIN <interval>)`, mirroring the adjacent `RETAIN HISTORY FOR '1h'` rather than introducing an acronym. It also names the obligation accurately, now that the bound is wall-clock time since the last acknowledgement rather than a retention distance. `ENVELOPE UPSERT`, `ENVELOPE DEBEZIUM`, and `AS OF AT LEAST` are rejected on the durable form. The envelopes cannot be produced correctly when resuming without a snapshot, since neither the sink nor the server holds the prior value for a key the resumed stream has not seen, and because `SNAPSHOT` defaults to `false` here, that would have been the default combination. The shipped pattern page is retitled from "Durable subscriptions" to "Resuming subscriptions", freeing the name for the object and matching what the page now covers, which is both approaches. Also resolved: acknowledging at or below the current position is accepted as a no-op rather than rejected, so a client retrying after an ambiguous failure need not distinguish "already applied" from "too low"; `ALTER ... SET (ACKNOWLEDGE WITHIN ...)` and `ALTER ... OWNER TO` are specified rather than appearing only in the documentation; and the starting position of a new subscription is stated where a reader creating one will find it. The documentation gains the conventions it was missing: a `## Privileges` section with a headless include on each new page, a private-preview marker, and a rendered label for `ACKNOWLEDGE` in the command index, which previously carried only a label that no section renders and so appeared nowhere. The shared `SUBSCRIBE` reference now documents that `AS OF` is an exclusive lower bound under `SNAPSHOT false`, which three pages depend on and which was previously stated only as inclusive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed the design against what a local-first sync engine would need of it, reading Zero, ElectricSQL, PowerSync, and Replicache documentation. The verdict is that a PowerSync-shaped engine could consume the API with changes, and Zero could not without work outside this design, because Zero's upstream must also host its own mutation bookkeeping tables and Materialize supports neither primary keys nor `UPDATE` inside transactions. The review inverted one conclusion. Rejecting `ENVELOPE UPSERT` and `ENVELOPE DEBEZIUM` looked like it excluded the natural keyed consumer, but every engine surveyed keeps its own previous state rather than asking the upstream for old values, and the raw diff stream gives them more than a Postgres feed does: every retraction carries the full old row, which is `REPLICA IDENTITY FULL` semantics by construction, and `WITHIN TIMESTAMP ORDER BY mz_diff` orders retractions before additions within a timestamp. The design now says so, since as written it read like the consumer had been ruled out. Corrects the idempotence rule, which was wrong. It said to deduplicate on the timestamp and row, which contradicts the same paragraph's warning that a resumed interval may consolidate differently. The unit of idempotence is the timestamp: apply each atomically, record it, and skip timestamps already applied. Resume always lands on a timestamp boundary, so re-delivery is always of whole timestamps. Adds the argument the design was missing, that a timestamp is a better resume token than a log position. A timestamp is a global commit order, so one value identifies a consistent cut across every collection in the timeline, whereas log positions of concurrent transactions interleave and need transaction boundaries plus synthetic watermarks to recover the same order. Also: the acknowledgement deadline ceiling should be in hours rather than minutes, and a long environment outage should pause the deadline rather than consume it; expiry gains in-band consent via `WITH (RESET IF EXPIRED)` so a consumer can recover in its reconnect path instead of issuing privileged DDL; and four gaps are recorded as quirks, namely that combining subscriptions is safe but unassisted, that timelines are incomparable, that no mapping exists from an upstream position to a Materialize timestamp, and that the WebSocket transport has no batch flow control. Restores the Testing section, which was lost in an earlier wholesale rewrite, and drops its self-refuting justification for the restart check: CI runs new feature flags on, so such a check cannot catch a flag-off boot failure. The restart check earns its place by proving the hold is re-acquired from durable state where a policy would silently ratchet away. `ALTER TABLE ... ADD COLUMN` is gated behind a flag that defaults off and is undocumented, so the expiry-on-schema-change behavior is demoted from a designed behavior to a forward-looking note rather than being documented for a statement users cannot run. No key clause is added. An unenforced `KEY` would be a comment with SQL syntax, since Materialize cannot detect a violation, so the documentation states the uniqueness obligation instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`WITH (RESET IF EXPIRED)` was underspecified: it promised to reset an expired subscription and "tell the client it did so" without saying through what channel. There is no channel. A snapshot arrives as a batch of insertions at the resume timestamp, indistinguishable from genuine insertions at that time, so a consumer receiving one cannot tell a fresh snapshot from a continuation. Signalling it otherwise would require either a new column on every `SUBSCRIBE` or a session notice, which is advisory and routinely ignored. Replace it with two attaches. A plain attach on an expired subscription fails with a distinguishable error, so a client can branch on it, and `SUBSCRIBE ... WITH (RESET)` resets to the current time and delivers a snapshot there. The client then knows what it received because it asked, having branched on the error. This keeps the property the in-band path existed for, which is recovery without a privileged data-definition statement, and costs one extra round trip only on the rare expiry path. The opening progress message is documented as a cross-check rather than the mechanism. `SUBSCRIBE`'s first update is guaranteed to be a progress message carrying the as-of, so a client that kept its own last acknowledged position can compare and detect a gap. It cannot carry the signal alone, because a client that relies on the server to hold its position is exactly the one that may not have kept a copy. Resolving this exposed a second gap. `WITH (RESET)` errors on a subscription that has not expired, and so does `ALTER ... RESET`, which means the "start over" mode was only ever reachable for an expired subscription. The documentation described it as the answer for any client that lost its local state. A live subscription deliberately cannot be fast-forwarded, since that would discard data it still holds history for and fence a reader that has not failed, so such a client reconciles at its acknowledged position instead and pays a replay bounded by `ACKNOWLEDGE WITHIN`. That bound is what makes refusing the fast-forward affordable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design document for a resumable
SUBSCRIBE, plus the user documentation written alongside it.A
SUBSCRIBEcannot be resumed today.AS OFalready expresses the resume, but nothing holds the timestamp readable, and the default compaction window is one second, so a client that remembers a position finds it compacted away. Our own console shows the cost: it opens one WebSocket per subscribe and re-runs from scratch on reconnect, holding a stale snapshot behind aresubscribingflag until a new snapshot completes.The proposal is a durable subscription: a named catalog object holding a read hold on a storage collection, advanced by an
ACKNOWLEDGEstatement. The hold defines a window of readable time, the acknowledgement moves its lower edge, and a wall-clock deadline bounds how long the window stays open without progress.Core decisions
ReadPolicy::Multiplehas zero construction sites, policy installation only ratchets capabilities upward so a lower frontier installed behind an advanced one is a silent no-op, the installation API is keyed byCompactionWindowand cannot express a per-collection constant, and an ordinaryALTER ... RETAIN HISTORYwould discard the contribution irreversibly. Holds report the frontier actually acquired; policies report nothing.REFRESHmaterialized view would otherwise need a value aware of every refresh policy in its ancestry, and a stalled source, paused source, or zero-replica cluster freezes the frontier precisely when releasing the hold matters most.Surface
ACKNOWLEDGE ... UP TOreuses the one exclusive-upper conventionSUBSCRIBEalready has, rather than adding a third;ATread as inclusive while meaning exclusive.ACKNOWLEDGE WITHINmirrors the adjacentRETAIN HISTORY FOR '1h'and names the obligation accurately now that the bound is wall-clock.SNAPSHOTdefaults tofalsehere, inverting plainSUBSCRIBE, and a requested snapshot is taken at the acknowledged position so the state received corresponds to a position the client can name.ENVELOPE UPSERT,ENVELOPE DEBEZIUM, andAS OF AT LEASTare rejected.Recovery from expiry is two attaches, not one option. A plain attach on an expired subscription fails with a distinguishable error, and
SUBSCRIBE ... WITH (RESET)resets to the current time and delivers a snapshot there. The reason is that a consumer otherwise cannot tell a fresh snapshot from a continuation: a snapshot arrives as insertions at the resume timestamp, indistinguishable from genuine insertions at that time, and the alternatives are a new column on everySUBSCRIBEor an advisory session notice. With two attaches the client knows because it asked. A live subscription deliberately cannot be fast-forwarded by either spelling ofRESET, since that would discard data it still holds history for; a client that lost local state while live reconciles instead, paying a replay bounded byACKNOWLEDGE WITHIN.Reviews folded in
The document has been through an adversarial design review and the repository's documentation review, and was then evaluated against what a local-first sync engine would require of it, reading Zero, ElectricSQL, PowerSync, and Replicache documentation.
That last review inverted one conclusion worth calling out. Rejecting the envelopes looked like it excluded the natural keyed consumer, but every engine surveyed keeps its own previous state rather than asking the upstream for old values, and the raw diff stream gives them more than a Postgres feed does: every retraction carries the full old row, which is
REPLICA IDENTITY FULLsemantics by construction, andWITHIN TIMESTAMP ORDER BY mz_difforders retractions before additions within a timestamp. It also corrected the idempotence rule, which had said to deduplicate on the timestamp and row. The unit is the timestamp: apply each atomically, record it, skip ones already applied, which works because resume always lands on a timestamp boundary.Verdict on consumability: a PowerSync-shaped engine could consume this with changes. Zero could not without work outside this design, because its upstream must also host its own mutation bookkeeping tables, with
lastMutationIDincremented in the same transaction as the user's write and replicated back through the feed, and Materialize supports neither primary keys norUPDATEinside transactions.A "Known quirks and interactions" section documents rather than fixes several existing behaviors the feature makes easier to trigger, including that a subscription's hold lowers the as-of chosen for objects created later over the same inputs, which for materialized views is written durably into
create_sql; that combining several subscriptions into a consistent cut is safe but unassisted, and unsound across timelines; and that no mapping exists from an upstream source position to the Materialize timestamp at which it became visible, which is a hard dependency for any consumer that needs to correlate its own writes with the feed.On the user documentation
The pages under
doc/user/document a feature that does not exist. They were written as part of designing it, to expose awkwardness in the SQL surface, and they earned the place. Writing them is what surfaced that progress messages are mandatory rather than optional, that the deadline must be required rather than defaulted, that retention is the minimum over readers so one stuck reader holds history for all of them, that at-least-once delivery is a genuine step back from what the existing manual pattern achieves for consumers with a transactional sink, and that the reset path had no way to tell a consumer what it had received.They must not merge before the feature exists. Hugo would publish them. They are here for review of the contract, and the intent is to land them with the implementation. Happy to move them under
doc/developer/design/as an appendix if reviewers prefer.doc/user/content/transform-data/patterns/durable-subscriptions.mdis a shipped private-preview page that was titled "Durable subscriptions", documenting the manualRETAIN HISTORYplus client-recorded-timestamp pattern. This renames it to "Resuming subscriptions" to free the name. The manual pattern stays documented, because recording the position yourself is what makes exactly-once possible.Open questions
Seven are listed in the document. The two most likely to change the shape of the implementation are whether the durable attach can bypass index import cleanly, and the value of the deadline ceiling, which should be in hours rather than the minutes an interactive client needs.
No tracking issue is filed yet; one is needed before this merges.
🤖 Generated with Claude Code