fix(manager-core): F-P-007 concurrent queue dispatch per tick

tick_queue_arm called list_active_queue_consumers() and then iterated
serially, awaiting one queue.claim(app, queue) per consumer. With N
consumers and a 100ms tick the worst-case throughput was
N × (claim + executor) / tick — one slow handler blocked every other
queue's progress on the dispatcher's task.

Replace the for-loop with `futures::stream::iter(consumers)
.for_each_concurrent(QUEUE_DISPATCH_PARALLELISM, …)`. The execution
gate already caps real script-concurrency to its permits, so this just
removes the dispatcher-side serialization.

QUEUE_DISPATCH_PARALLELISM = 32 (matches the default gate). Workspace
gains `futures = 0.3` so `for_each_concurrent` is available.

AUDIT.md anchor: F-P-007.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:19:15 +02:00
parent 97a6bd732f
commit 8ceb1352dd
4 changed files with 56 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ picloud-executor-core.workspace = true
picloud-orchestrator-core.workspace = true
async-trait.workspace = true
futures.workspace = true
axum.workspace = true
rand.workspace = true
serde.workspace = true

View File

@@ -79,6 +79,11 @@ const TICK_INTERVAL: Duration = Duration::from_millis(100);
/// 5-minute cap.
const ASYNC_EXEC_TIMEOUT: Duration = Duration::from_secs(300);
/// F-P-007: per-tick concurrency cap for queue dispatches. Matches the
/// default execution-gate concurrency so the gate is what bounds real
/// parallelism, not the dispatcher's serial-await.
const QUEUE_DISPATCH_PARALLELISM: usize = 32;
impl Dispatcher {
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
@@ -149,17 +154,30 @@ impl Dispatcher {
/// attempt one claim per `(app_id, queue_name)`. Each successful
/// claim runs through the same executor path as outbox-backed
/// trigger dispatches, so retry / dead-letter wiring is shared.
///
/// F-P-007: bound concurrency with `for_each_concurrent` instead of
/// serial-await. With N consumers and a 100ms tick the worst-case
/// throughput was N × (claim + executor) / tick; concurrent dispatch
/// lets one slow handler not block the others. The execution gate
/// still caps real parallelism — this just removes the
/// dispatcher-side serialization.
async fn tick_queue_arm(&self) -> Result<(), DispatcherError> {
use futures::stream::{self, StreamExt};
let consumers = self
.triggers
.list_active_queue_consumers()
.await
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?;
for consumer in consumers {
if let Err(err) = self.dispatch_one_queue(&consumer).await {
tracing::warn!(?err, queue = %consumer.queue_name, "queue dispatch errored");
}
}
// Concurrency cap matches the execution gate's typical default;
// overflow attempts return Err(Overloaded) and back off so a
// queue burst doesn't hammer the executor.
stream::iter(consumers)
.for_each_concurrent(QUEUE_DISPATCH_PARALLELISM, |consumer| async move {
if let Err(err) = self.dispatch_one_queue(&consumer).await {
tracing::warn!(?err, queue = %consumer.queue_name, "queue dispatch errored");
}
})
.await;
Ok(())
}