Skip to content

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

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

compute: a shared-trace primitive for cross-thread arrangement reads#38386
antiguru wants to merge 2 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. Stacks on #38396. Tracked by CPU-215.

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

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. 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, since it is another agent's hold on the same trace and so belongs to the meet the trace already computes, which is what the publisher publishes as since.

Inert: nothing in the crate calls it. The module is pub so that its accessors are reachable for dead-code analysis while the only callers are its own tests.

PublishArrangement requires Tr::Batch: Send + Sync and the tests instantiate it over RowRowSpine, so Arc-backed spines are a compile prerequisite rather than a preference. This PR retargets to main once #38396 merges.

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

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

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

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