Skip to content

Commit d3961b7

Browse files
committed
fix: gate outbox drain on holdership rather than local topic presence
1 parent 355ac2a commit d3961b7

3 files changed

Lines changed: 134 additions & 28 deletions

File tree

operations/src/metadata/forward.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -423,10 +423,15 @@ pub(crate) async fn forward_outbox_record(
423423
///
424424
/// The peer is a configured node of this realm and the record's bucket is one
425425
/// this node holds, so the record is queued into the local outbox verbatim and
426-
/// drains onto the bucket's topic like any locally-emitted record. Verbatim
427-
/// matters for admin operations: they are ordered by `(origin_node_id,
428-
/// origin_seq)` on every receiver, so re-stamping the relay as the origin would
429-
/// break that ordering and drop the emitter's earlier operations as stale.
426+
/// drains onto the bucket's topic like any locally-emitted record.
427+
///
428+
/// Document upserts and deletes only: an admin operation cannot be relayed at
429+
/// all. Receivers authenticate an admin event by requiring its signed publisher
430+
/// on the topic to *be* its `origin_node_id` (`validate_replicated_admin_event`),
431+
/// and a relay is by construction a different node signing for another origin, so
432+
/// every receiver would reject it. Accepting one here would report success while
433+
/// the operation was silently dropped realm-wide — worse than refusing it, which
434+
/// leaves the record in the emitter's outbox, retried and loud.
430435
async fn apply_forwarded_outbox_record(
431436
context: &Arc<DriverContext>,
432437
peer: NodeId,
@@ -446,6 +451,14 @@ async fn apply_forwarded_outbox_record(
446451
Ok(_) => {}
447452
Err(error) => return reject(error.to_string()),
448453
}
454+
if matches!(
455+
record.event,
456+
aruna_core::document::DocumentSyncOutboxEvent::AdminOperation { .. }
457+
) {
458+
return reject(
459+
"an admin operation cannot be relayed: receivers require its signed publisher to be its origin node",
460+
);
461+
}
449462
if !holds_placement(&config, &record.placement, net_handle.node_id()) {
450463
return reject(format!(
451464
"node holds none of bucket {}/{} of the relayed outbox record",

operations/src/task_incoming.rs

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
219219
struct 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)]
227228
enum 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.
247256
fn 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)

operations/tests/metadata_forwarding.rs

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,67 @@ async fn user_node_forwards_create() -> Result<(), Box<dyn std::error::Error>> {
9797
Ok(())
9898
}
9999

100+
/// A group created on a User-kind node is never silently lost.
101+
///
102+
/// A User node holds no bucket of any group, so its admin-operation outbox
103+
/// records can never publish from here. They also cannot be relayed: receivers
104+
/// authenticate an admin event by requiring its signed publisher to be its
105+
/// `origin_node_id`, so a holder publishing on the emitter's behalf is rejected
106+
/// realm-wide (`validate_replicated_admin_event`). The record therefore stays in
107+
/// the outbox, retried and loud, instead of being deleted after the caller was
108+
/// told the group exists. Creating groups from a User node needs a signed-origin
109+
/// admin relay in the net layer; until then this test pins the invariant that
110+
/// matters — the write is never thrown away.
111+
#[tokio::test]
112+
async fn user_node_group_survives() -> Result<(), Box<dyn std::error::Error>> {
113+
let realm = Realm::new();
114+
let (nodes, _config) = build_realm(&realm, 3, 1).await?;
115+
let user_node = nodes.last().expect("user node");
116+
117+
let (group, _auth) = drive(
118+
CreateGroupOperation::new(CreateGroupConfig {
119+
actor: Actor {
120+
node_id: user_node.net.node_id(),
121+
user_id: realm.user_id,
122+
realm_id: realm.realm_id,
123+
},
124+
display_name: "group from a user node".to_string(),
125+
owner_cap: None,
126+
}),
127+
user_node.context.as_ref(),
128+
)
129+
.await?;
130+
131+
// Give the drain time to run: it must not have deleted the records.
132+
sleep(Duration::from_secs(2)).await;
133+
assert!(read_group_auth(user_node, group.group_id).await?.is_some());
134+
assert!(
135+
outbox_len(user_node).await? > 0,
136+
"the user node's unpublishable admin records must be retained, not dropped"
137+
);
138+
139+
shutdown(nodes).await;
140+
Ok(())
141+
}
142+
143+
async fn outbox_len(node: &TestNode) -> Result<usize, Box<dyn std::error::Error>> {
144+
match node
145+
.context
146+
.storage_handle
147+
.send_storage_effect(aruna_core::effects::StorageEffect::Iter {
148+
key_space: aruna_core::keyspaces::DOCUMENT_SYNC_OUTBOX_KEYSPACE.to_string(),
149+
prefix: None,
150+
start: None,
151+
limit: 64,
152+
txn_id: None,
153+
})
154+
.await
155+
{
156+
Event::Storage(StorageEvent::IterResult { values, .. }) => Ok(values.len()),
157+
other => Err(format!("unexpected outbox iter event: {other:?}").into()),
158+
}
159+
}
160+
100161
/// Re-forwarding a create that already applied returns the existing record.
101162
///
102163
/// A forward whose response is lost is retried, possibly against a different
@@ -253,20 +314,27 @@ async fn seed_group(realm: &Realm, nodes: &[TestNode]) -> Result<Ulid, Box<dyn s
253314
)
254315
.await?;
255316

317+
wait_for_group(nodes, group.group_id).await?;
318+
Ok(group.group_id)
319+
}
320+
321+
/// Waits for the group's authorization document — what the permission check
322+
/// reads — on every sync-eligible node. A User node is never a sync target, so it
323+
/// never receives the group it may itself have created.
324+
async fn wait_for_group(
325+
nodes: &[TestNode],
326+
group_id: Ulid,
327+
) -> Result<(), Box<dyn std::error::Error>> {
256328
let deadline = Instant::now() + CONVERGENCE_TIMEOUT;
257329
loop {
258330
let mut pending = false;
259-
for node in nodes {
260-
// A user node is not a sync target, so it never receives the group.
261-
if !node.sync_eligible {
262-
continue;
263-
}
264-
if read_group_auth(node, group.group_id).await?.is_none() {
331+
for node in nodes.iter().filter(|node| node.sync_eligible) {
332+
if read_group_auth(node, group_id).await?.is_none() {
265333
pending = true;
266334
}
267335
}
268336
if !pending {
269-
return Ok(group.group_id);
337+
return Ok(());
270338
}
271339
if Instant::now() >= deadline {
272340
return Err("the group's authorization document never reached every holder".into());

0 commit comments

Comments
 (0)