Skip to content

compute: delay retracting a dropped export's lifecycle rows [deferred, unsound] - #38417

Closed
antiguru wants to merge 17 commits into
claude/hydration-visibility-compute-js1ycmfrom
claude/lifecycle-retraction-delay-js1ycm
Closed

compute: delay retracting a dropped export's lifecycle rows [deferred, unsound]#38417
antiguru wants to merge 17 commits into
claude/hydration-visibility-compute-js1ycmfrom
claude/lifecycle-retraction-delay-js1ycm

Conversation

@antiguru

@antiguru antiguru commented Aug 22, 2026

Copy link
Copy Markdown
Member

Deferred. Not ready, and not close to ready. Do not pick this up without reading the blocker below — the two-dyncfg design in this PR does not survive it.

Blocker: delayed retraction is unsound while ids can be reused

The delay retracts an export's rows at a future timestamp while removing its ExportState immediately. Nothing records that a retraction is pending, so a new export with the same GlobalId inside the window inserts a fresh row and the relation ends up holding two rows with the same (export_id, worker_id, event) from different incarnations. That breaks the at-most-once property the relation documents and that test/testdrive/compute-lifecycle-events.td asserts under max-tries=1, and it corrupts any duration a reader computes with max(occurred_at) FILTER (...).

Transient ids are reused because TransientIdGen::new() starts at 1 per process (src/repr/src/global_id.rs:123-129), so a plain environmentd restart — replicas survive and reconcile, transient dataflows are dropped, new generation reissues t1… — collides inside the window.

Non-transient ids can also be reused during reconciliation, so the window is not a transient-only problem. That kills the obvious fix: excluding transient exports from the delay does not make this sound, and the split between compute_lifecycle_retraction_delay and compute_lifecycle_retraction_delay_transient is not the axis that matters. Any future attempt needs to make retraction and re-insertion of the same id ordered — for example a pending_retractions map flushed at the current timestamp when handle_export sees an id whose retraction is still outstanding — before the delay is worth having at all.

Note the base PR is unaffected: it retracts at the drop timestamp, so no window exists in which two incarnations of an id coexist.

Two further findings, unaddressed

  • An unclamped compute_lifecycle_retraction_delay panics the replica in ts_at's expect("must fit"). There is a lower clamp to the logging interval but no upper one, and ALTER SYSTEM SET reaches it.
  • The delay makes the relation carry rows whose export_id is absent from mz_compute_exports_per_worker for up to the delay. The ontology description was written before dropped existed and still implies a lifecycle row means a live export, and the design doc and the testdrive orphan check disagree about whether an orphaned row is expected.

Original motivation

The lifecycle log retracts an export's rows the moment it is dropped, so an object's history vanishes with the object. That loses exactly the episodes worth looking at: a dataflow dropped before it hydrated, or one whose hydration is the reason someone opened the log. A short-lived dataflow can also come and go inside one introspection interval and leave no trace.

Part of CPU-226.

What is in the diff

Two commits on top of #38403: the retraction delay with a dropped stage and two dyncfgs, and an unrelated design-doc correction about catalog_server_explain.slt.

The dropped stage is the part worth keeping from this attempt. Without it a lingering row says only that an export reached some stage, not whether it still exists, so "hydrated but never written" cannot be told apart from "dropped before it wrote".

The storage reasoning also stands independently of the soundness problem: a transient export exists per peek and per subscribe, so at eight workers and two hundred peeks per second a five-minute window holds around 1.4 million rows, hundreds of megabytes, while a user object is dropped by DDL and a thousand drops inside the window is under ten megabytes. Whatever replaces this will still need to treat those two populations differently, just not by delay alone.

Record each compute export's lifecycle as an append-only log,
`mz_introspection.mz_compute_lifecycle_events_per_worker`, rather than as more
timestamp columns on the hydration time relation.

