Skip to content

adapter: collect durable replica hydration history - #38512

Open
aljoscha wants to merge 7 commits into
MaterializeInc:mainfrom
aljoscha:aj/replica-hydration-history
Open

adapter: collect durable replica hydration history#38512
aljoscha wants to merge 7 commits into
MaterializeInc:mainfrom
aljoscha:aj/replica-hydration-history

Conversation

@aljoscha

@aljoscha aljoscha commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Motivation

Materialize records successful hydration for individual indexes and materialized views, but it does not retain a replica-level history. This makes it difficult to answer when a replica finished hydrating, how many maintained compute exports participated, or what process resource high-water marks were visible when the episode was recorded.

Description

This change adds mz_internal.mz_replica_hydration_history and extends the existing durable hydration-history sweep to populate it.

A replica hydration episode is a connected set of overlapping export intervals. Each live non-transient compute export contributes an interval from its earliest worker installation to its latest worker hydration. The collector waits for every visible worker of every live non-transient export to hydrate. A gap between intervals means that the replica was fully hydrated before the next export was installed, so the next interval starts a new episode. Transient exports are excluded because the collection query creates one itself and would otherwise wait on its own hydration. The object-level collector remains limited to user indexes and materialized views.

Each row records replica and cluster IDs, episode boundaries, the number of compute exports in the episode, status, and the largest resource high-water marks reported by any replica process. Memory uses cgroup memory_peak. Disk uses sampled statvfs fs_used_peak when a scratch filesystem is present, with cgroup swap_peak as the fallback. Managed resource limits apply independently to each process, so the maximum across processes is relevant. Summing process peaks could combine values that did not occur simultaneously.

Resource observations have process-lifetime scope through collection. They are associated with an episode but are not bounded by its timestamps. Work after hydration can raise them, and a later episode can retain an earlier process peak.

Collection is snapshot sampling rather than an event log. Each sweep records only the latest completed episode visible in its snapshot. Intermediate episodes can be missed, and exports retracted before collection leave no evidence. Only successful episodes are currently recorded. Process-local clock skew can merge intervals that did not overlap in real time.

The collector reuses the replica-targeted timestamped read-then-write path. Exact-timestamp OCC and an anti-join make concurrent collection idempotent. A monotonic history guard prevents a later snapshot from recording an episode that precedes or overlaps retained history. Retention is bounded and eventually consistent across concurrent collectors.

The table survives normal environmentd and replica restarts and is protected from forced replacement migrations. Collection remains disabled by default through hydration_history_collection_interval. Coordinator shutdown explicitly aborts and waits for an active sweep before releasing timestamp-oracle workers, which preserves dependency ordering for timestamp-oracle clients cached by the sweep.

Tests

The hydration testdrive coverage exercises the production collector. It verifies that a system-only replica episode includes every visible non-transient export while excluding the collector's transient subscribe. It also checks single-export episode boundaries and compares persisted memory and disk values with independently published resource metrics.

The restart workflow verifies that retained rows survive environmentd and replica restarts, a restart creates a fresh episode identity, and repeated sweeps do not duplicate an identity. Unit coverage checks episode query construction, latest-episode selection, process-count gating, replica rotation, task cancellation, and migration policy. Catalog expectations cover the relation, comments, OID, ontology metadata, and system catalog plans.

Release note

This release will add mz_internal.mz_replica_hydration_history, an internal durable history of successful replica hydration episodes and their observed process resource high-water marks.

Add the internal durable table for successful replica hydration episodes, including object counts and nullable memory and disk peaks.

Protect the table from bootstrap resets and forced replacement migrations. Document episode boundaries, process-lifetime peak semantics, retention, and current failure-reporting limits.
Extend the hydration history sweep to record the latest completed replica episode after collecting per-object rows. Derive episode boundaries from connected object hydration intervals and wait for every configured process before snapshotting resource peaks.

Use the maximum cgroup memory peak and filesystem or swap peak across processes, retain rows with the existing 30-day policy, and cover incremental hydration, restart durability, deduplication, and retention.
@aljoscha
aljoscha force-pushed the aj/replica-hydration-history branch from 47d61fc to 03e5ca1 Compare August 27, 2026 13:02
@aljoscha
aljoscha marked this pull request as ready for review August 27, 2026 16:20
@aljoscha
aljoscha requested review from a team as code owners August 27, 2026 16:20
Replica hydration is replica-wide, so filtering through the catalog to indexes and materialized views omitted metric sinks and system indexes. An omitted export could also split one continuous hydration period into separate episodes.\n\nBuild episodes from every non-transient compute export instead. Transient exports remain excluded because the collector's own subscribe would otherwise prevent the completeness check from succeeding.
Describe the replica table in terms of hydration episodes throughout the catalog and ontology. Keep the user-facing section focused on episode boundaries, retention, and resource interpretation.\n\nReduce the design document to the replica-level additions and refer to the object history design for shared collection mechanics. Restore the original collection-setting description, which is sufficient for the shared collector.
Describe replica hydration episode boundaries and counts in terms of
maintained compute exports, including the lifetime of referenced catalog
objects. Align the companion designs with installed-to-hydrated intervals
over all non-transient exports.

Add a system-only testdrive assertion that compares the durable episode
membership with the replica's visible non-transient exports.
Replica history includes system exports, so both quickstart.r1 and hydration_history.r1 record episodes and fresh identities when materialized restarts. Limit the assertion to the cluster under test.
@@ -0,0 +1,195 @@
# Durable Replica Hydration History

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to cut this down quite a bit, we can rever to the other hystory design doc for reference, and really only describe here the bits we add

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c453b5c. The replica design now references the durable object hydration history design for shared collector mechanics and is limited to replica episode boundaries, resource interpretation, and table shape.


## `mz_replica_hydration_history`

The `mz_replica_hydration_history` table samples successful replica hydration

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can call these replica hydration episodes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c453b5c. The relation description and field semantics now call these replica hydration episodes.

before every preceding object has hydrated belongs to the same component. The
component finishes when all surviving object intervals have hydrated.

Collection is disabled by default. Setting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to cut these operational concerns and also cut this doc section smaller overall

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c453b5c. I removed rollout, introspection, migration, and collector-concurrency details. The section now keeps the user-facing episode boundaries, retention, sampling, and resource interpretation.

independently to each process, so the table records the largest process peak
rather than a sum of peaks that may not have occurred at the same time.

Retention is eventual across concurrent `environmentd` processes. Collectors

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also all these operational concerns, just report the simpler user facing facts: same as for the other history table we have in here already look at it's length and shape and contents for guidance here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c453b5c. This now follows the length and shape of the object hydration history section, with two short paragraphs and the relation fields.

Comment thread src/adapter-types/src/dyncfgs.rs Outdated
);

/// How often to sweep replicas for completed object hydration episodes.
/// How often to sweep introspection-enabled replicas for completed hydration history.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to talk about the introspection-enabled detail here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c453b5c. I removed the introspection-enabled detail.

Comment thread src/adapter-types/src/dyncfgs.rs Outdated
"hydration_history_collection_interval",
Duration::ZERO,
"How often to record completed object hydration episodes. A zero duration disables collection.",
"How often to record completed object hydration and replica hydration samples. A zero duration disables collection. Replicas with compute introspection disabled are skipped.",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the original description was fine here please restore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c453b5c. The original setting description is restored.

Comment thread src/adapter/src/coord.rs
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why'd we need this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The active sweep owns a PeekClient that can cache an Arc to the batching timestamp oracle without keeping the coordinator command channel open. Dropping AbortOnDropHandle requests cancellation but is not a completion barrier. Build 132852 exposed the race when the oracle worker stopped first and panicked at batching_oracle.rs:115 because the sweep still held a sender. The abort and await in 34e3b19 releases the sweep oracle client and outstanding read timestamp while global_timelines still owns the oracle worker.

Comment thread src/ore/src/task.rs
self.0.inner.is_finished()
}

/// Aborts the task, then waits for it to release its owned resources.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why these changes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A plain JoinHandle::abort would lose the wrapper’s abort-on-drop protection. The wrapper’s Future also intentionally remains pending after cancellation, so the coordinator cannot use it as a join barrier. The consuming abort_and_wait API in 34e3b19 preserves abnormal abort-on-drop behavior and provides ordered normal teardown. Its unit test verifies that task-owned resources are dropped before the call returns.

JOIN mz_internal.mz_object_global_ids AS ids ON ids.global_id = t.export_id
JOIN mz_catalog.mz_objects AS o ON o.id = ids.id
WHERE t.export_id LIKE 'u%'
AND o.type IN ('index', 'materialized-view')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this join and check, what other types could there even be running on there, and we probably want all the running objects anyways

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 155226c. Replica episodes now use every export with export_id NOT LIKE t%, with no catalog join or object-type filter. This includes system and introspection exports as well as metric sinks. Transient exports stay excluded because the collector subscribe is transient and would otherwise wait on itself. dd64244 adds a system-only test that checks the durable episode count against every visible non-transient export. The object-level collector remains limited to user indexes and materialized views.

@def-

def- commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Widening replica episodes to system exports makes quickstart record episodes, which the restart test's r.name = 'r1' filter silently absorbs

test/restart/mzcompose.py:1467

workflow_hydration_history_survives_restart selects replica episodes by replica name only, and r1 is the managed-replica name for every single-replica cluster, including the bootstrap quickstart cluster. Before this commit quickstart had no user indexes or MVs, so bool_and(hydrated) over an empty objects was NULL and it never produced a row; now its introspection log indexes alone produce an episode on every boot, so the helper returns rows from two different replicas and the workflow's replica-history assertions no longer describe the cluster under test.

Details

replica_episodes() joins mz_cluster_replicas on name alone, and managed_cluster_replica_name (src/catalog/src/durable/objects.rs:386) yields r1 for both quickstart and the hydration_history cluster created by the test.

Two consequences:

  • The pre-restart capture loop breaks as soon as any row appears. next_replica sweeps in ascending replica-id order, so quickstart's replica (allocated at bootstrap) is visited first, and its system-only episode is available immediately while hydration_history's needs a 100k-row index plus two MVs to finish. replica_before will therefore normally hold only quickstart's episode, and the assertions that follow it ("restart changed replica episodes", "restart lost replica episodes", "restart reused a replica hydration start") then verify quickstart rather than the replica the workflow was written to exercise.
  • The restart restarts both replicas, so two fresh identities appear. assert len(replica_fresh_ids) == 1 and assert len(replica_settled_ids) == len(replica_before_ids) + 1 (around lines 1620 and 1650) are off by one and will fail, with a message that points at episode counts rather than at the replica conflation.

Qualifying the selection by cluster fixes both, for example joining mz_catalog.mz_clusters and filtering c.name = 'hydration_history', or matching on the cluster id the test created. Note that raising the expected counts instead would leave the first consequence in place: the workflow would keep passing while validating quickstart.

2. LOW -- User docs no longer say collection is off by default

doc/user/content/reference/system-catalog/mz_internal.md:748

The rewritten mz_replica_hydration_history section keeps the phrase "while collection is enabled" but drops the sentences that said collection is disabled by default, that hydration_history_collection_interval enables it, and that replicas with INTROSPECTION INTERVAL = 0 are skipped. A reader now has no way to tell from this page why the table is empty. The same information was also removed from the dyncfg description in src/adapter-types/src/dyncfgs.rs:427, which additionally no longer mentions that the setting drives replica history at all.

@aljoscha

Copy link
Copy Markdown
Contributor Author

The restart test finding was real, but is already fixed in 6ebb99e. replica_episodes() now scopes by cluster and replica, and the replacement CI build is green.

I’m leaving the docs as is. hydration_history_collection_interval is internal-only and regular users cannot set it. This wording also follows the earlier review to keep operational details out of the catalog docs and restore the original dyncfg description. If this becomes user-configurable, we should document both hydration history tables together.

@ggevay ggevay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments from Claude for now. I'll try to take a deeper look tomorrow.

'hydrated'::text AS status
FROM episode AS e
CROSS JOIN resources AS r
WHERE (SELECT bool_and(hydrated) FROM objects)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate is stricter than the episode definition needs, and since only the latest completed episode is ever recorded, the strictness is lossy rather than just late. An export installed after the candidate's finished_at cannot belong to the candidate (its interval starts after every interval in the candidate ended, so by the definition it opens the next episode), yet it blocks the write. With dataflows created one after another, every sweep that lands during a hydration records nothing, and once that hydration ends the previous episode is no longer the latest and is never recorded.

The predicate the definition actually needs is

NOT EXISTS (
    SELECT 1 FROM objects AS o
    WHERE NOT o.hydrated AND o.installed_at <= e.finished_at
)

An unhydrated export installed at or before the candidate's finish would extend it; one installed after it starts the next episode. The exposure to late-visible worker rows is the same either way (an entirely invisible export is absent from objects under both predicates).

Separately, and true under either predicate: one permanently unhydrated export (a sink whose broker is down, a materialized view over a stalled source, the mv_wmr_stuck fixture in hydration-status.td) suppresses every later replica episode on that replica, restart episodes included, because its open interval connects to everything installed after it. Worth a sentence in the design doc's limitations; "successful episodes only" does not say that one stuck export silences the table for the whole replica.

Ok(Err(error)) => {
self.observe_mutation(step, "error");
if step == "collection" && matches!(&error, AdapterError::ReadThenWriteContention) {
if step.ends_with("collection")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: step.ends_with("collection") ties the trailing-frontier hint to the spelling of four metric labels, and a renamed label moves a step in or out of this branch with no compile error. A small Step enum with label() and is_collection() would carry both.

)
}

/// A bounded batch of replica history rows that have aged out.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: finished_at is nullable by design (the PR body reserves it for failed and OOM-killed episodes), and a NULL there would make the row immortal here. The object variant's doc calls that out; the same sentence belongs here. Keying on started_at instead would break the shared-cutoff symmetry (a row with started_at < cutoff <= finished_at would be deleted and re-collected every sweep); coalesce(finished_at, started_at) < cutoff would keep it and is identical for every row collection writes today.

)
}

/// What one sweep needs to run its mutations against the history table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "against the history table" here, and "one bounded batch" on retain below, are now two tables and one batch per table.

);

/// How long to retain completed object hydration episodes.
/// How long to retain completed object and replica hydration episodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: HYDRATION_HISTORY_COLLECTION_INTERVAL above still says "object hydration episodes" in both its rustdoc and its description, while this one now names both tables.

),
(
"object_count",
"The number of maintained compute dataflows in the hydration episode.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: after 155226c this count includes the replica's introspection log indexes (the system-only episode in the testdrive is exactly that), which a reader of "maintained compute dataflows" will not expect on an idle replica; "compute dataflows, including introspection indexes" or similar would say it. Also, the mz_object_hydration_history section of mz_internal.md keeps its cross-process clock-skew sentence, but the new section has none, although the replica interval spans more processes.

Comment thread test/restart/mzcompose.py
deadline = time.time() + 120
replica_before = []
while time.time() < deadline:
replica_before = replica_episodes()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: replica_before is captured at first sighting, and the two exact-count assertions after the restart (len(replica_fresh_ids) == 1, len(replica_settled_ids) == len(replica_before_ids) + 1) assume nothing else was recorded before the kill. That holds today only because the DDL lands in the same logging bucket as the log indexes' hydration, so they form one island; re-reading replica_before right before c.kill makes it independent of that timing.

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.

3 participants