Repository navigation
timely-util: fund upsert spine exertion from inserted updates - #39044
Conversation
c1e3189 to
497e085
Compare
6bfbebe to
bf2fea7
Compare
4bd3a47 to
2d28c6b
Compare
8294574 to
f9b7ce0
Compare
| if !funded && !self.merging.iter().any(|b| b.is_double()) { | ||
| // Unfunded: stay quiet until an insert pays. | ||
| return; | ||
| } | ||
| self.grant(effort); |
There was a problem hiding this comment.
Can we write this as:
if funded || self.merging.iter().any(|b| b.is_double()) { self.grant(effort) }
Benchmark resultsReplica: 400cc (8 workers, 65 GiB, swap-backed), 20% pool budget, four spill workers, The funding rule in isolation: 192M-row incompressible seed (about 407 GB, state about 6x memory):
Funding halves CPU and extent bytes, and doubles pace. Up to the kill, the funded synchronous run matched the funded async run on pace, CPU and extents, so the rule accounts for the whole gain. The funded runs were killed near the end at the replica's 466 GB memory-plus-swap ceiling. Every non-payload variant on this seed peaks within 5 to 15% of that ceiling, v1 included (455 GB). So pass or fail here comes down to a few percent of headroom, not the rule, and pace and CPU to the kill point are the comparison. The 300 GB seed below has headroom. Hydration: 300 GB incompressible seed (142.3M rows, state about 4.6x memory):
On the 8xlarge the stack is 14 to 24% behind v1 at CPU parity. On the 4xlarge it is 77% behind, and the swap device's write bandwidth is the limit. Chunked writes 1.95 TB to that device against v1's 0.72 TB, and holds the device's 605 MB/s ceiling. That gap is bytes per row, which payload separation addresses. It is out of scope for this PR. Hydration: 192M-row compressible seed (1,900-byte values with a repeated 475-byte pattern):
Steady state: 20M keys, ten minutes of uniform overwrites:
The snapshot is 31 to 36% faster than the stock spine, and p95 lag is 29 to 62% lower. p95 lag is still 1.4 to 4x v1's. That is the cost of synchronous probes against cold state, and this PR doesn't claim to fix it. Vendored copy against the fork. The interim image used the same rule on a differential-dataflow fork branch. On the 300 GB seed it ran 32m22s and 33m08s at 127 and 130 grants per batch. This PR's vendored spine reproduces that within noise (the 8xlarge trials above). Projection for the production case. This is an estimate, not a measurement. The production replica has 31 workers, 252 GB of memory and one NVMe swap device, and holds 1.56 TB of state. v1 hydrates it in 44 minutes, CPU-bound. Stock v2 did not finish: its worst sources reported 19.7 and 36.4 hours. The same per-worker memory, with four times the workers sharing one device, puts the stack at about 55 to 90 minutes if production values compress 1.8x to 2.7x. That is the range v1's footprint suggests. If they don't compress, it is about 2.4 hours. That turns a hydration that never finished into one about 1.2 to 2x slower than v1. Closing the rest of the gap needs fewer bytes per row. Written by Claude (Claude Code) from the benchmark notes. |
antiguru
left a comment
There was a problem hiding this comment.
Posted by Claude Code on behalf of @antiguru.
I confirmed the vendored spine matches differential 0.25.1 apart from the header and path rewrite. The 8 << level claim about introduce_batch also holds.
Main open question: what the credit actually bounds (inline). A funded grant can start a large merge that then finishes unfunded, so the real bound is grants per batch, not fuel per row. The miri ignores and a guard against a differential version bump are cheap to add.
| else { | ||
| // Introduce an empty batch with roughly *effort number of virtual updates. | ||
| let level = effort.next_power_of_two().trailing_zeros() as usize; | ||
| self.introduce_batch(None, level); |
There was a problem hiding this comment.
A funded grant debits effort (1000) from the credit, and this line introduces a virtual batch at level 10. That can start a merge up into the largest layer, and the merge then runs to completion unfunded through the is_double() path. So the work is bounded by the number of funded grants, not by fuel per inserted row. A 16-row insert funds almost no credit, but its 8 banked grants can each start a large merge.
The measurements show the bound holds in practice. Still, CONSOLIDATION_CREDIT_PER_UPDATE and the module doc read as a proportional fuel bound. Either charge the credit by the size of the merge a grant starts, or describe the actual bound as grants per inserted batch.
There was a problem hiding this comment.
Agreed, the bound is on grants, not fuel. I've changed the module doc, the constant's doc and the PR description to say that: up to eight grants per inserted update from credit plus the banked allowances, and a merge a grant starts may finish unfunded. I kept the charging as it is, because charging by merge size would change the behavior the benchmarks measured. The grants-per-batch numbers (125 to 164, against 372 unfunded) are the bound actually observed.
| } | ||
|
|
||
| #[mz_ore::test] | ||
| fn funded_exertion_stops_when_inserted_updates_are_spent() { |
There was a problem hiding this comment.
ci/test/cargo-test-miri.sh (nightly miri-test) runs the whole workspace, timely-util included. This test, closed_input_reduces_without_bound, and frontier_advances_fund_consolidation_of_a_quiet_input each build 2^18 rows and run about 170k exert calls, which will be very slow under miri. They need the same #[cfg_attr(miri, ignore)] as unfunded_turns_finish_merges_in_progress.
|
|
||
| #[rustfmt::skip] | ||
| #[allow(clippy::as_conversions, clippy::needless_pass_by_ref_mut)] | ||
| pub mod spine_fueled; |
There was a problem hiding this comment.
I checked the vendored file: it matches differential-dataflow-0.25.1/src/trace/implementations/spine_fueled.rs exactly, apart from the header and the path rewrite.
Once differential is bumped, upstream spine fixes won't reach this copy, and nothing will flag it. A TODO: pointing to a tracking issue for the fork-branch replacement would help. Better still would be a test that asserts the pinned differential version, so a bump forces someone to look at this file.
There was a problem hiding this comment.
Added a note at the top of the module: the vendored spine is a copy, not a dependency, a differential-dataflow bump doesn't update it, and upstream changes need a manual review on each bump. Since this copy will diverge further, it may not make sense to port upstream one to one, so the note asks for a review rather than a forced sync.
| let funded = self.upper.borrow().is_empty() || self.spend_funding(effort); | ||
| if !funded && !self.merging.iter().any(|b| b.is_double()) { | ||
| // Unfunded: stay quiet until an insert pays. | ||
| return; |
There was a problem hiding this comment.
nit: unlike upstream, this path skips activator.activate(). That's correct, since the next insert reschedules the operator, but worth a comment because it's a difference from upstream.
A separate question: with an open frontier and no inserts, stale upsert retractions now stay unmerged. That costs memory and adds batches for every lookup cursor. Has memory on a quiet source been measured, beyond the steady-state ingest numbers?
There was a problem hiding this comment.
Added the comment. On quiet sources: arrange_core inserts a batch on every frontier advance, empty or not, and each insert banks grants, so a reclocked source that stops receiving data still consolidates. frontier_advances_fund_consolidation_of_a_quiet_input covers it. Only a frontier that stops advancing entirely keeps its batches. Memory on an idle source hasn't been measured on AWS, and I'm adding that to the next benchmark run.
| assert_eq!(batch.lower(), &self.upper); | ||
|
|
||
| self.upper.clone_from(batch.upper()); | ||
| self.consolidation_credit = self.consolidation_credit.saturating_add( |
There was a problem hiding this comment.
nit: consolidation_credit never decays. After hydrating N rows it holds about 8·N·effort fuel, which later idle turns spend down. The total stays proportional to input, so this is fine, but one sentence on the field doc would help.
f9b7ce0 to
bd96fc5
Compare
Estimated impact on the production hydration that started this workThis is an estimate, not a measurement. The stack hasn't run against the production case yet, and that rerun is the next step. The case. One upsert replica with 31 workers, a 252 GB memory limit and one NVMe swap device, hydrating 105 sources. That is 756M records and 1.56 TB of logical state, about 2 KB per record, with three sources holding 90% of it.
What the stack changes for that case.
Scaling the bench to production. Per worker the two are close:
The model. Hydration time is whichever limit is slower:
The same model predicts the bench's device-bound 4xlarge run to within a minute: 57 minutes predicted, 58 measured.
Reading. The stack should turn a hydration that never finished into one that completes, about 1.2 to 2x slower than v1 at the likely compression ratio. Closing the rest of the gap takes fewer bytes per row. A prototype that stores values out of line, so merges move only keys and handles, hydrated the bench's 300 GB seed in 13 minutes against v1's 33, writing 0.4x v1's swap bytes. It is not part of this stack. What would move the estimate.
Written by Claude (Claude Code) from the benchmark notes. |
bd96fc5 to
81957de
Compare
81957de to
9f3032e
Compare
antiguru
left a comment
There was a problem hiding this comment.
Posted by Claude Code on behalf of @antiguru.
I re-checked the fixes on the current head: the funded || merging rewrite, the comment on the skipped reactivation, the miri ignores, the note on the vendored copy, and the docs on the bound being grants rather than work. All look good.
One correction, inline: the new docs say credit funds eight grants per inserted update. At the policy's effort of 1000 it's about one grant per 125 updates, so the bank of eight grants per batch is what bounds small batches.
|
|
||
| /// Fuel each inserted update credits for policy-requested effort, scaled by | ||
| /// the spine's effort multiplier. A granted request spends one `effort`, so | ||
| /// each inserted update funds up to eight grants. The multiple matches the |
There was a problem hiding this comment.
"Funds up to eight grants" only holds if a grant costs 1. The credit per update is 8 * self.effort, and the multiplier defaults to 1 (with_effort(1, …) in new). A grant spends the policy's return value, which is 1000 for the cluster policy (merge_effort = Some(1000) in src/cluster/src/client.rs) and for the test policy. So an inserted update funds about 1/125 of a grant, and a 16-row batch's credit funds none. For small batches, the bound actually comes from the eight banked allowances per batch. Could this doc, and the module doc at funded_spine.rs:30, say that? For example: "one grant per effort / 8 inserted updates, about 125 at the cluster policy's effort of 1000".
There was a problem hiding this comment.
Right, the multiplier is 1 and a grant spends the policy's 1000. So credit funds about one grant per 125 inserted updates, and the eight banked allowances per batch are what bound small batches. I fixed the constant's doc, the module doc and the PR description. My earlier reply on the bound had the same mistake.
| //! quiet until the next insert. Once the input closes, exertion is unbounded | ||
| //! again so the trace reaches the policy's reduced form. | ||
| //! | ||
| //! What funding bounds is the number of granted requests: up to eight per |
There was a problem hiding this comment.
Same issue as on CONSOLIDATION_CREDIT_PER_UPDATE: at the policy's effort of 1000, credit funds about one grant per 125 inserted updates, not eight per update.
9f3032e to
97c3792
Compare
97c3792 to
31ad14a
Compare
A verbatim copy of trace/implementations/spine_fueled.rs from the 0.25.1 release, with only its crate-internal paths rewritten to the differential_dataflow crate and a license header added. The header follows the convention for derived files, naming the MIT-licensed source whose license text is in the root LICENSE. Nothing uses it yet.
arrange_core calls Trace::exert once per operator activation, and each call the exertion policy answers grants a fixed allowance: fuel for an active merge or a virtual introduction that rolls up the layers below it. Nothing ties the number of grants to the amount of input, so optional consolidation scales with how often the operator is scheduled. Enough idle turns between two published batches lift each batch into the largest layer on its own, and merge work per row approaches the largest batch size over the published batch size. On a skewed 105-source upsert hydration the synchronous spine visited 16 to 24 rows per published row and its trials differed by 47%. The vendored spine now pays for policy-requested effort from credit that inserted updates accrue, eight fuel per update as introduce_batch already funds for active merges, and from a bank of allowances that each inserted batch tops up, eight per batch and at most 64 held, since arrange_core inserts batches as its input frontier advances. An unfunded request that would start new work is declined and the spine stays quiet until the next insert. A request that advances a merge already in progress proceeds unfunded: that merge must complete before anything else lands at its level, so finishing it early adds no work, while leaving it stalled holds both of its inputs and its partial output. Funding is still spent first when available, so a funded spine does the same work as before. A closed input, whose upper is the empty frontier, exerts without bound so the trace still reaches the reduced form. The stock Trace interface is unchanged, so the spine drops into arrange_core as is. Both upsert v2 feedback arrangements use it: the chunked flavor through FeedbackSpine and the paged flavor through a FundedValRowSpine alias in mz-row-spine. Compute arrangements keep the stock spine. On a 300 GB incompressible hydration seed at 400cc this rule took the synchronous v2 path from 2.25x behind the RocksDB-backed v1 to 12% to 15% behind at CPU parity, at 127 to 130 policy grants per published batch against 372 unmodified, with two trials 2.4% apart. Tests cover that funded turns never lift small batches into the largest one and go quiet once funding is spent, that unfunded turns still finish a merge in progress, that a closed input reduces to one batch, and that frontier advances alone fund consolidation of a quiet input.
31ad14a to
743a057
Compare
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]>
Motivation
Spine::exertis called once perarrange_coreactivation. When the exertion policy returns effort it grants a fixed allowance, either to an active merge or, with no merge in progress, as a virtual introduction that rolls up the layers below it. Nothing ties the number of grants to the amount of input, so the optional consolidation a spine performs scales with how often its operator is scheduled. Enough idle turns between two published batches lift each batch into the largest layer on its own, and work per row approaches the size of the largest batch divided by the published batch size.On a skewed 105-source upsert hydration (400cc, 8 workers) the unmodified synchronous spine visited 16 to 24 rows per published row and its two trials differed by 47%, which is what cadence-bounded work looks like. With this change, on a 300 GB incompressible seed, two trials landed 2.4% apart at 127 to 130 policy grants per published batch (unmodified: 372), 12% to 15% behind the RocksDB-backed v1 on wall clock at CPU parity, where the synchronous v2 path at the same policy was 2.25x behind. Steady state at 50cc: snapshot 5m22s against 9m38s for plain sync, p95 ingest lag 56 s against 93 s.
Description
Two commits, meant to be read separately.
Vendor the fueled spine.
src/timely-util/src/funded_spine/spine_fueled.rsistrace/implementations/spine_fueled.rsfrom differential-dataflow 0.25.1, byte for byte apart fromcrate::paths rewritten todifferential_dataflow::and a license header. The header follows the convention for derived files (Materialize's header plus a note naming the MIT-licensed source, whose license text is in the rootLICENSE), so the file needs no copyright-lint exclusion. Nothing uses it in this commit. Two clippy lints the upstream file trips are allowed at the module declaration rather than by editing the file.Fund optional exertion from input. Confined to
Spine::exert,Spine::insert, the constructor, two funding fields with their three constants, and two small helpers (grant,spend_funding) thatexertcalls:insertcredits eight fuel units per inserted update, the multipleintroduce_batchalready funds for active merges, and banks eight policy allowances per inserted batch, capped at 64.arrange_coreseals one batch per input frontier advance, so the bank is the progress signal.effort / 8inserted updates from credit, about 125 at the cluster policy's effort of 1000, plus the banked allowances, which are the bound for small batches. It does not bound the work a grant causes. A grant with no merge in progress introduces a virtual batch that can start a merge up into the largest layer, and that merge finishes unfunded. What changes is that new merges are paid for by input rather than by scheduling turns. The measured result is 125 to 164 grants per published batch, against 372 unfunded.exertspends credit first, then a banked allowance, and otherwise declines a request that would start new work and stays quiet. A request that advances a merge already in progress proceeds unfunded: that merge must complete before anything else lands at its level, so finishing it early adds no work, while leaving it stalled holds both inputs and its partial output. Funding is still spent first when available, so a funded spine does the same work it did in the measurements above. Once the input has closed (the spine's upper is the empty frontier) it exerts without bound, so the trace still reaches the policy's reduced form.Traceinterface is unchanged, so the spine drops intoarrange_corewith no operator changes. Both upsert v2 feedback arrangements use it: the chunked flavor viaFeedbackSpine, the paged flavor via aFundedValRowSpinealias added tomz-row-spine. Compute arrangements keep the stock spine.Consequence: an upsert feedback spine whose input is open but not advancing no longer spins to reduced form on its own, though it still finishes any merge in progress. Reclocked sources keep ticking, so this has not mattered in practice.
The vendored copy does not track upstream. A differential-dataflow bump leaves it unchanged, and the module documentation says upstream spine changes need a manual review on each bump. As this copy diverges, not every upstream change will apply.
Verification
New unit tests in
funded_spine/tests.rs: funded turns never lift small batches into the largest one and go quiet once funding is spent, unfunded turns still finish a merge in progress, a closed input reduces to one batch, and frontier advances alone fund consolidation of a quiet input. Existing upsert v2 operator tests exercise both flavors on the new spine.Release note: None.