Two things stop timestamp columns from carrying the lifecycle. The stages do not
share a grain: `installed`, `started` and `hydrated` are per-worker facts, since
each worker hydrates its own fragment of the dataflow, while whether the output
is durable is a property of the sink as a whole, maintained on one elected
worker. And a timestamp cannot say why the next stage has not happened, so a
NULL cannot tell a replacement materialized view waiting for a cutover apart
from an index that will never write.

    export_id    text        not null
    worker_id    uint8       not null
    event        text        not null
    occurred_at  timestamptz not null
    reason       text        nullable
    details      jsonb       nullable

`installed`, `started` and `hydrated` are logged by every worker. The write
stages are logged only by the worker that maintains the sink's shared write
frontier, so they appear once per object and the row records which worker was
elected. An index emits the first three and stops, which is the index degeneracy
of the lifecycle falling out of the model rather than being special-cased.

`hydrated` reads the dataflow's own progress frontier, the compute probe, not
the reported output frontier. The output frontier folds in the write frontier,
which makes it a measure of durability, and for a sink-backed collection it is
not even uniform across workers: `mint` clears the shared frontier on every
non-elected worker, where it is the empty antichain and contributes nothing to
the meet. `mz_compute_hydration_times_per_worker` is unchanged, so `hydrated_at`
and `time_ns` keep reporting exactly what they reported before, and the new
relation carries the dataflow reading alongside.

The write stages are gated on hydration. Before it the sink has produced nothing
and read-only mode is holding nothing back, and every collection starts
read-only, so reporting a block from installation would put a `write_blocked`
and a `write_unblocked` on essentially every materialized view, both ahead of
`hydrated`. Gating also keeps `written` ordered after `hydrated`, which it is not
otherwise: `apply_refresh` rounds a `REFRESH` materialized view's frontier up to
the next refresh time off its input frontier, before the dataflow computes
anything, so the sink writes an empty batch for the pre-refresh window and the
shard's upper passes the as-of while the dataflow is still hydrating. That also
means a refresh schedule advances writing rather than blocking it, so there is no
`refresh` cause for `write_blocked` to report.

`details` carries the dataflow's as-of, which every stage is defined relative to:
without it the interval between two stages says nothing about how much work was
done, since a replacement materialized view with a far behind as-of is a
completely different amount of work at the same duration.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
@linear-code

linear-code Bot commented Aug 22, 2026

Copy link
Copy Markdown

CPU-226

claude added 10 commits August 24, 2026 08:59
`observe_hydration` took a bool, leaving the choice of frontier and the handling
of an empty one to its two call sites. Those call sites had already diverged.
`report_frontiers` compares the as-of against a probe or write frontier with no
emptiness check; `process_subscribes` compares it against a batch upper and
guards with `matches!(response, SubscribeResponse::Batch(_))`, whose comment
claims that filtering out `DroppedAt` is enough to stop a cancelled subscribe
from reading as hydrated.

It is not enough. A subscribe signals completion by sending a batch at the empty
frontier, so the completion batch passes the `matches!` and the empty antichain
is the maximum of the order. `SUBSCRIBE ... UP TO x AS OF x` is legal, only
attaching an `EqualSubscribeBounds` notice, and it emits no rows at all: its
`up_to` filter admits no times. It then logs `hydrated` for a dataflow that
computed nothing.

Take the frontier itself and decide inside, so there is one definition of what
counts as progress. An empty frontier reports completion, not hydration, whether
it arrives as `DroppedAt`, as a completion batch, or from anywhere else. A
subscribe that genuinely hydrated has already logged it from the preceding batch,
so nothing is lost.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
`written` compared the as-of against the sink's write frontier. That frontier is
the output shard's upper, a property of the shard rather than of this replica,
and `as_of_selection::apply_downstream_storage_constraints` bounds the as-of to
one step below the upper for a non-empty storage export. So for any shard that
already holds data the comparison is true from the moment the dataflow is
installed, and the stage says only that somebody once wrote the output.

The read-only gate did not fix this, it deferred it by one poll. A replica that
is permitted to write is not thereby the replica that wrote. Three cases got a
`written` for appending nothing:

  * a replica added by raising the replication factor,
  * a replica whose process restarted,
  * a read-only replica at cutover, where `write_unblocked` and `written` land in
    the same call and the interval between them is always zero.

The third is the case the relation exists to measure, and the previous NOTE at
this site claimed the guard prevented exactly what it permitted.

Latch the frontier the first time writes are permitted and require it to advance
past that baseline. The latch is deliberately before the hydration gate rather
than after it: latching later would fold this replica's own early writes into the
baseline and never report them, while latching before the block is lifted would
measure against an upper the previous writer goes on to advance.

`written` now means that this replica's sink advanced the shard beyond where it
stood when the replica was allowed to write. For a fresh materialized view, whose
as-of is not stepped back, that is the first real append, unchanged from before.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
The relation promises at most one row per export, worker and stage, and a reader
relies on it: an event's `occurred_at` is taken directly, without aggregating
first. That promise was kept by three unrelated mechanisms, none of them in the
demux that owns the log. `CollectionState::logged_stages` covered four stages,
`hydration_timestamps.started_at` covered `started`, and `installed` relied on
`handle_export` running once per export.

`log_lifecycle_at` pushed unconditionally, and `CollectionLogging::log_lifecycle`
is public and accepts any stage, including the two the demux already emits from
its own events. A caller passing either produced a duplicate row that nothing
rejected. The doc comment stated the contract, the type did not.

Key the retained rows by stage and ignore a stage already logged. The bound on
the map is now structural rather than incidental, the back-fill of `started` in
`handle_hydration` no longer needs to know whether `handle_hydration_start`
already ran, and a caller that cannot cheaply tell whether it has reported a
stage does not have to.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
Three cleanups to the boundary the write lifecycle stages depend on.

`frontier_owner` lived in the v1 materialized view sink and was named for a
property v1 does not own. The election belongs to the persist sink protocol that
both implementations share, so it moves to `crate::sink`, and all four call sites
now spell it the same way. `materialized_view_v2` had been reaching for it twice
in one file under two different paths.

`metric_sink` carries a byte-identical expression and must keep it. Its shared
frontier is written by every worker, before the early return for the inactive
ones, so it has no elected owner at all, and the worker it does elect is the one
that folds metrics into the registry. The obvious next cleanup would be to call
`frontier_owner` there and silently couple two independent elections, so both
sites now say why they are separate.

`sink_write_frontier` and `owns_sink_frontier` were two public fields whose
coupling lived only in a doc comment. Three sinks set the frontier and two set
the flag. A future sink that sets the frontier and forgets the flag loses every
write stage with no error, and one that sets it wrongly reports having written
everything immediately, because a non-owning worker's copy is cleared to the
empty antichain. One setter takes both, and the fields are now private.

Also correct the `ExportState::as_of` comment: a dataflow retained across
reconciliation logs no new `Export` event, so the field holds the as-of it was
installed with rather than the collection's current one.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
A dataflow's exports share one suspension token, so its computation begins only
once every export has been scheduled. `handle_schedule` says so in a comment and
then reports hydration start for the single export the command named.

For a dataflow exporting A and B, scheduled at t1 and t2, the dataflow starts at
t2 but A's `started` reads t1. A claims to have started while its dataflow was
still suspended, and `hydrated - started` overstates the compute time by t2 - t1.

Report the start only on the token release that actually unsuspends the dataflow,
and report it for every export of that dataflow. The start is a property of the
dataflow, so all its exports share the instant, and now they share it because
that is when computation began rather than by approximation.

This is latent today: nothing in production ships more than one export per
dataflow, since `export_index` and `export_sink` each insert one. It affects
`mz_compute_hydration_times_per_worker.started_at` equally.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
Rejecting the empty frontier in `observe_hydration` was wrong. An inputless
collection, such as an index or materialized view on `SELECT 1`, emits its rows
and drops its capability, so its frontier goes from the initial time straight to
the empty antichain without ever holding a non-empty value beyond its as-of.
Rejecting empty leaves such an export permanently unhydrated in the lifecycle
relation, and because the write stages are gated on `hydrated`, a materialized
view of that shape reports no write stage ever.

It also contradicted the durability reading. `CollectionState::hydrated` compares
the as-of against the reported output frontier with no emptiness check, so the
existing relation calls these exports hydrated. The two relations would have
disagreed for the same object.

Emptiness therefore cannot distinguish completion from cancellation, and a caller
that can observe a dataflow ending without computing its as-of has to exclude
that itself. `process_subscribes` is the one such caller: `DroppedAt` carries the
empty antichain for a subscribe cancelled mid-hydration, so it keeps the batch
check that excludes it. A subscribe that runs to its `up_to` also finishes with an
empty upper, but it carries that in a batch and did compute through its as-of.

The frontier-taking signature stays. Passing the frontier rather than a bool is
what gives the emptiness question a single answer instead of one per call site.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
The latched baseline did not work, for two independent reasons.

It latched a placeholder. `mint` initializes the shared sink frontier to
`Antichain::from_elem(Timestamp::MIN)` and only fills in the real upper once its
`persist_frontiers` stream has read it back. The latch runs from the first
`report_frontiers` poll that sees writes permitted, which is normally before
that, so the baseline was `[MIN]` and any real upper compared greater. `written`
fired at once, which is what the baseline was meant to prevent.

And no reading of that frontier can attribute a write. Every replica's `mint`
reads the shard's upper back from persist into its own shared frontier, so the
frontier advances on every replica when any one of them wins the append. A
baseline latched from it, placeholder or not, says only that the shard moved
while this replica was eligible to write.

Go back to comparing against the as-of and state the semantics instead of
implying stronger ones. `written` means the output is durable through the as-of
and this replica was permitted to write. Replicas of a cluster produce identical
batches and race to append, so which replica won is not an operationally
meaningful question, but it does mean `written` lands with `hydrated` on a
restarted or scaled-out replica and with `write_unblocked` at a cutover, and a
reader has to know that.

Real attribution needs a signal from this replica's own append path. That has to
cross workers, because `next_append_worker` rotates independently of
`sink::frontier_owner`, so it is left as a TODO rather than approximated.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
…nator

A consumer rolling the per-worker relation up per object has to know whether
every worker has reached a stage. `mz_compute_hydration_times_per_worker` lets it
ask without knowing the worker count, because it holds one row per export and
worker from installation with a nullable `time_ns`, so `count(*) = count(time_ns)`
is the test. The introspection subscribe for `ComputeHydrationTimes` uses exactly
that.

An append-only log has no NULLs to count: a worker that has not hydrated has no
`hydrated` row. The equivalent is `installed`, which every worker logs
unconditionally when the export is created, so the count of `installed` events is
the number of workers reporting on that export.

That makes it a contract the relation owes its readers rather than an incidental
property of the implementation, and it was written down nowhere. Say it in the
ontology description, where a reader of the relation will find it, and in the
design doc alongside the grain rules, together with the point that which stages
have which grain is fixed by the vocabulary rather than varying per object or
cluster, so a consumer never needs a catalog join to interpret a count.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
The design doc argued that withholding `written` while writes are blocked keeps
another writer's progress from being attributed to this replica. That is true of a
blocked replica and says nothing about a permitted one, which leaves the reader
with a stronger impression of the stage than it earns.

Every replica's `mint` reads the output shard's upper back from persist into its
own shared frontier, so the frontier advances on every replica when any one of
them wins the append. State that, state the two cases where the implied interval
is therefore zero, `written` landing with `hydrated` on a restarted or scaled-out
replica and with `write_unblocked` at a cutover, and record why the obvious
tightening does not work: a frontier latched when writes are first permitted sees
the same concurrent advance, and the value available at the first observation is
usually the placeholder `mint` starts from rather than a real upper.

Attribution needs a signal from the replica's own append path, which has to cross
workers because `next_append_worker` rotates independently of the frontier owner.
That goes under "Follow-up work" with the open question of whether the
distinction earns the machinery, given that the racing replicas append identical
batches.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
The stages do not all describe the same object. `hydrated` and the write stages
belong to one export: hydration reads that export's own progress, and the write
stages read its sink. `installed` and `started` describe the dataflow, which can
maintain more than one export, and `started` is the instant that dataflow was
unsuspended, shared by every export it maintains.

Keying the relation by export is still right. The identifier a consumer has is the
export id, which is what `mz_objects.id` holds, while dataflow ids are per worker
and internal to a replica. A dataflow-grain relation could not hold hydration or
the write stages at all, so splitting along the seam would produce two relations
instead of one and make the most basic question a join through the
export-to-dataflow map.

Carry the dataflow id as a column instead. The events whose cause is dataflow-wide
become recognizable as such, `SELECT DISTINCT dataflow_id, event, occurred_at`
recovers the dataflow-level facts, and the redundancy is visible rather than
implied. The relation also ends up carrying the export-to-dataflow mapping that
`mz_compute_exports_per_worker` holds, for eight bytes a row.

No new migration step. `make_mz_indexes` inlines each log's `index_by` column
names, so `mz_indexes` moves again, but `MigrationStep::replacement` records only
a version and an object, and the step this change already added at the current dev
version covers any further change to that SQL within the same version.
`make_mz_sources` inlines no columns, so it does not move.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
@antiguru
antiguru force-pushed the claude/lifecycle-retraction-delay-js1ycm branch from 8ea2b6a to 069d6fe Compare August 24, 2026 11:28
`lifecycle_event_in_dataflow` declared `dataflow_id` a many-to-one foreign key
into `dataflow_global_id_per_worker.id`, which it is not.
`ComputeLog::DataflowGlobal` keys that relation on `(id, worker_id, global_id)`,
one row per object rendered in the dataflow, so the advertised join both crosses
workers and multiplies every lifecycle event by the number of rendered objects.
Adding `worker_id` through `extra_key_columns` would not fix it, because the
fan-out over `global_id` remains: the relation is a dataflow-to-object mapping,
not a dataflow entity, and no ontology entity is keyed by `(dataflow_id,
worker_id)` for the link to point at.

This metadata is acted on rather than merely read. `mz_ontology_link_types` is
what agents are pointed at to discover join paths, so a wrong cardinality
produces a query that silently fans out instead of a description that merely
reads oddly.

Remove the link and put the join guidance where it cannot mislead a planner: the
column description now says dataflow ids are worker-scoped, names
`(dataflow_id, worker_id)` against `mz_compute_exports_per_worker` as the way to
reach a dataflow's exports, and warns that the global-ids relation fans out.

The surviving `lifecycle_event_of` link is `MapsTo`, which carries no cardinality
and so claims nothing false, but it joins two per-worker relations on `export_id`
alone. Its `note` now records that the exact join is `(export_id, worker_id)`.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
@antiguru
antiguru force-pushed the claude/lifecycle-retraction-delay-js1ycm branch from 069d6fe to c56501a Compare August 24, 2026 11:38
claude added 2 commits August 24, 2026 11:48
The description claimed dataflow ids are worker-scoped and only meaningful paired
with `worker_id`. They are not. Each worker assigns indices from a counter
advanced by the same command sequence, so a dataflow carries the same index on
every worker of a replica, and `dataflow_id` identifies the dataflow rather than a
per-worker artifact. It is still a replica-local index and not a catalog id, which
is the part that matters for a consumer.

Dropping the `lifecycle_event_in_dataflow` link remains right, for the other
reason given: `mz_compute_dataflow_global_ids_per_worker` holds one row per object
rendered in a dataflow, so `dataflow_id` is not unique there even within a single
worker, and a foreign key claiming otherwise makes a generated join fan out.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
`cluster.slt` lists each per-replica introspection index's key columns with their
positions, so a column added to an unkeyed log shifts every position after it.
`cockroach/srfs.slt` generates a series over each relation's column count, so it
gains a row per instance.

Both were missed when the column was added. The position listing in `cluster.slt`
is a third section of that file, distinct from the two counts already updated
there, and searching the file for the relation's name finds it only if the search
is not anchored on those counts.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
@antiguru
antiguru force-pushed the claude/lifecycle-retraction-delay-js1ycm branch from c56501a to 5981d21 Compare August 24, 2026 12:14
claude added 3 commits August 24, 2026 13:00
Four corrections, none changing what the relation reports.

`sink_write_frontier` was left public. Its own doc comment says it is set through
`set_sink_write_frontier` together with `owns_sink_frontier`, and the point of
that setter is that the pairing cannot be forgotten, which a public field
defeats. `report_frontiers` is the only other reader and is in the same module.

`handle_schedule` scanned every collection to find the dataflow's exports. A
`Schedule` arrives once per dataflow, so a replica starting N dataflows made N
full traversals of the collection map on the timely worker thread, quadratic in
exactly the phase where start-up latency is the thing being measured. Two strong
references to the dataflow index mean the named collection is the only export, so
take that case directly.

`drop_collection` releases a suspension token without the check `handle_schedule`
now makes, so for a multi-export dataflow it can be the release that unsuspends
the computation while reporting no start. Unreachable while every dataflow
reaching a replica has exactly one export, which
`SequentialHydration::absorb_command` requires, but silent divergence between two
paths that release the same token deserves a note rather than nothing.

`CollectionLogging::log_lifecycle` still told callers the demux does not
deduplicate. It does, since the retained rows became keyed by stage, and two
handlers rely on it. A caller trusting the stale comment would add a redundant
guard, or trust it in the other direction.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
The lifecycle log retracts an export's rows the moment it is dropped, so an
object's history vanishes with the object. That loses exactly the episodes worth
looking at: a dataflow that was dropped before it hydrated, or one whose
hydration is the reason someone is reading the log at all. It also means a short
lived dataflow can come and go inside one introspection interval and leave no
trace.

Delay the retraction instead, and record the drop as a stage of its own.

Two delays, because the two populations churn at completely different rates. A
transient export is created per peek and per subscribe, so retaining those for
minutes costs hundreds of megabytes on a busy replica: at eight workers and two
hundred peeks per second, a five minute window holds around 1.4 million rows.
A few seconds is enough for a reader to observe them and costs single digit
megabytes. A user object is dropped by DDL, so even a thousand drops inside the
window is under ten megabytes, and there the history is worth keeping.

    compute_lifecycle_retraction_delay            default 5 min
    compute_lifecycle_retraction_delay_transient  default 5 s

Both are floored at the logging interval in code rather than by convention. The
demux rounds update timestamps up to that interval, so a shorter delay can round
to the same timestamp as the insertion and leave the rows never separately
visible, which would defeat the point.

The delays are read per batch rather than at construction, so an
`UpdateConfiguration` command takes effect without recreating the logging
dataflow, and a per-replica override applies to a replica under investigation
without touching the rest.

The `dropped` stage is what makes the delay readable. Without it a lingering row
says only that an export reached some stage, not whether it still exists, so
"hydrated but never written" could not be told apart from "dropped before it
wrote". With it the object's last event names its fate and the delay is pure
retention, and a row whose export id no longer appears in `mz_objects` is
explained by its own `dropped` event rather than reading as a leak.

Part of CPU-226

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
The design doc says `catalog_server_explain.slt` needs no change when a builtin
log is added, on the grounds that its query filters `o.id NOT LIKE 'si%'` and so
never enumerates a per-replica introspection index. The filter is real, but the
conclusion does not follow. The plans already in the file embed the inlined
builtin `VALUES` sets as `Constant (N rows)` nodes, so every count over a catalog
relation that gained a row moves, and adding an ontology entity and link moves two
more.

Record the question that catches this: not whether a new plan appears, but whether
the existing plans change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
@antiguru
antiguru force-pushed the claude/lifecycle-retraction-delay-js1ycm branch from 5981d21 to 4541a11 Compare August 24, 2026 13:02
@antiguru antiguru changed the title compute: delay retracting a dropped export's lifecycle rows compute: delay retracting a dropped export's lifecycle rows [deferred, unsound] Aug 24, 2026
@antiguru
antiguru force-pushed the claude/hydration-visibility-compute-js1ycm branch from fb01c00 to fe9c7b4 Compare August 27, 2026 14:54
@antiguru antiguru closed this Aug 31, 2026
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.

2 participants