Skip to content

compute: a shared-trace primitive for cross-thread arrangement reads - #38386

Open
antiguru wants to merge 12 commits into
mainfrom
mh/interactive-01-shared-trace
Open

compute: a shared-trace primitive for cross-thread arrangement reads#38386
antiguru wants to merge 12 commits into
mainfrom
mh/interactive-01-shared-trace

Conversation

@antiguru

@antiguru antiguru commented Aug 21, 2026

Copy link
Copy Markdown
Member

First of eight PRs splitting #37770. Tracked by CPU-215.

An arrangement is normally readable only from the timely worker that maintains it. Its batches already cross threads, being Arc-backed through mz_row_spine::ArcBatch, but its trace handle is a TraceAgent, which is Rc<RefCell<..>> and pinned to one thread, and its spine does merge work whenever it is touched, including inside roll_up and complete_at, which finish merges synchronously. A spine behind a mutex would hold that mutex for whole merges.

SharedSpine in mz-timely-util is a Trace wrapper that sidesteps this. It owns the inner spine on the arranging worker's thread and, once attached to a publication point, mirrors the spine's batch chain, upper, and compaction frontiers into the point inside every mutation. Batches are immutable and reference counted, so the view is the trace's contents, not a copy, and the lock is held for a chain rebuild, one reference count per spine level, never for merge work. Readers on any thread cut the chain through TraceReader, register compaction holds against the point, and import it as an arrangement with the analogue of TraceAgent::import_frontier_core. The writer applies the meet of its own TraceBox frontier and the readers' holds to the inner spine, so a shared arrangement compacts no faster than its slowest reader, and a reader that moves a hold wakes the arrange operator through a SyncActivator.

Every spine in typedefs is wrapped, so any arrangement can be published. Unattached, the wrapper costs one branch per trace call. The Materialize-side glue is Published, a publication point plus the standing hold (a reader with no physical hold, tracking the frontier the importing runtime has applied), PublishArrangement::adopt, which attaches an arrangement's trace to a point through TraceAgent::trace_box_unstable, and SharedTraceHandle, the reader carrying the publisher's peer count so imports refuse mismatched scopes.

Logical and physical compaction carry different frontiers throughout. Logical decides which times stay distinguishable, physical which batches may merge, and since is never the right physical bound. A reader's physical hold starts at the chain coverage, the frontier through which the published chain is complete, because that is where a seeded reader makes its first cut.

The published since is the trace's own compaction frontier, read off the spine after each mutation, so no controller frontier is forwarded through the registry. The point closes when the trace drops, which is when its last TraceAgent drops. Production keeps one in the trace manager. Tests keep one for as long as they read.

Inert: nothing in the crate calls it. The module is pub(crate) with a single #![allow(dead_code, unused_imports)] carrying a TODO(CPU-215), since crate::sharing arrives in the next PR of the stack. Exporting a crate-internal primitive on the public surface to keep dead-code analysis quiet would be the worse trade.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk

@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from fda6a81 to ebd007b Compare August 21, 2026 13:24
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from ebd007b to 870f336 Compare August 21, 2026 13:42
@antiguru
antiguru requested a review from DAlperin August 21, 2026 13:46
Base automatically changed from mh/interactive-00-arc-spines to main August 21, 2026 14:31
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from 870f336 to b508d89 Compare August 21, 2026 14:31
pull Bot pushed a commit to Arstman/materialize that referenced this pull request Aug 21, 2026
…nt sharing (MaterializeInc#38396)

Replaces MaterializeInc#37881, whose head branch lives on a fork and so cannot be the
base of a stacked PR in this repository. Same commits, same tree, on an
upstream branch instead. This is the root of the stack MaterializeInc#38386 through
MaterializeInc#38393, which splits MaterializeInc#37770.

### Motivation

Cross-runtime arrangement sharing (the two-runtime read-isolation work,
MaterializeInc#37770) needs batches readable from a thread other than the one
maintaining the trace. Differential's default spines reference-count
batches with `Rc`, which is worker-local.

### Description

Introduce `mz_row_spine::ArcBatch`, a local newtype around `Arc<B>` that
carries differential's batch traits (the orphan rule forbids the blanket
impl on a bare `Arc<B>`), and switch the production spines and their
builders — `RowRowSpine`, `RowValSpine`, `RowSpine`, `ValRowSpine`,
`ColValSpine`, `ColKeySpine` — from `Rc`/`RcBuilder` to
`ArcBatch`/`ArcBuilder`. An `Arc`-backed batch whose contents are `Send
+ Sync` can be read across threads, which `Rc` cannot do. Only the batch
handle becomes atomic; the batch contents are unchanged, so the cost is
a marginally more expensive refcount.

Also adds generic `ArcOrdVal`/`ArcOrdKeySpine` aliases for callers
outside `mz_compute`, adapts batch-size logging
(`log_arrangement_size_inner`) to reach through the newtype to the inner
`Arc`, and switches the storage sink trace to the `Arc`-backed spine.

Builds against released differential-dataflow 0.25 with no fork or
`[patch.crates-io]`.

### Verification

`cargo check --workspace` passes with no `Cargo.lock` churn.
`relations.slt`'s golden is rewritten because the spine type name
appears in operator names.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru marked this pull request as ready for review August 21, 2026 16:17
@antiguru
antiguru requested a review from a team as a code owner August 21, 2026 16:17
@def-

def- commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- a live import permanently pins the published arrangement's physical compaction

src/compute/src/shared_trace.rs:1075

The read hold import_snapshot_at retains for the life of the import advances only on the logical axis, and Clone seeds its physical hold from the mint-time since rather than the chain coverage. The accumulated physical frontier therefore sticks at that stale since forever, the publisher never forwards anything higher, and the published spine stops merging: batches pile up in Spine::pending, one per seal, for the life of the import.

Details

Measured on a RowRowSpine published through adopt, with a live import_snapshot_at (until empty) consumed by as_collection, over 40 seal ticks against an identical unimported control:

after register: physical_holds={0: [4]}          coverage_hold=[4]  accumulated=[4]  since=[0]
after import:   physical_holds={2: [0]}          coverage_hold=[4]  accumulated=[0]  since=[0]
end:            physical_holds={2: [0]}          coverage_hold=[40] accumulated=[0]  since=[0]
chain_len: imported=39  control=5

Registration id 2 is the hold clone at shared_trace.rs:1075. register/register_at do install the hold at the chain coverage (id 0 above sits at [4], as shared_trace.rs:151 documents), but Clone at shared_trace.rs:625 writes self.physical into the new registration, and self.physical is initialised to since at shared_trace.rs:482 and shared_trace.rs:537. Nothing then moves it: shared_trace.rs:1167 follows acknowledged on the logical axis only, and the TraceFrontier clone that a join would advance is dropped at build time for every consumer that keeps only the stream. So physical_compaction's meet is pinned at the since observed when the handle was minted, agent.set_physical_compaction joins and cannot be pulled back down, and consider_merges never drains pending again because pending[0].upper() <= physical_frontier stays false.

Cost is unbounded rather than constant: retractions in stranded pending batches never consolidate, and every cursor_through builds a CursorList over a batch count that grows one per seal. On an index with a live shared import and a one-second seal cadence that is thousands of batches per hour.

Suggested fix, verified against the same probe (chain folds to 5, exactly matching the control, and the hold tracks to [40]):

                                 if let Some(hold) = hold.as_mut() {
                                     hold.set_logical_compaction(acknowledged.borrow());
+                                    hold.set_physical_compaction(acknowledged.borrow());
                                 }

acknowledged is the right value on both axes: it is exactly the frontier below which this import will never cut again, which is what the physical hold is supposed to express. Worth separately reconciling Clone at shared_trace.rs:625 with the coverage-seeded hold register/register_at install, since as written a clone silently lowers a registration's physical hold to since and the two paths disagree about the documented invariant at shared_trace.rs:151.

@antiguru

Copy link
Copy Markdown
Member Author

Confirmed and fixed in 285af14. The measurement reproduces exactly: 39 batches against an unimported control's 5, over 40 seals.

Both of the report's points were real, and the second one is the root cause rather than a separate cleanup. A handle carries two physical frontiers that are not interchangeable. The one it reports through get_physical_compaction is seeded at the published since, because a reported frontier may never lead the chain coverage (mz_join_core asserts exactly that). The one it holds is seeded at that coverage, because a merge spanning the coverage destroys the boundary the reader was seeded with. Clone and set_physical_compaction both wrote the reported frontier into the hold, so the hold silently dropped to the weaker value, and since the accumulation is a meet, one such registration is a floor under every other hold.

The import's read hold hit this on both counts: it is a clone, so it registered at since, and it advanced only on the logical axis, so nothing raised it afterwards.

The fix keeps the two frontiers in separate fields, has Clone inherit the hold, has the setter join into both, and takes the suggested set_physical_compaction(acknowledged) on the import's hold. acknowledged is right on the physical axis for the reason given: it is exactly the frontier below which that import will never cut again.

Two regression tests, each verified red without its half of the fix:

  • live_import_does_not_pin_merging — the two-arm chain-length comparison, 39 against 5 before, folding to the control after. One note on reproducing it: the minting handle has to be dropped after building the import, as render::import_shared_index does. A live mint holds its own coverage-seeded registration and pins the floor by itself, which masks the bug under test. My first attempt at this test failed for that reason rather than the one it was written for.
  • clone_inherits_the_hold_not_the_reported_frontier — asserts the registered hold directly, since the reported frontier cannot be read back through TraceReader. This is what pins the Clone half; the merge test alone does not, because the added physical advance repairs that scenario regardless of what Clone seeded.

The coverage_hold this PR already gives the publisher caps the forwarded physical frontier at the chain coverage, so advancing a reader hold to acknowledged cannot push the forwarded value past what the chain carries even though acknowledged tracks the stream frontier, which leads the coverage by up to a scheduling round.

The stack above this PR is rebased and pushed. Full run at the tip: 124 tests, workspace cargo check --all-targets, clippy, rustdoc with -D warnings.

(Posted by Claude Code.)

antiguru added a commit that referenced this pull request Aug 26, 2026
Several modules in `mz-compute` carry test modules many times the size
of the production code they cover, so the code has to be scrolled past
to read. `src/cluster-controller` already uses the out-of-line pattern,
where `#[cfg(test)] mod tests;` points at a sibling `tests.rs`. This
records that as the crate convention, with a threshold so it is
decidable rather than a matter of taste.

Out-of-line tests still reach private items through `super::`, so moving
a module needs no visibility changes.

Worth landing before #38386 and the seven PRs stacked on it, which
follow this rule and between them move about 5,900 lines of in-file test
modules out of line.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
antiguru added a commit that referenced this pull request Aug 28, 2026
A handle carries two physical frontiers, and they are not interchangeable. The
one it reports through `get_physical_compaction` is seeded at the published
`since`, because a reported frontier may never lead the chain coverage. The one
it holds is seeded at that coverage, because a merge spanning the coverage
destroys the boundary the reader was seeded with.

`Clone` and the setter both wrote the reported frontier into the hold, which
silently lowers it. Since the accumulation is a meet, one such registration is a
floor under every other hold, so the published spine stops merging: batches pile
up in `Spine::pending`, one per seal, for as long as that registration lives.
The cost is unbounded rather than constant, since retractions in stranded
batches never consolidate and every `cursor_through` builds a `CursorList` over
all of them.

An import's read hold hit this on both counts. It is a clone, so it registered
at `since`, and it advanced only on the logical axis, so nothing ever raised it.
Measured against an unimported control over 40 seals: 39 batches against 5.

Keep the two frontiers in separate fields, have `Clone` inherit the hold, have
the setter join into both, and advance the import's hold on both axes.
`acknowledged` is the right value for the physical axis too: it is exactly the
frontier below which that import will never cut again.

Reported by the QA LLM review on #38386.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from 285af14 to e953639 Compare August 28, 2026 14:05
antiguru added a commit that referenced this pull request Sep 3, 2026
A handle carries two physical frontiers, and they are not interchangeable. The
one it reports through `get_physical_compaction` is seeded at the published
`since`, because a reported frontier may never lead the chain coverage. The one
it holds is seeded at that coverage, because a merge spanning the coverage
destroys the boundary the reader was seeded with.

`Clone` and the setter both wrote the reported frontier into the hold, which
silently lowers it. Since the accumulation is a meet, one such registration is a
floor under every other hold, so the published spine stops merging: batches pile
up in `Spine::pending`, one per seal, for as long as that registration lives.
The cost is unbounded rather than constant, since retractions in stranded
batches never consolidate and every `cursor_through` builds a `CursorList` over
all of them.

An import's read hold hit this on both counts. It is a clone, so it registered
at `since`, and it advanced only on the logical axis, so nothing ever raised it.
Measured against an unimported control over 40 seals: 39 batches against 5.

Keep the two frontiers in separate fields, have `Clone` inherit the hold, have
the setter join into both, and advance the import's hold on both axes.
`acknowledged` is the right value for the physical axis too: it is exactly the
frontier below which that import will never cut again.

Reported by the QA LLM review on #38386.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from e953639 to e295c50 Compare September 3, 2026 08:48
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
Comment thread src/compute/src/shared_trace.rs Outdated
antiguru added a commit that referenced this pull request Sep 3, 2026
Review feedback on #38386.

A handle carried two physical frontiers, one it reported and one it held.
Reporting the chain coverage satisfies the only consumer that reads the
frontier back, `mz_join_core`, whose assertion compares it against the coverage
it derives from `map_batches`. So the two collapse into one field seeded at the
coverage, and the class of bug the split was guarding against stops being
expressible.

The publication point also kept a `BTreeMap` of per-registration holds on each
axis, solely to recover the previous value when computing a delta into the
accumulation. The handle already owns that value, so the maps go and the
setters pass `previous` explicitly. A handle no longer needs a registration id
at all; ids now serve only importer queues. `coverage_hold` goes the same way:
the publisher falls back to the chain coverage when the accumulation is empty,
which is the shape the logical axis already used.

Test-only surface moves into `shared_trace/tests.rs`, which reaches private
items through `super::`. `snapshot_at` gains a deadline so a wedged publisher
fails with the frontier it stalled on rather than hanging.

`Tr::Time: TotalOrder` is now a bound on the `TraceReader` impl, so
`batches_through` stopping at the first batch beyond the cut rests on a stated
property rather than a comment. The module is `pub(crate)` with an explicit
`allow(dead_code)`, rather than `pub` to keep dead-code analysis quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@def-

def- commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- empty_logical_request_releases_the_hold can no longer fail

src/compute/src/shared_trace/tests.rs:687

Rewriting the assertion against the accumulated MutableAntichain made it vacuous: the handle registers at since and the standing hold sits at the same minimum time, so logical_holds() == standing_hold() holds identically whether or not the empty request released anything. The test named for the release property can no longer observe it, and no other test in the file does either.

Details

Verified by mutation. With set_logical_compaction changed to ignore an empty request outright, so the handle's hold is never released:

let next = self.logical.join(&frontier.to_owned());
if next.is_empty() { return; }   // hold is never released

all 17 shared_trace tests still pass. The concealed regression is real: an unreleased logical hold sits in logical_compaction for the life of the handle, the publisher forwards that frontier to its agent every activation, and the published arrangement stops compacting past it. That is exactly what the reduce operator triggers on every dataflow whose input finishes, per the test's own doc.

The accumulation is a meet, so a single hold at or above the standing hold is unobservable through logical_holds(). Releasing the standing hold first makes the accumulation carry the handle's contribution alone:

published.note_standing_hold(&Antichain::new());
assert!(published.logical_holds().is_empty());

let mut hold = published.handle();
assert!(!published.logical_holds().is_empty());
hold.set_logical_compaction(Antichain::new().borrow());
assert_eq!(published.logical_holds(), Antichain::new(), "...");

That version passes on the current code and fails on the mutation above.

@linear-code

linear-code Bot commented Sep 3, 2026

Copy link
Copy Markdown

CPU-215

@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch 2 times, most recently from a38f3a0 to 18d0897 Compare September 3, 2026 15:41
antiguru added a commit that referenced this pull request Sep 4, 2026
A handle carries two physical frontiers, and they are not interchangeable. The
one it reports through `get_physical_compaction` is seeded at the published
`since`, because a reported frontier may never lead the chain coverage. The one
it holds is seeded at that coverage, because a merge spanning the coverage
destroys the boundary the reader was seeded with.

`Clone` and the setter both wrote the reported frontier into the hold, which
silently lowers it. Since the accumulation is a meet, one such registration is a
floor under every other hold, so the published spine stops merging: batches pile
up in `Spine::pending`, one per seal, for as long as that registration lives.
The cost is unbounded rather than constant, since retractions in stranded
batches never consolidate and every `cursor_through` builds a `CursorList` over
all of them.

An import's read hold hit this on both counts. It is a clone, so it registered
at `since`, and it advanced only on the logical axis, so nothing ever raised it.
Measured against an unimported control over 40 seals: 39 batches against 5.

Keep the two frontiers in separate fields, have `Clone` inherit the hold, have
the setter join into both, and advance the import's hold on both axes.
`acknowledged` is the right value for the physical axis too: it is exactly the
frontier below which that import will never cut again.

Reported by the QA LLM review on #38386.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
antiguru added a commit that referenced this pull request Sep 4, 2026
Review feedback on #38386.

A handle carried two physical frontiers, one it reported and one it held.
Reporting the chain coverage satisfies the only consumer that reads the
frontier back, `mz_join_core`, whose assertion compares it against the coverage
it derives from `map_batches`. So the two collapse into one field seeded at the
coverage, and the class of bug the split was guarding against stops being
expressible.

The publication point also kept a `BTreeMap` of per-registration holds on each
axis, solely to recover the previous value when computing a delta into the
accumulation. The handle already owns that value, so the maps go and the
setters pass `previous` explicitly. A handle no longer needs a registration id
at all; ids now serve only importer queues. `coverage_hold` goes the same way:
the publisher falls back to the chain coverage when the accumulation is empty,
which is the shape the logical axis already used.

Test-only surface moves into `shared_trace/tests.rs`, which reaches private
items through `super::`. `snapshot_at` gains a deadline so a wedged publisher
fails with the frontier it stalled on rather than hanging.

`Tr::Time: TotalOrder` is now a bound on the `TraceReader` impl, so
`batches_through` stopping at the first batch beyond the cut rests on a stated
property rather than a comment. The module is `pub(crate)` with an explicit
`allow(dead_code)`, rather than `pub` to keep dead-code analysis quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from 18d0897 to 7591e7b Compare September 4, 2026 15:41
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from 7591e7b to fb5a031 Compare September 4, 2026 17:37
antiguru added a commit that referenced this pull request Sep 4, 2026
A handle carries two physical frontiers, and they are not interchangeable. The
one it reports through `get_physical_compaction` is seeded at the published
`since`, because a reported frontier may never lead the chain coverage. The one
it holds is seeded at that coverage, because a merge spanning the coverage
destroys the boundary the reader was seeded with.

`Clone` and the setter both wrote the reported frontier into the hold, which
silently lowers it. Since the accumulation is a meet, one such registration is a
floor under every other hold, so the published spine stops merging: batches pile
up in `Spine::pending`, one per seal, for as long as that registration lives.
The cost is unbounded rather than constant, since retractions in stranded
batches never consolidate and every `cursor_through` builds a `CursorList` over
all of them.

An import's read hold hit this on both counts. It is a clone, so it registered
at `since`, and it advanced only on the logical axis, so nothing ever raised it.
Measured against an unimported control over 40 seals: 39 batches against 5.

Keep the two frontiers in separate fields, have `Clone` inherit the hold, have
the setter join into both, and advance the import's hold on both axes.
`acknowledged` is the right value for the physical axis too: it is exactly the
frontier below which that import will never cut again.

Reported by the QA LLM review on #38386.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
antiguru added a commit that referenced this pull request Sep 4, 2026
Review feedback on #38386.

A handle carried two physical frontiers, one it reported and one it held.
Reporting the chain coverage satisfies the only consumer that reads the
frontier back, `mz_join_core`, whose assertion compares it against the coverage
it derives from `map_batches`. So the two collapse into one field seeded at the
coverage, and the class of bug the split was guarding against stops being
expressible.

The publication point also kept a `BTreeMap` of per-registration holds on each
axis, solely to recover the previous value when computing a delta into the
accumulation. The handle already owns that value, so the maps go and the
setters pass `previous` explicitly. A handle no longer needs a registration id
at all; ids now serve only importer queues. `coverage_hold` goes the same way:
the publisher falls back to the chain coverage when the accumulation is empty,
which is the shape the logical axis already used.

Test-only surface moves into `shared_trace/tests.rs`, which reaches private
items through `super::`. `snapshot_at` gains a deadline so a wedged publisher
fails with the frontier it stalled on rather than hanging.

`Tr::Time: TotalOrder` is now a bound on the `TraceReader` impl, so
`batches_through` stopping at the first batch beyond the cut rests on a stated
property rather than a comment. The module is `pub(crate)` with an explicit
`allow(dead_code)`, rather than `pub` to keep dead-code analysis quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from fb5a031 to 984163c Compare September 4, 2026 19:37
@antiguru
antiguru requested a review from petrosagg September 4, 2026 19:39
@antiguru

antiguru commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

Confirmed and fixed in c59f8e4. The test now releases the standing hold first, so the accumulation carries the handle's contribution alone, and it fails under the mutation described above.

The same commit moves SharedTraceHandle::frontiers, which had no production caller, into the test module.

Posted by Claude Code.

Comment thread src/compute/src/shared_trace/handle.rs Outdated
Comment on lines +330 to +331
let mut capabilities = Some(CapabilitySet::new());
capabilities.as_mut().unwrap().insert(capability);

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.

We could turn capabilities into a blank CapabilitySet, without wrapping it in an Option: if it is empty (capabilities.is_empty() through the Deref impl) should be equivalent to the current let Some(...) = capabilities.as_mut(), and the None assignment equivalent to downgrading to the empty frontier. This would remove some implementation complexity.

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. capabilities is a CapabilitySet::from_elem(capability), the activation guard asks is_empty(), and the bound check downgrades to the empty frontier instead of assigning None. The as_mut().unwrap() and the extra nesting are gone.

Posted by Claude Code.

antiguru added a commit that referenced this pull request Sep 7, 2026
A handle carries two physical frontiers, and they are not interchangeable. The
one it reports through `get_physical_compaction` is seeded at the published
`since`, because a reported frontier may never lead the chain coverage. The one
it holds is seeded at that coverage, because a merge spanning the coverage
destroys the boundary the reader was seeded with.

`Clone` and the setter both wrote the reported frontier into the hold, which
silently lowers it. Since the accumulation is a meet, one such registration is a
floor under every other hold, so the published spine stops merging: batches pile
up in `Spine::pending`, one per seal, for as long as that registration lives.
The cost is unbounded rather than constant, since retractions in stranded
batches never consolidate and every `cursor_through` builds a `CursorList` over
all of them.

An import's read hold hit this on both counts. It is a clone, so it registered
at `since`, and it advanced only on the logical axis, so nothing ever raised it.
Measured against an unimported control over 40 seals: 39 batches against 5.

Keep the two frontiers in separate fields, have `Clone` inherit the hold, have
the setter join into both, and advance the import's hold on both axes.
`acknowledged` is the right value for the physical axis too: it is exactly the
frontier below which that import will never cut again.

Reported by the QA LLM review on #38386.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
antiguru added a commit that referenced this pull request Sep 7, 2026
Review feedback on #38386.

A handle carried two physical frontiers, one it reported and one it held.
Reporting the chain coverage satisfies the only consumer that reads the
frontier back, `mz_join_core`, whose assertion compares it against the coverage
it derives from `map_batches`. So the two collapse into one field seeded at the
coverage, and the class of bug the split was guarding against stops being
expressible.

The publication point also kept a `BTreeMap` of per-registration holds on each
axis, solely to recover the previous value when computing a delta into the
accumulation. The handle already owns that value, so the maps go and the
setters pass `previous` explicitly. A handle no longer needs a registration id
at all; ids now serve only importer queues. `coverage_hold` goes the same way:
the publisher falls back to the chain coverage when the accumulation is empty,
which is the shape the logical axis already used.

Test-only surface moves into `shared_trace/tests.rs`, which reaches private
items through `super::`. `snapshot_at` gains a deadline so a wedged publisher
fails with the frontier it stalled on rather than hanging.

`Tr::Time: TotalOrder` is now a bound on the `TraceReader` impl, so
`batches_through` stopping at the first batch beyond the cut rests on a stated
property rather than a comment. The module is `pub(crate)` with an explicit
`allow(dead_code)`, rather than `pub` to keep dead-code analysis quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from c59f8e4 to 2791a39 Compare September 7, 2026 09:35
Comment thread src/compute/src/shared_trace/publish.rs Outdated
// already holds. The stream frontier still drives the published `upper` and the
// incremental `Frontier` instructions below, which is where it is authoritative.
let mut chain = Vec::new();
agent.map_batches(|batch| chain.push(batch.clone()));

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.

We're not removing elements from chain, which means that we might retain batches that will be merged by the maintenance runtime, but kept alive here. This would be a memory problem.

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.

The chain is not appended to. Every activation rebuilds it from agent.map_batches and assigns state.chain = chain, so a batch the spine has merged away leaves the published chain at the publisher's next activation. Readers hold only Arc<SharedTrace>, and a cursor clones the chain for the duration of one read.

Two retention windows did exist, one of them systematic. The publisher read the chain, published it, and then called agent.set_physical_compaction, which can complete merges synchronously. The chain it had just published therefore named the pre-merge batches until the next activation, on every activation where the forwarded floor let the spine merge. That is fixed: the chain is re-read after the compaction call.

The other window is merges the arrange operator finishes on its own between publisher activations, through the activator the spine holds. Nothing downstream observes those, so the published chain pins that spine's pre-merge batches until the next stream or frontier event, which for a maintained collection is the next tick. For a collection whose upper has stopped moving there is no next tick, so the pre-merge set stays pinned alongside the merged batch until the dataflow drops. Bounded at one extra copy of that arrangement, and only for sealed collections. Closing it would need the publisher to poll while the chain has more than one batch, which I have not done.

Importer queues hold their batches until the importer drains, which is the unbounded-queue caveat already in the design.

Posted by Claude Code.

@antiguru

antiguru commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

The publisher changed shape in the latest push, so the earlier review threads on publish.rs (chain retention, stream versus trace upper, forwarding of the controller's frontier) describe code that no longer exists.

The sink operator is gone. SharedSpine in mz-timely-util is a Trace wrapper: every spine in typedefs is one, and once an arrangement is attached to a publication point the trace mirrors its chain, upper, and compaction frontiers into the point inside each mutation. The published since is the spine's own frontier, read back after the TraceBox applies the meet of the local agents' holds and the readers' holds, so nothing is forwarded through the registry any more (note_allow_compaction and Diagnostics::writer_logical are deleted in the later PRs). A mutex around the spine itself was rejected because roll_up and complete_at finish merges synchronously, so such a lock is held for whole merges; measured on a churn workload, writer mutations peaked at 276 ms while readers of the wrapper acquired cursors in 1.5 µs at p99.

Consequences elsewhere in the stack: PR 04 and PR 08 lose the publisher operator from the introspection goldens, and the arrangement-size doubling in introspection-sources.td went with the sink. Tests keep a TraceAgent alive while they read, since the point now closes when the trace drops, which is where the trace manager holds it in production.

Posted by Claude Code.

use crate::typedefs::{RowRowAgent, RowRowEnter};
use mz_row_spine::{RowRowBuilder, RowRowColPagedBuilder, RowRowSpine};
use crate::typedefs::{RowRowAgent, RowRowEnter, RowRowSpine};
use mz_row_spine::{RowRowBuilder, RowRowColPagedBuilder};

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.

Fix import order.

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, mz_row_spine moved up into the external group after mz_repr. The same misplacement was already on main in this file; fixed since the line was touched anyway.

Posted by Claude Code.

Comment thread src/compute/src/render/top_k.rs Outdated
use crate::typedefs::{
ErrBatcher, ErrBuilder, KeyBatcher, MzTimestamp, RowRowSpine, RowSpine, RowValSpine,
};
use mz_row_spine::{

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.

Fix import order.

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, same move as in linear_join.rs.

Posted by Claude Code.

antiguru and others added 11 commits September 7, 2026 20:17
An arrangement is normally readable only from the timely worker that maintains
it, because its batches are `Rc`-backed and its trace handle is neither `Send`
nor `Sync`. This adds a publication point that carries `Arc`-backed batches
together with the trace's `since` and `upper`, so a reader on any thread can
mint a `Send` handle for the same arrangement and import it as a snapshot at a
chosen `as_of`. Nothing in the crate calls it yet, so the module is inert: it
compiles, its unit tests exercise publish, import, seal, and compaction
holdback, and no rendered dataflow reaches it.

A publication point is differential's `TraceBox` for readers that are not agents
of the trace. It accumulates their holds in a `MutableAntichain` per axis and
each handle adjusts that accumulation as a delta, the way a `TraceAgent` does,
which costs the times that changed rather than a walk over every hold. Two
special cases go with it: an empty request contributes nothing instead of having
to be filtered out, and there is no zero-holds case to fall back from. The
standing hold and the publisher's own hold at the chain coverage are ordinary
holds in those accumulations, so a shared arrangement compacting no faster than
the slowest runtime's command stream follows from a registered hold rather than
from an invariant asserted after the fact.

The controller's own frontier stays out of the accumulation. It is another
agent's hold on the same trace, so it belongs to the meet the trace already
computes, which is what the publisher publishes as `since`.

The concrete `SharedOks*`/`SharedErrs*` type aliases live here rather than
alongside the registry that will consume them. They name a shared-trace handle
over `RowRowSpine` and `ErrSpine` and mention no registry type, so this is where
they belong.

`Published::diagnostics`, `note_writer_logical`, and `note_standing_hold` are
`pub` like the rest of the type's accessors. Scoping them to the crate would
make them unreachable for dead-code analysis while the only callers are the
tests.

Tests are out of line in `shared_trace/tests.rs`, per the convention in
`src/compute/AGENTS.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A handle carries two physical frontiers, and they are not interchangeable. The
one it reports through `get_physical_compaction` is seeded at the published
`since`, because a reported frontier may never lead the chain coverage. The one
it holds is seeded at that coverage, because a merge spanning the coverage
destroys the boundary the reader was seeded with.

`Clone` and the setter both wrote the reported frontier into the hold, which
silently lowers it. Since the accumulation is a meet, one such registration is a
floor under every other hold, so the published spine stops merging: batches pile
up in `Spine::pending`, one per seal, for as long as that registration lives.
The cost is unbounded rather than constant, since retractions in stranded
batches never consolidate and every `cursor_through` builds a `CursorList` over
all of them.

An import's read hold hit this on both counts. It is a clone, so it registered
at `since`, and it advanced only on the logical axis, so nothing ever raised it.
Measured against an unimported control over 40 seals: 39 batches against 5.

Keep the two frontiers in separate fields, have `Clone` inherit the hold, have
the setter join into both, and advance the import's hold on both axes.
`acknowledged` is the right value for the physical axis too: it is exactly the
frontier below which that import will never cut again.

Reported by the QA LLM review on #38386.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #38386.

A handle carried two physical frontiers, one it reported and one it held.
Reporting the chain coverage satisfies the only consumer that reads the
frontier back, `mz_join_core`, whose assertion compares it against the coverage
it derives from `map_batches`. So the two collapse into one field seeded at the
coverage, and the class of bug the split was guarding against stops being
expressible.

The publication point also kept a `BTreeMap` of per-registration holds on each
axis, solely to recover the previous value when computing a delta into the
accumulation. The handle already owns that value, so the maps go and the
setters pass `previous` explicitly. A handle no longer needs a registration id
at all; ids now serve only importer queues. `coverage_hold` goes the same way:
the publisher falls back to the chain coverage when the accumulation is empty,
which is the shape the logical axis already used.

Test-only surface moves into `shared_trace/tests.rs`, which reaches private
items through `super::`. `snapshot_at` gains a deadline so a wedged publisher
fails with the frontier it stalled on rather than hanging.

`Tr::Time: TotalOrder` is now a bound on the `TraceReader` impl, so
`batches_through` stopping at the first batch beyond the cut rests on a stated
property rather than a comment. The module is `pub(crate)` with an explicit
`allow(dead_code)`, rather than `pub` to keep dead-code analysis quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Complexity pass over the module, no behaviour change.

`TraceReader` already declares `type Time: Timestamp + Lattice`, and timely's
`Timestamp` implies `Clone`, `Send`, and `'static`. Every `Tr::Time: Lattice +
Clone` clause here was therefore vacuous. Dropping them leaves only the bounds
that constrain something, `TotalOrder` and `Sync`, and removes a real gotcha:
`Drop` could not repeat the vacuous bound, so it reached past the state's own
API to adjust the accumulations directly. It now calls the movers like every
other caller.

`SharedTraceHandle::writer_logical` had no caller, and `Published::diagnostics`
already returns the same frontier from the publication point, which its own doc
argues is the right place to read it from. `PublishArrangement::adopt_named` had
no caller either; its only invocation was `adopt` forwarding a literal.

Comments: several facts were owned by two or three places at once. The choice of
physical seed now lives only at `register_at`, the standing hold's seed only at
`adopt`, the pairwise-peers invariant only on `SharedTrace::peers`, and the
lost-wakeup argument only at the `on_seal` call. Each remaining copy points at
the owner. Also dropped two references that no longer resolve, one to a helper
that moved into the test module and one chronology clause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Importer queues now follow differential's listener pattern: the importer owns
the only `Arc<ImportQueue>`, the publication point holds a `Weak`, and the
publisher prunes dangling entries as it walks them. Registration ids, the
monotonic counter that minted them, and the `QueueGuard` that removed entries by
id all go. Each queue carries its own lock, so an importer draining on the
reader's worker no longer takes the lock the publisher holds while it rebuilds
the chain. Lock order stays acyclic: the publisher takes the state lock and then
a queue's, an importer takes only a queue's.

`adopt` takes a name and builds `PublishShared({name})`. Every publisher
previously shared one operator name, which left timely-log introspection unable
to tell an index publisher from a logging one.

`diagnostics` returns a named `Diagnostics` rather than a four-tuple. Its `since`
duplicated `handle_at`'s `Err` and nothing reasoned about its `upper`, so both
are gone and the two remaining fields carry the docs that justify them.

`Published::placeholder` becomes `Published::new`. It is the only constructor,
but the name and its doc read as one of several paths and sent a reader looking
for an eager one.

The module splits into `state`, `publish`, and `handle`: the shared state and
its frontier arithmetic, the owner-facing API with the publisher operator, and
the `Send` reader handle with the import operator. The seam is only viable after
the queue change, which is what removed the reader half's reach into the queue
map. `shared_trace.rs` keeps the module doc, the type aliases, and the
re-exports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`empty_logical_request_releases_the_hold` compared the accumulated logical
holds against the standing hold, which sit at the same minimum time, so the
assertion held whether or not the empty request released anything. Releasing
the standing hold first leaves the handle's contribution as the whole
accumulation, and the test now fails when the setter ignores an empty request.

`SharedTraceHandle::frontiers` had no production caller, so it moves to the
test module beside the other probes. Three comments lose a design-doc path
that is not in the tree, an invariant label from that document, and a sentence
about an earlier revision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
An empty `CapabilitySet` already means the read is over, so the `Option`
around it only added an unwrap and a second level of nesting. The bound check
now downgrades the set to the empty frontier instead of replacing it with
`None`, and the activation guard asks `is_empty`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
The publisher read the chain from the trace, published it, and then forwarded
the readers' holds to the agent. `set_physical_compaction` can complete merges
synchronously, and every batch the spine dropped that way stayed alive for as
long as the published chain still named it, which was until the next
activation. The chain is now re-read after the compaction call, so the
publication pins pre-merge batches only for merges the arrange operator
finishes on its own between activations, and those for at most one activation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
The publisher was a sink on the arrangement stream that re-read the trace's
chain on its own schedule, computed `since` as a meet it could only
approximate, and forwarded readers' holds through a `TraceAgent` of its own.
Everything it published lagged the trace by an activation, which is where the
chain-retention and stream-versus-trace-upper cases came from.

`SharedSpine`, a `Trace` wrapper in `mz-timely-util`, replaces it. Every spine
in `typedefs` is wrapped. Once attached to a publication point, the trace
mirrors its chain, upper, and compaction frontiers into the point inside each
mutation, and applies the meet of its `TraceBox` frontier and the readers'
holds to the inner spine. The lock is held for a chain rebuild, never for merge
work, which a lock around the spine itself could not avoid: `roll_up` and
`complete_at` finish merges synchronously.

`Published` keeps the standing hold as a reader with no physical hold, and
`SharedTraceHandle` wraps the primitive's reader with the publisher's peer
count so imports keep refusing mismatched scopes. `note_writer_logical` and
`Diagnostics::writer_logical` are gone: the published `since` is the trace's
own frontier.

Tests keep an agent alive where the sink's agent clone used to, and advance its
physical compaction where the trace manager would. The two tests of the sink's
two-source frontier feed and the queue-injection test of a duplicated seed
batch are dropped with the code they tested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
@antiguru
antiguru force-pushed the mh/interactive-01-shared-trace branch from 9207827 to 35172f8 Compare September 7, 2026 18:18
@def-

def- commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- the published since is written in a second lock acquisition, so a reader can latch a frontier the trace has already compacted past

src/timely-util/src/shared_trace.rs:389

apply_holds reads the readers' holds under the point's lock, releases it, and only then advances the inner trace's logical frontier and runs the merges that coalesce to it. state.logical is written afterwards, by a second acquisition in publish_frontiers/publish_chain. For the whole of that gap, which spans the merge (the PR's own measurement puts writer mutations at up to 276 ms), the point still advertises the pre-compaction since, so a reader thread can mint a handle at an as_of whose times the spine is coalescing away and read silently wrong data there.

Details

The gap is explicit in the code: shared_trace.rs:393-400 reads remote_logical/remote_physical and drops the guard; 404 applies the new logical frontier to the inner trace; 411-415 then applies the physical frontier, whose Spine::consider_merges completes merges synchronously, advancing update times to the logical frontier just set. Only after all of that does publish_frontiers (shared_trace.rs:328) or publish_chain (shared_trace.rs:345) take the lock again and write state.logical.

Shared::reader_at (shared_trace.rs:216) gates on state.logical and documents that "Checks and registers under one lock acquisition, so a returned reader holds a frontier the trace can still honour". That holds against the published value, but the published value is the stale one for the duration above, so Published::handle_at returns Ok for an as_of in [since_old, since_new).

The effect outlives the window. The reader registers its hold at the stale frontier in remote_logical, so the next apply_holds computes meet(local, stale) and calls inner.set_logical_compaction with a frontier below the one already applied. Spine::set_logical_compaction accepts a rewind without complaint (unlike the physical axis, which apply_holds:411 guards), so the published since regresses to a value the trace can no longer honour and stays there for the life of that handle.

The publisher this replaces did not have the gap: it read the accumulated holds and wrote state.since inside one acquisition and applied the result to its agent afterwards, so a reader either was counted in that round's meet or observed the new since.

Fix: compute both target frontiers and write state.logical in the same acquisition in which the holds are read, before touching the inner trace. publish_frontiers then has nothing left to do on the logical axis.

…tomically

Two defects in `SharedSpine`, both reachable from the same file.

`attach` replaced any earlier attachment, so a trace published under several ids
backed only the last one. The logging dataflow arranges one shared, permanently
empty error collection and publishes it under every log index's id, so every log
index but one was left with an errs point frozen at the minimum frontier. An
importer waits on both halves of an index, so a read of any introspection relation
on the interactive runtime parked forever: the oks half reached `until` and the
errs half never advanced. Attachments become a list, keyed by point identity, and
`publish_chain`, `publish_frontiers` and `Drop` fan out across it.

`apply_holds` read the readers' holds under the point's lock, released it, then
advanced the inner trace and ran the merges that coalesce to the new frontier,
publishing the result only in a later acquisition. Throughout that gap the point
advertised a `since` the trace had already compacted past, so `reader_at` could
admit an `as_of` whose times were being merged away, and the reader then registered
a hold at the stale frontier that pulled the applied frontier back down. Reading
the holds and publishing the frontier they produce now happen under one
acquisition, before the inner trace is touched, which is what the sink-operator
publisher this replaced did. Points are locked in address order so two traces
sharing a pair of them cannot deadlock.

The holds meet runs across every attached point, since a reader of any of them
holds this one trace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tLhSbZdXrTSK2KwSocT59
@antiguru

antiguru commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Confirmed and fixed in fb18e16.

The report is right about both the window and the durable effect. apply_holds now takes one acquisition: it reads remote_logical/remote_physical, computes the meet against the local frontiers, and writes state.logical under that same guard, before the inner trace is touched at all. A reader that registers before us is counted in the meet; one that registers after sees the frontier we are about to apply, and reader_at refuses an as_of below it. So the interval where the point advertised a since the spine was coalescing away is gone, and with it the stale hold that pulled the applied frontier back down.

Publishing the target before applying it means the point can briefly advertise a since ahead of the trace's own. That refuses a reader the trace could still have served, which is the safe direction; the reverse is the one the report describes.

One thing the fix had to add that the report does not mention. A single trace can back several publication points, since one arrangement is published under every id that re-exports it, so the meet runs across all of them and they are now locked together. They are acquired in address order to make that deadlock-free, and the critical section is antichain arithmetic only, never merge work.

Posted by Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants