feat(v1.1.1-routes): outbox-routed sync HTTP + dispatch_mode=async

Routes gain `dispatch_mode TEXT NOT NULL DEFAULT 'sync'` (migration
0012). Existing routes default to sync so the migration is
non-breaking. `DispatchMode` enum lands in `picloud-shared`.

The user-routes orchestrator handler now branches:
- `dispatch_mode = async` → write outbox row with `reply_to = None`,
  return `202 Accepted` + `{accepted_at, execution_id}`. Dispatcher
  fires the script in the background; retries / dead-letters via
  the framework from commit 5.
- `dispatch_mode = sync` → register an inbox channel
  (`tokio::sync::oneshot`), write outbox row with `reply_to =
  inbox_id`, `.await` on the receiver with a timeout =
  script.timeout_seconds + 2s buffer. Dispatcher hands the result
  back; orchestrator maps `InboxResult` into the HTTP response per
  the design-notes §3 status-code table (422/502/503/504/507/500).

`InboxRegistry` (orchestrator-core/src/inbox.rs) is the in-process
implementation of `InboxResolver`. Lock-free HashMap of pending
oneshot senders keyed by `inbox_id`. Tests cover register/deliver
round-trip, unknown-id is abandoned, dropped-receiver is abandoned,
explicit cancel. Cluster mode (v1.3+) swaps this for
LISTEN/NOTIFY-keyed lookup behind the same trait.

`OutboxWriter` trait lives in `picloud-shared` so orchestrator-core
can write to the outbox without depending on manager-core (which
would invert the dependency arrow). `PostgresOutboxRepo` implements
both `OutboxRepo` (dispatcher surface) and `OutboxWriter`
(orchestrator surface); the picloud binary clones the same concrete
Arc into both trait views.

The dispatcher's HTTP arm (commit 5 had a stub) now decodes the
`HttpDispatchPayload` off the outbox row, looks up the script,
synthesizes an `ExecRequest`, and runs it through the executor.
Outcome routing reuses the same path as KV triggers — sync HTTP
flows through the inbox, async dispatch gets dropped after
success (or DL'd on exhaustion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 22:12:55 +02:00
parent 6a2971ac70
commit 77b2cb58bb
14 changed files with 767 additions and 78 deletions

View File

@@ -82,6 +82,7 @@ async fn seed_into(
// Accept any method so both `curl /hello` and
// `curl -d '{"name":"X"}' /hello` work out of the box.
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
})
.await?;

View File

