compute: a shared-trace primitive for cross-thread arrangement reads - #38386
compute: a shared-trace primitive for cross-thread arrangement reads#38386antiguru wants to merge 12 commits into
Conversation
fda6a81 to
ebd007b
Compare
ebd007b to
870f336
Compare
870f336 to
b508d89
Compare
…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>
QA LLM Review1. MEDIUM -- a live import permanently pins the published arrangement's physical compaction
The read hold DetailsMeasured on a Registration id 2 is the Cost is unbounded rather than constant: retractions in stranded Suggested fix, verified against the same probe (chain folds to 5, exactly matching the control, and the hold tracks to if let Some(hold) = hold.as_mut() {
hold.set_logical_compaction(acknowledged.borrow());
+ hold.set_physical_compaction(acknowledged.borrow());
}
|
|
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 The import's read hold hit this on both counts: it is a clone, so it registered at The fix keeps the two frontiers in separate fields, has Two regression tests, each verified red without its half of the fix:
The The stack above this PR is rebased and pushed. Full run at the tip: 124 tests, workspace (Posted by Claude Code.) |
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>
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>
285af14 to
e953639
Compare
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>
e953639 to
e295c50
Compare
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>
QA LLM Review1. MEDIUM --
|
a38f3a0 to
18d0897
Compare
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>
18d0897 to
7591e7b
Compare
7591e7b to
fb5a031
Compare
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>
fb5a031 to
984163c
Compare
|
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 Posted by Claude Code. |
| let mut capabilities = Some(CapabilitySet::new()); | ||
| capabilities.as_mut().unwrap().insert(capability); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
c59f8e4 to
2791a39
Compare
| // 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())); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
The publisher changed shape in the latest push, so the earlier review threads on The sink operator is gone. Consequences elsewhere in the stack: PR 04 and PR 08 lose the publisher operator from the introspection goldens, and the arrangement-size doubling in 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}; |
There was a problem hiding this comment.
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.
| use crate::typedefs::{ | ||
| ErrBatcher, ErrBuilder, KeyBatcher, MzTimestamp, RowRowSpine, RowSpine, RowValSpine, | ||
| }; | ||
| use mz_row_spine::{ |
There was a problem hiding this comment.
Done, same move as in linear_join.rs.
Posted by Claude Code.
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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
9207827 to
35172f8
Compare
QA LLM Review1. MEDIUM -- the published
|
…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
|
Confirmed and fixed in fb18e16. The report is right about both the window and the durable effect. Publishing the target before applying it means the point can briefly advertise a 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. |
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 throughmz_row_spine::ArcBatch, but its trace handle is aTraceAgent, which isRc<RefCell<..>>and pinned to one thread, and its spine does merge work whenever it is touched, including insideroll_upandcomplete_at, which finish merges synchronously. A spine behind a mutex would hold that mutex for whole merges.SharedSpineinmz-timely-utilis aTracewrapper 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 throughTraceReader, register compaction holds against the point, and import it as an arrangement with the analogue ofTraceAgent::import_frontier_core. The writer applies the meet of its ownTraceBoxfrontier 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 aSyncActivator.Every spine in
typedefsis wrapped, so any arrangement can be published. Unattached, the wrapper costs one branch per trace call. The Materialize-side glue isPublished, 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 throughTraceAgent::trace_box_unstable, andSharedTraceHandle, 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
sinceis 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
sinceis 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 lastTraceAgentdrops. 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 aTODO(CPU-215), sincecrate::sharingarrives 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