Skip to content

environmentd: restart in place only for preflight catch-up halts - #40065

Merged
bosconi merged 3 commits into
mainfrom
jc/envd-preflight-restart
Oct 26, 2026
Merged

bosconi merged 3 commits into
mainfrom
jc/envd-preflight-restart

Conversation

@bosconi

@bosconi bosconi commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

This re-lands the in-container restart of #40029 (SQL-807) for the read-only catch-up halts only, so a fenced environmentd in a replaced pod no longer restarts and fences the live leader.

Motivation

#40029 restarted environmentd inside its container on every halt! (exit 166), to avoid Kubernetes crash backoff for repeated catch-up halts during zero-downtime upgrades. #40061 reverted it, and this PR is based on main after that revert, because #40029 broke the nightly job K8s recovery: envd on failing node.

The halts that SQL-807 is about are the DDL reboots in check_ddl_changes, which a read-only deployment can hit many times while it catches up.

Description

  • mz_ore::process: add RESTART_IN_PLACE_EXIT_CODE (75, EX_TEMPFAIL) and the restart_in_place! macro, which behaves like halt! but exits with 75. Name the existing halt code HALT_EXIT_CODE (166). Shells, tini and Kubernetes report death by signal N as 128 + N, so a code below 128 can only come from an explicit exit.
  • src/environmentd/ci/entrypoint.sh: restart environmentd in place only on 75, and export MZ_RESTARTED_IN_PLACE=true before doing so. Every other exit, including 166, ends the container as before environmentd: restart halts within the container #40029. A graceful exit still sleeps forever.
  • preflight_0dt: when MZ_RESTARTED_IN_PLACE is set (the hidden --restarted-in-place flag) and the catalog is at this process's own generation, halt with 166 instead of booting writable (preflight.rs#L70-L82).
    • Only read-only code paths restart in place, so that combination means a process of this generation has been promoted: one in a pod that replaced this one, or, after a skipped catch-up, this container's previous process, whose read-only bootstrap restarted after the takeover. Booting writable would fence a replacement, and halting is correct in both cases.
    • A kubelet restart starts a new container without the marker, so a pod that wasn't replaced still boots as leader.
  • check_ddl_changes: use restart_in_place! only while the catalog's deployment generation is still older than this process's, so the restarted process boots read-only. Once a process of this generation has been promoted, halt with 166 (deployment generation N has been promoted; halting). The generation is read from the openable catalog that the check already builds, before open_savepoint consumes it, so the check reads one catalog snapshot, not two.
  • Storage controller: the read-only halt in create_collections_for_bootstrap for a dependency deleted during bootstrap uses restart_in_place! (lib.rs#L1031-L1047). It keeps halt! when writable.
  • The takeover halt after promotion (fenced out old deployment; rebooting as leader) keeps halt!. That process has just opened the catalog writable, and a restart in place would only reach the marker check one boot later.
  • tini -g and the entrypoint's TERM/INT traps are restored as environmentd: restart halts within the container #40029 had them: environmentd receives SIGTERM itself, and the shell exits after it instead of restarting or sleeping.
  • bin/environmentd (misc/python/materialize/cli/run.py) restarts on 75 as it does on 166.

Residual: a stale read-only process in a replaced pod still restarts in place on these halts until the kubelet kills its container. The marker keeps it from booting writable, but it competes with the replacement for the generation's replicas during that window.

Tests

  • cargo check and cargo clippy --all-targets -- -D warnings for mz-ore, mz-environmentd, mz-storage-controller and mz-sqllogictest with Rust 1.98.1: clean. rustfmt --check on the changed Rust files: clean.
  • shellcheck -s sh src/environmentd/ci/entrypoint.sh (koalaman/shellcheck): clean.
  • Entrypoint loop under busybox sh with a stub environmentd:
    • exits 75 then 0: restarted once with the same arguments and MZ_RESTARTED_IN_PLACE=true, then sleeps.
    • exits 75, 75, 166: restarted twice, then the container exits 166.
    • exits 166, 1 or 134: the container exits with that code, no restart.
    • docker stop under tini with -g: environmentd gets SIGTERM, and the container exits 143 within 0.1 s.
  • test/0dt workflow restarted-in-place-after-promotion: promotes mz_new, then starts a second process of its generation with MZ_RESTARTED_IN_PLACE=true, and asserts that it exits 166 with the marker halt while mz_new keeps serving as leader. Without the marker check, that process would fence mz_new.
  • Not run: K8s recovery: envd on failing node needs the cloudtest kind cluster, and it and the test/0dt workflows, including the new one, need image builds. Please run the nightly job against this branch before merging.

🤖 Generated with Claude Code

Re-land the in-container restart from #40029, reverted in #40061, for
the one halt that is safe to repeat inside a container that Kubernetes
may already have replaced: the read-only reboot after DDL during
catch-up (SQL-807).

Add `mz_ore::restart_in_place!`, which exits with the new
`RESTART_IN_PLACE_EXIT_CODE` (168), and name the halt code
`HALT_EXIT_CODE` (166). The environmentd entrypoint restarts
environmentd only on 168. Every other exit, including 166, ends the
container as before #40029, so a fenced environmentd in a replaced pod
no longer reboots and fences the live leader.

`check_ddl_changes` uses the new macro only while the catalog's
deployment generation is still older than its own, so the restarted
process boots read-only and opens the catalog in savepoint mode. If a
process of this generation has been promoted meanwhile, it halts as
before. The takeover halt after promotion keeps `halt!`, because that
process has just opened the catalog writable.

Keep `tini` without `-g` and set no traps: on SIGTERM the entrypoint
shell dies with its default disposition as before, so the loop never
restarts during shutdown.

`bin/environmentd` restarts on 168 as it does on 166.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@bosconi
bosconi requested a review from a team as a code owner October 21, 2026 14:29
@bosconi
bosconi requested a review from aljoscha October 21, 2026 14:29
@bosconi

bosconi commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@aljoscha is this acceptable?

Residual: a stale read-only process in a replaced pod still restarts on a DDL halt until the kubelet kills its container. It stays read-only and cannot take the catalog epoch, but it competes with the replacement for the generation's replicas during that window.

@def-

def- commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Generation guard is checked in the dying process, but the restarted process decides leader vs read-only on its own

src/environmentd/src/deployment/preflight.rs:349

The guard reads the catalog generation before restart_in_place!, but the restarted process makes its own leader-or-read-only decision a full boot later in preflight_0dt and does not know it was restarted in place. If a replacement pod of the same generation is promoted inside that window, a stale process in a replaced pod restarts, sees catalog_generation == deploy_generation, opens the catalog writable and fences the live leader. That is the failure this PR exists to prevent.

Details

Sequence: a 0dt upgrade to generation N is catching up in pod A. A's node stalls and A is replaced by pod B (orchestratord sets terminationGracePeriodSeconds: 0, see src/orchestratord/src/controller/materialize/generation.rs:1303). DDL lands on the old leader before B bootstraps, so it is in B's baseline but not in A's. When A resumes, its overdue DDL tick fires at once, check_ddl_changes reads generation N-1 (preflight.rs:359) and exits 168. The entrypoint restarts environmentd right away. Before the new process reaches preflight_0dt, it opens persist, initializes the catalog state and waits for the system parameter sync (src/environmentd/src/lib.rs:466-531), which takes seconds. If orchestratord promotes B during that time, preflight_0dt returns false (preflight.rs:65). The process then calls open (lib.rs:719), takes a new epoch, and B panics with the same unable to advance catalog upper: Durable(Fence(..)) seen in the nightly. Nothing in the restarted process records that it came from an in-place restart.

Fix: make the check at the point where the decision is made. The entrypoint could export a marker before the loop restarts environmentd, for example MZ_RESTARTED_IN_PLACE=1. preflight_0dt would then halt! (166) instead of booting as leader when the marker is set and catalog_generation == deploy_generation. A process restarted in place was not ReadyToPromote while it restarted, so a promotion of its own generation means another pod took over. Exiting 166 leaves the decision to the kubelet, and a kubelet restart starts a new container without the marker. The existing check in check_ddl_changes can stay as a fast path.

@aljoscha aljoscha 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.

had some questions, but otherwise the direction looks good

# (166), exits the container and leaves the restart to Kubernetes.
restart_in_place=168

while true; do

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 reverted change also had the change to add -g to our tini invocation. Did you think about if that is needed or not?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Restored both in 8018a0d5, as #40029 had them.

.unwrap_or_terminate("unexpected error while fencing out old deployment");

// Reboot as the leader.
// Reboot as the leader. This process now holds the catalog, so leave

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.

why doesn't this use restart_in_place! ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This halt runs right after the process has taken over as leader. If its pod was replaced in the meantime, restarting in place would fence the replacement, which is what went wrong in #40029. It also happens only once per promotion, so there's no crash backoff to avoid.

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.

okay, let's keep like this now

Comment on lines +366 to +367
"there have been DDL that we need to react to, and generation {} has been \
promoted; halting. {}",

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.

should probably be just sth like "there is a newer generation in the catalog, halting.", without the DDL bit

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed on dropping the DDL part. Go without "newer", because the usual case is that catalog_generation equals ours, because another process of this generation, a replacement pod, was promoted? 8018a0d5 changes it to halt!("deployment generation {} has been promoted; halting", catalog_generation).

@aljoscha

Copy link
Copy Markdown
Contributor

One other thing, I think we need to also restart_in_place here:

.

And then, how'd you arrive at the exit code, my agent suggested 75 because "75 follows the conventional EX_TEMPFAIL value.". No idea...

We might want to also keep the tini -g change, and the trapping in the wrapper script, the agent had this to say:

For the restart-loop wrapper, I would keep -g. It wasn't overboard. Without it, tini forwards SIGTERM only to its immediate child, the shell, not to environmentd:

tini → shell running restart loop → environmentd

A shell waiting for a foreground command can defer its signal trap until that command exits. Without forwarding SIGTERM to environmentd, shutdown can therefore stall until Kubernetes sends SIGKILL. Tini documents this exact shell-wrapper problem.

The two pieces serve different purposes:

  • tini -g delivers shutdown signals to both the shell and environmentd.
  • The shell's TERM/INT traps make the wrapper exit rather than restart or sleep after environmentd terminates.

We could replace -g with explicit signal forwarding and child management in the shell, but that is more machinery. Keeping -g and the traps is the simpler solution. It does not prevent the stale-pod race before Kubernetes delivers a shutdown signal.

@aljoscha

Copy link
Copy Markdown
Contributor

Tha QA llm review might be bogus, or might be a real issue, needs careful thinking

@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

SQL-807

Address review on the in-place restart.

The entrypoint now exports `MZ_RESTARTED_IN_PLACE=true` before it
restarts environmentd, and `preflight_0dt` halts instead of booting
writable when that is set and the catalog is at the process's own
generation. A process restarts in place only while it is read-only and
has not announced itself ready to promote, so that combination means
another process of its generation, in a pod that replaced this one, has
been promoted. Booting writable would fence it. Without the marker, a
stale process could restart while the replacement was being promoted
and fence it once.

With that guard, the storage controller's read-only halt for a
collection deleted during bootstrap uses `restart_in_place!` too, and
keeps `halt!` when writable.

`restart_in_place!` exits with 75 (`EX_TEMPFAIL`) instead of 168, which
is also what a shell reports for a process killed by signal 40.

Restore `tini -g` and the entrypoint's TERM/INT traps, so environmentd
receives SIGTERM itself and the shell exits instead of restarting or
sleeping after it.

Shorten the halt in `check_ddl_changes` once the generation has been
promoted to "deployment generation N has been promoted; halting".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@bosconi
bosconi requested review from a team and ggevay as code owners October 23, 2026 14:19

@bosconi bosconi left a comment •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks @aljoscha.

# (166), exits the container and leaves the restart to Kubernetes.
restart_in_place=168

while true; do

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Restored both in 8018a0d5, as #40029 had them.

.unwrap_or_terminate("unexpected error while fencing out old deployment");

// Reboot as the leader.
// Reboot as the leader. This process now holds the catalog, so leave

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This halt runs right after the process has taken over as leader. If its pod was replaced in the meantime, restarting in place would fence the replacement, which is what went wrong in #40029. It also happens only once per promotion, so there's no crash backoff to avoid.

Comment on lines +366 to +367
"there have been DDL that we need to react to, and generation {} has been \
promoted; halting. {}",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed on dropping the DDL part. Go without "newer", because the usual case is that catalog_generation equals ours, because another process of this generation, a replacement pod, was promoted? 8018a0d5 changes it to halt!("deployment generation {} has been promoted; halting", catalog_generation).

@bosconi

bosconi commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

One other thing, I think we need to also restart_in_place here:

.

Done in 8018a0d5. That halt now uses restart_in_place! when the storage controller is read-only, and keeps halt! when it is writable (lib.rs#L1031-L1045).

@bosconi
bosconi requested a review from aljoscha October 23, 2026 14:47

@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.

LGTM, minor comments.

Nightly passed apart from one unrelated orchestratord upgrade-chain timeout that only runs released environmentd images: https://buildkite.com/materialize/nightly/builds/18586

info!("this deployment is a new generation; booting in read only mode");
Ok(true)
} else if catalog_generation == deploy_generation {
// A process restarts in place only while it is read-only and has not

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.

With skip-catchup (force promote), the takeover can happen while bootstrap is still read-only, so the storage-controller site can exit 75 after this container's own takeover. Halting is still right, but "has not announced itself ready to promote" doesn't hold, and neither does "still catching up" in the storage controller or the description. Maybe: "...means a process of this generation has been promoted: one in a pod that replaced this one, or, after a skipped catch-up, this container's previous process."

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. The halt is correct but the reasoning wasn't. a5f2ab0c rewords the comment and description accordingly: a process of this generation has been promoted, either in a pod that replaced this one or, after a skipped catch-up, this container's previous process.

// replaced this one, has been promoted. Booting writable would fence
// that process. Kubernetes does not restart a deleted pod, so leave the
// restart to it.
if restarted_in_place {

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.

No test fails if this guard is removed, but without it a process restarted in place after a replacement of its generation was promoted would boot writable and fence it. preflight_0dt is only tested with false, and no CI job sets MZ_RESTARTED_IN_PLACE (the materialized image's entrypoint restarts environmentd without it). Could you add a test/0dt workflow that promotes the new generation, then starts a second process of that generation with MZ_RESTARTED_IN_PLACE=true and asserts that it halts while the leader keeps serving? Without the marker, that process fences the leader, so the workflow would catch a regression. Returning the decision from preflight_0dt instead of halting would allow a cheaper unit test, but it wouldn't cover the env plumbing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in a5f2ab0c: the test/0dt workflow restarted-in-place-after-promotion promotes mz_new, then starts a second process of its generation with MZ_RESTARTED_IN_PLACE=true, and asserts that it exits 166 with the marker halt while mz_new keeps serving as leader. Going to merge and check how this does in tonights nightly.

// Restart in place only while this generation is still read-only. Once a
// process of this generation has been promoted, a restarted process would
// only halt in `preflight_0dt`, so leave the restart to Kubernetes.
let catalog_generation = openable_catalog(

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.

This builds a second catalog state just to read the generation, and building one reads a full snapshot of the catalog shard (32-37 s at boot on our largest environment). Could you read the generation from the first openable before open_savepoint, as boot does before open? An earlier read can only under-report, and the marker check in preflight_0dt catches that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in a5f2ab0c. check_ddl_changes now reads the generation from the openable it already builds, before open_savepoint consumes it.

Read the deployment generation in check_ddl_changes from the openable
catalog it already builds, before open_savepoint consumes it, instead of
building a second one, which reads another full catalog snapshot.

Reword the comments at the marker check and the storage-controller halt:
after a skipped catch-up, a process's read-only bootstrap can restart in
place after that same process took over, so the promoted process is not
necessarily in a replacement pod.

Add the test/0dt workflow restarted-in-place-after-promotion, which
starts a second process of the promoted generation with
MZ_RESTARTED_IN_PLACE=true and asserts that it halts with 166 while the
leader keeps serving.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@bosconi
bosconi merged commit 86328bf into main Oct 26, 2026
87 checks passed
@bosconi
bosconi deleted the jc/envd-preflight-restart branch October 26, 2026 23:13
bosconi pushed a commit that referenced this pull request Oct 27, 2026
Adds the rc.3 increment: one Improvements entry for #40065, which makes
a read-only environmentd restart in place rather than exit its container
when it reboots to pick up DDL changes during zero-downtime upgrade
catch-up, so Kubernetes no longer applies crash-restart backoff.

Provisional dates are unchanged from rc.1 and rc.2 (Cloud 2026-10-29,
Self-Managed 2026-10-30), so the operator compatibility table is
untouched.
@bosconi bosconi mentioned this pull request Oct 27, 2026
ggevay added a commit that referenced this pull request Oct 27, 2026
#40221)

The `test/0dt` workflow `restarted-in-place-after-promotion` (#40065)
expects its `mz_restarted_in_place` service to halt with `restarted in
place, but deployment generation 1 has been promoted`.
`ci-annotate-errors` reports every unknown `halting process:` line, so
Zero downtime 2 failed in [Nightly
18592](https://buildkite.com/materialize/nightly/builds/18592#01a17a8c-df0c-45f8-b0b1-6003bc6c7464)
although the workflow passed.

This ignores that message for the `0dt-mz_restarted_in_place-1` service
only, so the same halt elsewhere is still reported.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
maheshwarip pushed a commit that referenced this pull request Oct 28, 2026
Adds the rc.3 increment: one Improvements entry for #40065, which makes
a read-only environmentd restart in place rather than exit its container
when it reboots to pick up DDL changes during zero-downtime upgrade
catch-up, so Kubernetes no longer applies crash-restart backoff.

Provisional dates are unchanged from rc.1 and rc.2 (Cloud 2026-10-29,
Self-Managed 2026-10-30), so the operator compatibility table is
untouched.
maheshwarip pushed a commit that referenced this pull request Oct 29, 2026
Adds the rc.3 increment: one Improvements entry for #40065, which makes
a read-only environmentd restart in place rather than exit its container
when it reboots to pick up DDL changes during zero-downtime upgrade
catch-up, so Kubernetes no longer applies crash-restart backoff.

Provisional dates are unchanged from rc.1 and rc.2 (Cloud 2026-10-29,
Self-Managed 2026-10-30), so the operator compatibility table is
untouched.
maheshwarip pushed a commit that referenced this pull request Oct 30, 2026
Adds the rc.3 increment: one Improvements entry for #40065, which makes
a read-only environmentd restart in place rather than exit its container
when it reboots to pick up DDL changes during zero-downtime upgrade
catch-up, so Kubernetes no longer applies crash-restart backoff.

Provisional dates are unchanged from rc.1 and rc.2 (Cloud 2026-10-29,
Self-Managed 2026-10-30), so the operator compatibility table is
untouched.
val-materialize added a commit that referenced this pull request Oct 30, 2026
Release notes for the **v26.48.0** train. One commit is added per
snapshot; the `final` snapshot sets the real dates. **Applied: `rc.1`,
`rc.2`, `rc.3`, `final` — the train is complete.**

- **Content**: 3 Feature sections (upsert hydration; Kafka sink headers
GA; MySQL `EXCLUDE ALL NOT NULL CONSTRAINTS`), 9 Improvements, 2 Guides,
4 Bug Fixes. No **Agent Skills** section — every agent-skills PR in all
four windows was omitted.
- **Scope**: 123 PRs across the four snapshots — rc.1 114 (14 included),
rc.2 5 (0 included), rc.3 1 (1 included), final 3 (0 included).
- **Dates — now real, not provisional**: Cloud **2026-10-29**,
Self-Managed **2026-10-30**. The `rc.1`-anchored provisional estimate
was right, so `final` left both files unchanged. The `v26.48.0` tag's
commit date is 2026-10-28T18:35:05Z, a Wednesday; the first Thursday on
or after it is 2026-10-29, with Self-Managed the day after.
- **Operator compatibility**: row for `v26.48`, release date
`2026-10-30`. Unchanged by rc.2, rc.3, and final.
- **Branch base**: `main`, whose newest published section was `##
v26.46.1` when this branch was cut; v26.47.0 and v26.47.1 have since
merged in. `v26.48.0` stays at the top of the list.

### final increment — empty, and verified empty

`final`'s commit is empty. The tracking issue
([release#627](MaterializeInc/release#627))
lists exactly one item, the unassociated `4edd60a8d` "release: bump to
version v26.48.0", which the release-process-automation rule omits. The
three agent-skills PRs in the window
([#118](MaterializeInc/agent-skills#118),
[#119](MaterializeInc/agent-skills#119),
[#120](MaterializeInc/agent-skills#120)) were
all omitted.

This was **verified rather than assumed**, because a zero-PR snapshot
and a failed fetch produce identical output:
`compare/v26.48.0-rc.3...v26.48.0` returns `total_commits: 1`, and that
commit is the version bump. Nothing merged onto the release branch in
the 42 h 49 m between the `rc.3` tag and the ship tag. Across the train,
every agent-skills PR from
[#111](MaterializeInc/agent-skills#111) to
[#120](MaterializeInc/agent-skills#120) falls in
exactly one window, with no gaps or overlaps.

⚠️ **One decision is still open for the release owner — see "Upsert v2"
below.** Every flag this train held open was re-read in source at the
`v26.48.0` ship tag rather than inherited from the RC snapshots:

| Flag | Default at `v26.48.0` | Source | Held since | Disposition |
| -- | -- | -- | -- | -- |
| `enable_upsert_v2` | **`false`** |
`src/storage-types/src/dyncfgs.rs:505-509` | rc.2 | ⚠️ premise
contradicted — see below |
| `enable_upsert_chunked_stash` | **`false`** |
`src/storage-types/src/dyncfgs.rs:469-475` | rc.2 | same |
| `enable_upsert_paged_spill` | **`false`** |
`src/storage-types/src/dyncfgs.rs:450-455` | rc.2 | same |
| `enable_mcp_protocol_2026_07_28` | **`false`** |
`src/adapter-dyncfgs/src/dyncfgs.rs:295-299` | rc.1 | hold stands —
still omitted |
| `cluster-details-page-cns-173` | n/a (LaunchDarkly) | no source
default | rc.1 | hold stands — still omitted |

**The rc.2 hold on
[#40189](#40189) rests
on a premise this branch now contradicts.** rc.2 omitted it at
Borderline on exactly one ground — every measurement is taken on a path
`enable_upsert_v2` keeps off by default — and asked whether any
environment actually runs upsert v2. The branch answers that without
anyone feeding the answer back: the human-added **"Improved hydration
times and memory efficiency for upsert sources"** section states *"This
change has been rolled out to all Materialize Cloud environments and
applies to all existing and new clusters."* The three flags still read
`false` at the ship tag, which is exactly the
source-default-versus-LaunchDarkly divergence the rollout-lever rule
covers: the published docs call wins, and the sole stated reason for the
omit has fallen away.

- **Not already covered.** The published section is about **hydration**
— building a source from scratch, 206 min → 108–113 min, 542 GiB →
183–208 GiB. #40189 is about **catch-up after a backlog**, a different
operation with its own motivating incident
([CPU-320](https://linear.app/materializeinc/issue/CPU-320): a source
that *"could not catch up on any replica size after a producer rate
increase"*). A customer who read the hydration section would learn
nothing about catch-up.
- **Suggested Improvements bullet, to add only if you confirm** — it was
*not* applied in the `final` commit:
> **Faster catch-up for upsert sources with a large backlog**: An upsert
source working through a backlog now drains one timestamp per round
instead of merging and re-pushing the whole backlog each round, so
catch-up time grows linearly rather than quadratically with the backlog;
draining a 100-timestamp tail fell from 6.5 to 0.15 seconds, and a
400-timestamp tail drains in 0.67 seconds.
- **Caveat that must travel with the decision**: the baseline in those
timings is the *paged* flavor on `main`, not the configuration Cloud ran
before this rollout — so they are not a clean before/after for a given
customer. Dropping the two timings and keeping the
linear-versus-quadratic claim holds regardless of baseline.
- **Two weaker links, stated rather than buried**: that the rolled-out
configuration is the one #40189 was measured on is *inferred from the
published section's prose* (its "buffer pool … serve sequential work
from disk" reads as chunked stash plus paged spill), not confirmed by
anyone. And the author declared "Release notes: None", naming
`enable_upsert_v2` — so publishing this would be an **override** of an
author signal, justified by your own later rollout statement.
- **Third consecutive train** to hold this flag: `v26.44.0-rc.1`
([#39041](#39041)),
`v26.45.0-rc.1`
([#39044](#39044)),
`v26.48.0-rc.2` (#40189). If the rollout statement is accurate, the
earlier two are worth revisiting rather than left to age out.

**The other two holds stand, corroborated.**
`enable_mcp_protocol_2026_07_28` still reads `false` at the ship tag and
the five MCP PRs rc.1 held appear nowhere in the section; neither does
the Console cluster-details page stack. In both cases you had the whole
train in view and did not promote them — the confirmation rc.1 asked
for, arriving in the negative.

**No duplicates, re-checked at ship.** The seven PRs rc.1 flagged as
already covered by v26.47.0 are still absent, the `## v26.47.1` section
is untouched, and rc.3's entry
([#40065](#40065))
appears exactly once.

**Two notes on human edits to this branch**, both recorded as fact
rather than objection:

- `524adb8` removed the rc.1 feature section **"Native Google Cloud
Storage for persist"**
([#39983](#39983))
outright. Flagging it only because a published entry disappearing is
easy to miss in a long branch — if that was deliberate, nothing to do.
- Two rc.1 Improvements were promoted to their own feature sections with
docs links and a code sample (Kafka sink headers
[#39908](#39908);
MySQL `EXCLUDE ALL NOT NULL CONSTRAINTS`
[#39873](#39873)), and
a **Guides** section was added that is not in the skill's template. The
lesson for next time: an option whose flag flipped to `true` in the same
release more likely warrants a feature section than the Improvements
bullet the rubric suggests by default.

**Carried forward from rc.3 and still open**: the `K8s recovery: envd on
failing node` nightly — the job that sank
[#40029](#40029), the
first attempt at what #40065 re-lands — **was not checked**; it needs
Buildkite, outside this run. The entry itself was verified correct in
source at both the rc.3 and ship tags, so this is a CI-confidence
question, not a notes-accuracy one.

### rc.3 increment

One Improvement, for #40065: a read-only `environmentd` that reboots to
pick up DDL changes during zero-downtime upgrade catch-up now restarts
in place rather than exiting its container, so Kubernetes no longer
applies crash-restart backoff. Not inherited from rc.1 — rc.1 correctly
omitted the first attempt (#40029) together with its revert (#40061) as
a no-op on `main`; rc.3 re-lands the behavior in narrowed form, verified
as a real change by comparing `entrypoint.sh` at the `v26.47.0` tag (no
retry loop) against `v26.48.0-rc.3` (re-exec on exit 75). The draft's
closing "so zero-downtime upgrades catch up faster" was cut: no cited PR
measures an upgrade duration, so the entry claims only the mechanism.

- **Not a Bug Fix.** 0dt upgrades completed on v26.47.0; the catch-up
reboots merely collected backoff delay. Existing behavior improved, so
Improvements.
- **Residual, deliberately unpublished.** A stale read-only process in a
replaced pod still restarts in place on these halts until the kubelet
kills its container, competing with its replacement for the generation's
replicas. The `MZ_RESTARTED_IN_PLACE` marker check keeps it from booting
writable, so it cannot fence the leader — the failure #40061 reverted.
Worth watching if 0dt reports look odd after this ships.

### rc.2 increment — empty, by design

rc.2's commit is empty: no entry was published and the provisional dates
did not move. It exists so later runs can see from the commit subject
that rc.2 was applied. Its single materialize PR, #40189, is the open
item discussed under the `final` increment above.

### rc.1 — standing warnings

⚠️ **Seven PRs in rc.1's tracking issue were already covered by the
v26.47.0 train, and none is republished here.** v26.47.0 took five RCs
whose later ones were cherry-picks onto the release branch; the same PRs
appear in this issue because they are also what landed on `main` for
v26.48.0. Four of the seven **shipped to Cloud in v26.47.0 on
2026-10-22** — #37653, #39910, #39927, #40036 — so republishing any of
them would put duplicate text in front of customers eight days later.
The other three (#39903, #39906, #40046) were held at Borderline in
v26.47.0 and are held again here, each reason re-checked rather than
inherited. The "snapshots are disjoint by construction" guarantee holds
*within* a train, not across trains, so this was caught mechanically by
grepping every PR number in the issue against the v26.47.0 snapshot
directories. All seven are listed under their own heading in
`rc.1/omitted.md`.

**Open questions for the release owner**

- **Upsert v2** (#40189, rc.2) — see the `final` increment above. The
rollout statement on this branch has changed the picture since rc.2;
this is the one item that needs a decision before merge.
- **MCP protocol revision 2026-07-28** (#39698, #39702, #39703, #39759,
#39761) — the stack is complete and `enable_mcp_protocol_2026_07_28`
still defaults to **`false`** at the ship tag. Omitted, with the absence
of any MCP text on this branch taken as confirmation. Drafted wording
remains in `rc.1/omitted.md` if the flag flips.
- **Redesigned cluster details page in the Console** (#39446, #39507,
#39508, #39510, #39607) — omitted for the same reason. Note that the
Improvements section does carry **"New cluster details page on
Self-Managed"**, added by hand, which links the v26.47 section rather
than re-describing the page.
- **Schema projection on the Console's Freshness page** (#40031) — a
genuine new way to diagnose lag, but the author states the whole
Freshness page "is currently behind a feature flag that is only enabled
for certain customers in impersonation mode". **Is the page generally
reachable yet?** This is the same page v26.47.0 held for the same
reason.
- **Time zone data 2026e timing** (#40045) — the author flags the
Canadian zone changes as effective **2026-11-01**, three days after the
Cloud date. Tight, but it holds.

*Resolved since rc.1: the Kafka sink headers framing question — the GA
section on this branch answers it.*

**Verification notes** — rc.1's Step 5 pass cut one drafted entry and
rewrote four:

- **Cut**: a Bug Fix drafted for #40032 ("halt with exit code 168 when a
live reader loses its lease"). The PR's own Problem section is about
alerting — `mz_persist_lease_timeout_read` cannot distinguish a dead
reader from a stuck one — and the follow-up it names is a Cloud alerting
change. Nothing a customer observes changes.
- **tzdata** (#40045): the draft said the Canadian zones move to
"permanent daylight saving time", the author's prose. The PR's data
table shows a permanent **−05/EST** and **−06/CST** — summer *offsets*
kept year-round, but with another zone's standard-time *abbreviations*,
which is what a user sees from `AT TIME ZONE`. Reworded to what the
table supports.
- **Hydration memory** (#39923): scoped to the consolidation step and
the measured outcome (~628 GiB peak, no restart, against every
pre-change run exiting on the 849 GiB limit). The PR explicitly says the
fix "covers this consolidation path, not the sink's whole footprint";
[CPU-317](https://linear.app/materializeinc/issue/CPU-317) tracks the
rest.
- **Kafka clients** (#40026): "55 to 24" is measured on a 4-worker,
11-source setup. The CPU throttling and degraded freshness are observed
symptoms of the *old* behavior at a few hundred sources, not a measured
post-change result at that scale, so the entry states the count as the
outcome and the throttling as what the count drove.
- **`environmentd` accept backlog** (#40035): the author's hedge is kept
— "less likely to be restarted", not eliminated, because the PR says
"This makes the queue fill later, not never."

**Judgment calls a reviewer may want to revisit** — #39605 is filed as
an **Improvement**, not a Bug Fix: nothing was incorrect, an unbounded
operation gained a bound and a knob. #40101 is filed as a **Bug Fix**
rather than omitted under the internal-logging rule, because credentials
reaching the logs is a security defect with a user-visible remedy; only
credentials a user inlined rather than wrapped in a `SECRET` were
affected.

Snapshot drafts (with PR links) in mz-skills:
- rc.1:
[release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.1/release_notes.md)
/
[omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.1/omitted.md)
- rc.2:
[release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.2/release_notes.md)
/
[omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.2/omitted.md)
- rc.3:
[release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.3/release_notes.md)
/
[omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.3/omitted.md)
- final:
[release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/final/release_notes.md)
/
[omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/final/omitted.md)

**Borderline PRs omitted:** rc.1: 12 —
[omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.1/omitted.md#borderline),
rc.2: 1 —
[omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.48.0/rc.2/omitted.md#borderline),
rc.3: 0, final: 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Valerie Chiang <[email protected]>
Co-authored-by: Pranshu Maheshwari <[email protected]>
Co-authored-by: Pranshu Maheshwari <[email protected]>
@bosconi bosconi mentioned this pull request Nov 4, 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.

4 participants