@@ -27,8 +27,8 @@ use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
use picloud_shared::{
ExecResponseSummary, ExecutionId, InboxDeliveryOutcome, InboxFailureKind, InboxResolver,
InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
ExecResponseSummary, ExecutionId, HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind,
InboxResolver, InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
};
use rand::Rng;
use uuid::Uuid;
@@ -148,36 +148,36 @@ impl Dispatcher {
return Ok(());
};
// Resolve the trigger config (KV or DL) and the script.
let resolved = match row.source_kind {
OutboxSourceKind::Http => {
// Sync HTTP path lands here when commit 6 wires up
// the orchestrator -> outbox bridge. For now, this
// arm is a forward-compat stub — drop the row to
// avoid a permanent stuck state.
tracing::debug!(outbox_id = %row.id, "HTTP outbox row encountered; commit 6 wires this in");
self.outbox
.delete(row.id)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
drop(permit);
return Ok(());
}
// Resolve the trigger config (KV / DL) or pull the HTTP
// payload directly off the outbox row.
let (resolved, exec_req) = match row.source_kind {
OutboxSourceKind::Http => match self.build_http_request(&row).await {
Ok(pair) => pair,
Err(err) => {
tracing::warn!(outbox_id = %row.id, ?err, "http exec build failed; dropping");
self.outbox
.delete(row.id)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
drop(permit);
return Ok(());
}
},
OutboxSourceKind::Kv | OutboxSourceKind::DeadLetter => {
self.resolve_trigger(&row).await?
}
};
let exec_req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req,
Err(err) => {
tracing::warn!(outbox_id = %row.id, ?err, "exec request build failed; dropping row");
self.outbox
.delete(row.id)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
drop(permit);
return Ok(());
let resolved = self.resolve_trigger(&row).await?;
let req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req,
Err(err) => {
tracing::warn!(outbox_id = %row.id, ?err, "exec request build failed; dropping row");
self.outbox
.delete(row.id)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
drop(permit);
return Ok(());
}
};
(resolved, req)
}
};
@@ -275,6 +275,81 @@ impl Dispatcher {
})
}
/// Build an `(ResolvedTrigger, ExecRequest)` for an HTTP outbox
/// row. HTTP rows don't have a backing `triggers` row (the
/// `trigger_id` references `routes.id` instead). We pull the
/// script id off the outbox row, the request shape off the
/// payload, and synthesize a `ResolvedTrigger` with retry
/// settings irrelevant for HTTP (sync HTTP is never retried;
/// async HTTP uses default policy from `TriggerConfig`).
async fn build_http_request(
&self,
row: &OutboxRow,
) -> Result<(ResolvedTrigger, ExecRequest), DispatcherError> {
let Some(script_id) = row.script_id else {
return Err(DispatcherError::ResolveTrigger(
"HTTP outbox row missing script_id".into(),
));
};
let script = self
.scripts
.get(script_id)
.await
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
.ok_or_else(|| {
DispatcherError::ResolveTrigger(format!("script {script_id} not found"))
})?;
let payload: HttpDispatchPayload = serde_json::from_value(row.payload.clone())
.map_err(|e| DispatcherError::ResolveTrigger(format!("decode http payload: {e}")))?;
let execution_id = ExecutionId::new();
let req = ExecRequest {
execution_id,
request_id: RequestId::new(),
script_id,
script_name: payload.script_name.clone(),
invocation_type: InvocationType::Http,
path: payload.path.clone(),
headers: payload.headers,
body: payload.body,
params: payload.params,
query: payload.query,
rest: payload.rest,
sandbox_overrides: script.sandbox,
app_id: row.app_id,
// HTTP outbox rows don't run as the trigger registrant —
// they run with no principal (public ingress) or the
// attached one (origin_principal forensic field is not
// promoted to execution principal in this MVP).
principal: None,
trigger_depth: row.trigger_depth,
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
is_dead_letter_handler: false,
event: None,
};
let resolved = ResolvedTrigger {
trigger_kind: TriggerKind::Kv, // placeholder; HTTP doesn't have a kind
is_dead_letter_handler: false,
script_id,
script_source: script.source,
script_name: payload.script_name,
sandbox_overrides: script.sandbox,
// HTTP outbox rows don't carry a registered_by_principal
// — use a sentinel zero UUID since this field isn't used
// downstream for HTTP (no retries, no inbox principal).
registered_by_principal: picloud_shared::AdminUserId::from(uuid::Uuid::nil()),
// Async HTTP uses the platform default retry policy from
// TriggerConfig. Sync HTTP (reply_to.is_some) never retries
// regardless.
retry_max_attempts: self.config.retry_max_attempts,
retry_backoff: self.config.retry_backoff,
retry_base_ms: self.config.retry_base_ms,
};
Ok((resolved, req))
}
async fn handle_success(
&self,
row: &OutboxRow,

View File

@@ -5,7 +5,10 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, ExecutionId, ScriptId, TriggerId};
use picloud_shared::{
AdminUserId, AppId, ExecutionId, NewHttpOutbox, OutboxWriter, OutboxWriterError, ScriptId,
TriggerId,
};
use sqlx::PgPool;
use uuid::Uuid;
@@ -195,6 +198,28 @@ impl OutboxRepo for PostgresOutboxRepo {
}
}
/// `OutboxWriter` implementation so orchestrator-core (which can't
/// depend on manager-core) can enqueue HTTP outbox rows through the
/// shared trait.
#[async_trait]
impl OutboxWriter for PostgresOutboxRepo {
async fn enqueue_http(&self, row: NewHttpOutbox) -> Result<Uuid, OutboxWriterError> {
self.insert(NewOutboxRow {
app_id: row.app_id,
source_kind: OutboxSourceKind::Http,
trigger_id: Some(TriggerId::from(row.route_id)),
script_id: Some(row.script_id),
reply_to: row.reply_to,
payload: row.payload,
origin_principal: row.origin_principal,
trigger_depth: row.trigger_depth,
root_execution_id: row.root_execution_id,
})
.await
.map_err(|e| OutboxWriterError::Backend(e.to_string()))
}
}
#[derive(sqlx::FromRow)]
struct OutboxRowRaw {
id: Uuid,

View File

@@ -77,6 +77,12 @@ pub struct CreateRouteRequest {
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
/// Per-route dispatch mode (v1.1.1). Defaults to `Sync` when
/// omitted so older clients aren't broken. `Async` routes return
/// `202 Accepted` immediately and run the script in the
/// background via the dispatcher.
#[serde(default)]
pub dispatch_mode: picloud_shared::DispatchMode,
}
#[derive(Debug, Deserialize)]
@@ -211,6 +217,7 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
path_kind: input.path_kind,
path: normalized_path,
method: input.method,
dispatch_mode: input.dispatch_mode,
})
.await?;
refresh_table(&state).await?;
@@ -370,6 +377,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
method: r.method.clone(),
dispatch_mode: r.dispatch_mode,
})
})
.collect()

View File

@@ -4,7 +4,7 @@
//! after every write — see the route_admin module for the binding.
use async_trait::async_trait;
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
use picloud_shared::{AppId, DispatchMode, HostKind, PathKind, Route, ScriptId};
use sqlx::PgPool;
use uuid::Uuid;
@@ -20,6 +20,7 @@ pub struct NewRoute {
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
pub dispatch_mode: DispatchMode,
}
#[async_trait]
@@ -62,7 +63,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
path_kind, path, method, dispatch_mode, created_at \
FROM routes ORDER BY created_at",
)
.fetch_all(&self.pool)
@@ -73,7 +74,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
path_kind, path, method, dispatch_mode, created_at \
FROM routes WHERE id = $1",
)
.bind(route_id)
@@ -85,7 +86,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
path_kind, path, method, dispatch_mode, created_at \
FROM routes WHERE app_id = $1 ORDER BY created_at",
)
.bind(app_id.into_inner())
@@ -100,7 +101,7 @@ impl RouteRepository for PostgresRouteRepository {
) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
path_kind, path, method, dispatch_mode, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at",
)
.bind(script_id.into_inner())
@@ -113,10 +114,10 @@ impl RouteRepository for PostgresRouteRepository {
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
path_kind, path, method, dispatch_mode \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at",
path_kind, path, method, dispatch_mode, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
@@ -126,6 +127,7 @@ impl RouteRepository for PostgresRouteRepository {
.bind(path_kind_str(input.path_kind))
.bind(&input.path)
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.fetch_one(&self.pool)
.await;
@@ -198,6 +200,7 @@ struct RouteRow {
path_kind: String,
path: String,
method: Option<String>,
dispatch_mode: String,
created_at: chrono::DateTime<chrono::Utc>,
}
@@ -221,6 +224,7 @@ impl From<RouteRow> for Route {
},
path: r.path,
method: r.method,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
created_at: r.created_at,
}
}