test(dispatcher): pin trigger_depth ceiling in the async outbox path
An outbox row whose trigger_depth exceeds max_trigger_depth is dropped without executing the handler. Mirrors the disabled-http drop harness; mutation-verified (weakening the depth gate fails it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2785,5 +2785,76 @@ mod tests {
|
|||||||
"executor ran for a disabled HTTP outbox row; fire-time gate missing"
|
"executor ran for a disabled HTTP outbox row; fire-time gate missing"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `trigger_depth` bounds chain DEPTH. The sync `invoke()` path is pinned
|
||||||
|
/// (sdk/invoke.rs), but the ASYNC dispatcher enforcement — `dispatch_one`
|
||||||
|
/// dropping a row past `max_trigger_depth` — was not. Without it, a trigger
|
||||||
|
/// whose handler writes back to the collection it watches is an infinite
|
||||||
|
/// outbox self-amplification loop (write→trigger→write→…) from one request.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn outbox_row_past_max_depth_is_dropped_without_executing() {
|
||||||
|
let app_id = AppId::new();
|
||||||
|
// ENABLED script — so what stops execution is the DEPTH gate, not the
|
||||||
|
// disabled-drop path. (The depth check runs before script resolution,
|
||||||
|
// so the repo is never even consulted, but keep it honest.)
|
||||||
|
let mut script = disabled_script(app_id);
|
||||||
|
script.enabled = true;
|
||||||
|
|
||||||
|
let config = TriggerConfig::from_env();
|
||||||
|
let row_id = Uuid::new_v4();
|
||||||
|
let row = OutboxRow {
|
||||||
|
id: row_id,
|
||||||
|
app_id,
|
||||||
|
source_kind: OutboxSourceKind::Kv,
|
||||||
|
trigger_id: Some(TriggerId::new()),
|
||||||
|
script_id: Some(script.id),
|
||||||
|
reply_to: None,
|
||||||
|
payload: serde_json::json!({}),
|
||||||
|
origin_principal: None,
|
||||||
|
// One past the ceiling — must be dropped, not dispatched.
|
||||||
|
trigger_depth: config.max_trigger_depth + 1,
|
||||||
|
root_execution_id: None,
|
||||||
|
attempt_count: 0,
|
||||||
|
next_attempt_at: Utc::now(),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let deleted = Arc::new(Mutex::new(None));
|
||||||
|
let executed = Arc::new(AtomicBool::new(false));
|
||||||
|
let dispatcher = Dispatcher {
|
||||||
|
outbox: Arc::new(RecordingOutbox {
|
||||||
|
deleted: deleted.clone(),
|
||||||
|
}),
|
||||||
|
triggers: Arc::new(UnusedTriggers),
|
||||||
|
scripts: Arc::new(DisabledScriptRepo { script }),
|
||||||
|
dead_letters: Arc::new(UnusedDeadLetters),
|
||||||
|
abandoned: Arc::new(UnusedAbandoned),
|
||||||
|
principals: Arc::new(OkPrincipals),
|
||||||
|
executor: Arc::new(RecordingExecutor {
|
||||||
|
executed: executed.clone(),
|
||||||
|
}),
|
||||||
|
gate: Arc::new(ExecutionGate::new(1)),
|
||||||
|
log_sink: Arc::new(UnusedLogSink),
|
||||||
|
inbox: Arc::new(UnusedInbox),
|
||||||
|
queue: Arc::new(UnusedQueue),
|
||||||
|
group_queue: Arc::new(NoopGroupQueue),
|
||||||
|
config,
|
||||||
|
instance_id: "test-instance".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = dispatcher.dispatch_one(row).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "dispatch_one returned {result:?}");
|
||||||
|
assert_eq!(
|
||||||
|
*deleted.lock().unwrap(),
|
||||||
|
Some(row_id),
|
||||||
|
"a depth-exceeded row must be deleted (not left to re-amplify)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!executed.load(Ordering::SeqCst),
|
||||||
|
"executor ran for a depth-exceeded row — the depth gate is missing, \
|
||||||
|
so a self-writing trigger would loop unbounded"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user