Skip to content

doc: design durable subscribe - #38468

Draft
antiguru wants to merge 4 commits into
MaterializeInc:mainfrom
antiguru:durable-subscribe-design
Draft

doc: design durable subscribe#38468
antiguru wants to merge 4 commits into
MaterializeInc:mainfrom
antiguru:durable-subscribe-design

Conversation

@antiguru

@antiguru antiguru commented Aug 25, 2026

Copy link
Copy Markdown
Member

Design document for a resumable SUBSCRIBE, plus the user documentation written alongside it.

A SUBSCRIBE cannot be resumed today. AS OF already 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 a resubscribing flag 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 ACKNOWLEDGE statement. 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

  • A read hold, not a read policy. ReadPolicy::Multiple has 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 by CompactionWindow and cannot express a per-collection constant, and an ordinary ALTER ... RETAIN HISTORY would discard the contribution irreversibly. Holds report the frontier actually acquired; policies report nothing.
  • One hold, not two. The compute controller never relaxes a sink's input hold, so a continuously connected reader would pin history from its attach point forever and the deadline would never notice. The subscription's hold is therefore the attached dataflow's input floor.
  • A wall-clock deadline, not a frontier distance. A REFRESH materialized 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.
  • A timestamp is a better resume token than a log position. One timestamp names a consistent cut across every collection in the timeline. Log positions of concurrent non-conflicting transactions interleave, so systems built on them need transaction boundaries and synthetic pre-commit watermarks to recover the order a timestamp gives for free.

Surface

CREATE DURABLE SUBSCRIPTION s ON t WITH (ACKNOWLEDGE WITHIN '1m');
SUBSCRIBE USING DURABLE SUBSCRIPTION s WITH (PROGRESS);
ACKNOWLEDGE DURABLE SUBSCRIPTION s UP TO 1723459200000;

ACKNOWLEDGE ... UP TO reuses the one exclusive-upper convention SUBSCRIBE already has, rather than adding a third; AT read as inclusive while meaning exclusive. ACKNOWLEDGE WITHIN mirrors the adjacent RETAIN HISTORY FOR '1h' and names the obligation accurately now that the bound is wall-clock. SNAPSHOT defaults to false here, inverting plain SUBSCRIBE, 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, and AS OF AT LEAST are 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 every SUBSCRIBE or an advisory session notice. With two attaches the client knows because it asked. A live subscription deliberately cannot be fast-forwarded by either spelling of RESET, since that would discard data it still holds history for; a client that lost local state while live reconciles instead, paying a replay bounded by ACKNOWLEDGE 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 FULL semantics by construction, and WITHIN TIMESTAMP ORDER BY mz_diff orders 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 lastMutationID incremented in the same transaction as the user's write and replicated back through the feed, and Materialize supports neither primary keys nor UPDATE inside 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.md is a shipped private-preview page that was titled "Durable subscriptions", documenting the manual RETAIN HISTORY plus 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

antiguru and others added 4 commits August 25, 2026 20:13
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant