Skip to content

[dbsp] Publish accumulated outputs atomically across workers#6589

Merged
ryzhyk merged 1 commit into
mainfrom
accumulate-output-cohort-release
Jul 15, 2026
Merged

[dbsp] Publish accumulated outputs atomically across workers#6589
ryzhyk merged 1 commit into
mainfrom
accumulate-output-cohort-release

Conversation

@ryzhyk

@ryzhyk ryzhyk commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

The AccumulateOutput operator produces a single output per transaction per worker. The idea was that this would generate a single output batch per transaction for each output connector, giving the connector an opportunity to commit these changes in a single transaction to the data sink (e.g., the Delta Lake connector does that). However different workers can produce their per-transaction outputs in different steps, resulting in multiple non-empty outputs from the circuit. This was unlikely to happen before the introduction of streaming exchange, since the upstream shard operator contains a barrier that synchronizes transaction completion across all threads.

This commit introduces a local (per-host) barrier that synchronizes transaction output across all workers.

There is an alternative approach: allow workers to produce outputs at any step, but only handle them in the controller when the transaction commits. This may be an even cleaner solution. One advantage of my fix is that it allows views that are computed early to produce outputs before all other views are fully evaluated in the transaction. Some users rely on this behavior. We may want to revise this in the future, but for now I wanted to implement the least intrusive fix.

Describe Manual Test Plan

Checklist

  • Unit tests added/updated
  • Integration tests added/updated
  • Documentation updated
  • Changelog updated

Breaking Changes?

Mark if you think the answer is yes for any of these components:

Describe Incompatible Changes

@ryzhyk
ryzhyk requested a review from blp July 3, 2026 16:09
@ryzhyk ryzhyk added the connectors Issues related to the adapters/connectors crate label Jul 3, 2026
@ryzhyk
ryzhyk enabled auto-merge July 3, 2026 16:09

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Elegant, minimally intrusive fix. A Mutex-backed per-host cohort barrier avoids busy-waiting and only lets the completing worker publish, so there is no thundering herd on the mailboxes. Design and tests look good; a few small notes inline.

Comment thread crates/dbsp/src/operator/output.rs Outdated
_marker: PhantomData<T>,
}

unsafe impl<T> Sync for CohortId<T> {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: unsafe impl Sync deserves a // SAFETY: note — same fix would apply to the existing OutputId<T> above. Something like: // SAFETY: CohortId only stores a usize and a PhantomData<T>; it never owns or exposes a T, so it is Sync regardless of T.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that this unsafe impl could be eliminated by declaring _marker as:

_marker: PhantomData<fn() -> T>

based on https://doc.rust-lang.org/nomicon/phantom-data.html#table-of-phantomdata-patterns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, thanks!

Comment thread crates/dbsp/src/operator/output.rs Outdated

// The upstream accumulator emits exactly once per transaction per
// worker, so the slot must be free.
debug_assert!(pending[worker].is_none());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider assert! instead of debug_assert!. If the upstream accumulator's "once per transaction per worker" invariant is ever violated (say by a future refactor, a transaction abort/rollback leaving a stale slot behind, or a checkpoint-restore race), a release build would silently overwrite the previous value, publish a mixed cohort, and hand connectors like Delta Lake a corrupted transaction. Cheap enough to keep on in release. (Same spirit as NASA Power of Ten rule 5 for shared-state invariants.)

///
/// Slot `w` stores worker `w`'s output until every worker on this host has
/// produced its output for the transaction.
struct CohortSlots<T>(Arc<Mutex<Vec<Option<T>>>>);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Related to the debug_assert above: what happens to a partially-filled CohortSlots if a transaction is aborted / rolled back before every worker has emitted, or if the runtime restarts mid-commit and a checkpoint is restored? The next transaction's put on the stale worker would fire the debug_assert (release: silent overwrite). If the accumulator machinery guarantees that can't happen, a one-liner in the CohortSlots doc noting the assumption would help future readers; if it can happen, we probably want a reset hook (on abort / on restore).

/// host and publishes the whole cohort to the mailboxes when the last
/// worker's output arrives, so a reader sees either all of a transaction's
/// outputs or none.
pub struct AccumulateOutput<B>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The docstring is clear that this is a per-host barrier. Worth explicitly noting the multi-host implication: with N hosts, each host publishes its own cohort into its own local mailboxes, and cross-host atomicity for a transaction still depends on the controller aggregating per-host results at commit time (which is presumably where the "alternative approach" you mentioned in the PR description would kick in). Just so the next reader doesn't assume this fix already covers the distributed case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ryzhyk Fred prompted me to consider the multihost case. I don't understand how this works in that case. The CohortSlots object is necessarily per-host, but each instance of it has a slot for every worker overall. I think only the slots for the current host will ever get filled, so that this will hang on multihost.

Maybe I misunderstand, or maybe you have tested with multiple hosts, so I will approve this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for spotting this. Should be fixed now.

/// commit; `DBSPHandle::transaction` would only return after the commit
/// completed, when the cohort is trivially complete.
#[test]
fn test_accumulate_output() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice that the test drives the manual start_transaction / start_commit_transaction / step API — that's the only way to catch a partial-cohort leak. One thing this doesn't exercise is workers emitting in different commit steps (which is the entire scenario the fix is aimed at); with a small add_input_zset it likely all completes in a single step. A follow-up test that forces multi-step commits with staggered per-worker emission (via a slow operator or an artificial streaming-exchange delay) would give the barrier a real workout. Not a blocker.

Comment thread crates/dbsp/src/operator/output.rs Outdated
_marker: PhantomData<T>,
}

