@@ -214,19 +214,20 @@ impl DrainSyncOutcome {
214214
215215/// Per-run defer state, carried across the drain's pages so a topic deferred on
216216/// one page keeps deferring on later pages (preserving FIFO within a topic) and
217- /// each topic's genesis presence is probed at most once per run.
217+ /// each topic's holdership and genesis presence are decided at most once per run.
218218#[ derive( Default ) ]
219219struct DrainDeferState {
220220 topic_exists : HashMap < irokle:: TopicId , bool > ,
221+ topic_held : HashMap < irokle:: TopicId , bool > ,
221222 deferred_topics : HashSet < irokle:: TopicId > ,
222223 undeliverable_topics : HashSet < irokle:: TopicId > ,
223224}
224225
225- /// What a record whose shard topic has no local genesis is waiting for .
226+ /// Whether a shard-classed record can ever publish from this node .
226227#[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
227228enum DeferOutcome {
228- /// The local node holds the bucket, so the genesis is on its way (rank-0
229- /// creates it, every other holder pulls it): keep the record and retry .
229+ /// The local node holds the bucket, so it may publish onto the bucket's topic
230+ /// once the genesis is there (rank-0 creates it, every other holder pulls it).
230231 Retry ,
231232 /// The local node holds none of the record's bucket. Topic membership is the
232233 /// holder set, so it may neither mint the genesis nor join the topic: this
@@ -236,14 +237,22 @@ enum DeferOutcome {
236237
237238/// Splits FIFO-ordered drain records into those to publish now, those deferred
238239/// because their shard topic has no local genesis yet, and those that can never
239- /// publish from this node at all. Each topic's availability is evaluated once
240- /// per run (state persists in `defer`); once a topic defers, every later record
241- /// of that topic defers too. Splitting FIFO-adjacent records of one topic across
242- /// a defer/publish boundary would let the newer op publish first, invert its
243- /// origin sequence on receivers, and drop the older op forever as
244- /// StaleOriginSequence — so a topic never straddles that boundary within a run,
245- /// across pages included. A shard topic's holder set is a function of its
246- /// placement, so undeliverability is likewise decided once per topic.
240+ /// publish from this node at all.
241+ ///
242+ /// Holdership is checked *before* topic availability, and it is the only thing
243+ /// that decides publishability. Joining a shard topic is not holder-gated — a
244+ /// non-holder can adopt a co-holder's genesis, and the drain's own bootstrap pass
245+ /// will happily pull one — but its publishes onto that topic are not accepted. So
246+ /// "the topic exists locally" is not evidence this node may publish onto it, and
247+ /// classifying on that first would route a non-holder's records into a publish
248+ /// that silently goes nowhere instead of into the forwarding path.
249+ ///
250+ /// Each topic's holdership and genesis presence are decided once per run (state
251+ /// persists in `defer`); once a topic defers, every later record of that topic
252+ /// defers too. Splitting FIFO-adjacent records of one topic across a
253+ /// defer/publish boundary would let the newer op publish first, invert its origin
254+ /// sequence on receivers, and drop the older op forever as StaleOriginSequence —
255+ /// so a topic never straddles that boundary within a run, across pages included.
247256fn partition_drain_records (
248257 records : Vec < DrainRecord > ,
249258 defer : & mut DrainDeferState ,
@@ -262,6 +271,15 @@ fn partition_drain_records(
262271 undeliverable. push ( ( record_key, record, topic) ) ;
263272 continue ;
264273 }
274+ let held = * defer
275+ . topic_held
276+ . entry ( topic)
277+ . or_insert_with ( || classify_defer ( & record) == DeferOutcome :: Retry ) ;
278+ if !held {
279+ defer. undeliverable_topics . insert ( topic) ;
280+ undeliverable. push ( ( record_key, record, topic) ) ;
281+ continue ;
282+ }
265283 let already_deferred = defer. deferred_topics . contains ( & topic) ;
266284 let available = !already_deferred
267285 && * defer
@@ -272,11 +290,6 @@ fn partition_drain_records(
272290 to_publish. push ( ( record_key, record, topic) ) ;
273291 continue ;
274292 }
275- if !already_deferred && classify_defer ( & record) == DeferOutcome :: Undeliverable {
276- defer. undeliverable_topics . insert ( topic) ;
277- undeliverable. push ( ( record_key, record, topic) ) ;
278- continue ;
279- }
280293 defer. deferred_topics . insert ( topic) ;
281294 debug ! (
282295 event = "pipeline.drain.deferred" ,
@@ -557,11 +570,23 @@ impl OperationsTaskHandler {
557570 // short retry — the genesis arrives via gossip or the next pass. A
558571 // topic already deferred earlier this run is skipped: its records
559572 // stay deferred regardless, and re-probing would only waste RPCs.
573+ //
574+ // Only buckets this node holds are pulled. Joining is not holder-gated,
575+ // so a non-holder could adopt the genesis here — and would then look
576+ // publishable while its publishes went nowhere. Its records belong in
577+ // the forwarding path instead.
560578 let mut missing_topics: BTreeMap < Vec < aruna_core:: NodeId > , BTreeSet < irokle:: TopicId > > =
561579 BTreeMap :: new ( ) ;
562580 for ( _, record, topic) in & records {
563581 if !record. target . uses_shard_topic ( )
564582 || defer_state. deferred_topics . contains ( topic)
583+ || !realm_config. as_ref ( ) . is_none_or ( |config| {
584+ crate :: placement:: holds_placement (
585+ config,
586+ & record. placement ,
587+ net_handle. node_id ( ) ,
588+ )
589+ } )
565590 || net_handle
566591 . document_sync_topic_exists ( * topic)
567592 . unwrap_or ( false )
0 commit comments