unsafe impl<T> Sync for CohortId<T> {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that this unsafe impl could be eliminated by declaring _marker as:

_marker: PhantomData<fn() -> T>

based on https://doc.rust-lang.org/nomicon/phantom-data.html#table-of-phantomdata-patterns

/// host and publishes the whole cohort to the mailboxes when the last
/// worker's output arrives, so a reader sees either all of a transaction's
/// outputs or none.
pub struct AccumulateOutput<B>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ryzhyk Fred prompted me to consider the multihost case. I don't understand how this works in that case. The CohortSlots object is necessarily per-host, but each instance of it has a slot for every worker overall. I think only the slots for the current host will ever get filled, so that this will hang on multihost.

Maybe I misunderstand, or maybe you have tested with multiple hosts, so I will approve this.

@ryzhyk
ryzhyk added this pull request to the merge queue Jul 14, 2026
@blp
blp removed this pull request from the merge queue due to a manual request Jul 14, 2026
@blp

blp commented Jul 14, 2026

Copy link
Copy Markdown
Member

I removed it from the queue because I wanted thoughts on #6589 (comment) before queuing.

The AccumulateOutput operator produces a single output per transaction
per worker. The idea was that this would generate a single output batch
per transaction for each output connector, giving the connector an
opportunity to commit these changes in a single transaction to the
data sink (e.g., the Delta Lake connector does that). However different
workers can produce their per-transaction outputs in different steps,
resulting in multiple non-empty outputs from the circuit. This was
unlikely to happen before the introduction of streaming exchange, since
the upstream `shard` operator contains a barrier that synchronizes
transaction completion across all threads.

This commit introduces a local (per-host) barrier that synchronizes
transaction output across all workers.

There is an alternative approach: allow workers to produce outputs
at any step, but only handle them in the controller when the transaction
commits. This may be an even cleaner solution. One advantage of my fix
is that it allows views that are computed early to produce outputs before
all other views are fully evaluated in the transaction. Some users rely
on this behavior. We may want to revise this in the future, but for
now I wanted to implement the least intrusive fix.

Signed-off-by: Leonid Ryzhyk <ryzhyk@gmail.com>
@ryzhyk
ryzhyk force-pushed the accumulate-output-cohort-release branch from c1b0532 to 5c4db8e Compare July 15, 2026 08:05
@ryzhyk
ryzhyk enabled auto-merge July 15, 2026 08:05
@ryzhyk
ryzhyk added this pull request to the merge queue Jul 15, 2026
Merged via the queue into main with commit ee7af2a Jul 15, 2026
1 check passed
@ryzhyk
ryzhyk deleted the accumulate-output-cohort-release branch July 15, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

connectors Issues related to the adapters/connectors crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants