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

@@ -17,13 +17,15 @@ use axum::{
use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, Principal, RequestId,
ScriptId,
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus,
HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox, OutboxWriter, Principal,
RequestId, ScriptId,
};
use serde_json::Value as Json_;
use uuid::Uuid;
use crate::client::ExecutorClient;
use crate::inbox::InboxRegistry;
use crate::resolver::{ResolverError, ScriptResolver};
use crate::routing::{AppDomainTable, RouteTable};
@@ -39,6 +41,14 @@ pub struct DataPlaneState<E, R> {
/// Routing table for user-defined paths, partitioned per app.
/// Shared with the manager (admin router writes; this side reads).
pub routes: Arc<RouteTable>,
/// NATS-style inbox registry (v1.1.1). Used by sync HTTP via
/// outbox to await the dispatcher's delivery on a oneshot
/// channel.
pub inbox: Arc<InboxRegistry>,
/// Writer for the universal trigger outbox (v1.1.1). The sync
/// HTTP path inserts a row with `reply_to = inbox_id`; the async
/// path inserts with `reply_to = None` and returns 202.
pub outbox: Arc<dyn OutboxWriter>,
}
impl<E, R> Clone for DataPlaneState<E, R> {
@@ -49,6 +59,8 @@ impl<E, R> Clone for DataPlaneState<E, R> {
log_sink: self.log_sink.clone(),
app_domains: self.app_domains.clone(),
routes: self.routes.clone(),
inbox: self.inbox.clone(),
outbox: self.outbox.clone(),
}
}
}
@@ -202,50 +214,312 @@ where
Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
};
let mut req = build_exec_request(
matched.matched.script_id,
&script.name,
&headers,
&body_bytes,
app_id,
principal,
)?;
req.path = path;
req.params = matched.params;
req.query = parse_query_string(&query_str);
req.rest = matched.rest.unwrap_or_default();
req.sandbox_overrides = script.sandbox;
let body_json: Json_ = if body_bytes.is_empty() {
Json_::Null
} else {
serde_json::from_slice(&body_bytes)
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
};
let header_map: BTreeMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|s| (k.as_str().to_string(), s.to_string()))
})
.collect();
let query = parse_query_string(&query_str);
let rest = matched.rest.clone().unwrap_or_default();
let request_id = req.request_id;
let request_path = req.path.clone();
let request_headers = req.headers.clone();
let request_body = req.body.clone();
match matched.matched.dispatch_mode {
DispatchMode::Async => {
handle_async_route(
&state,
app_id,
matched.matched.route_id,
matched.matched.script_id,
&script.name,
path,
method,
header_map,
body_json,
matched.params,
query,
rest,
script.timeout_seconds,
principal,
)
.await
}
DispatchMode::Sync => {
handle_sync_route(
&state,
app_id,
matched.matched.route_id,
matched.matched.script_id,
&script.name,
path,
method,
header_map,
body_json,
matched.params,
query,
rest,
script.timeout_seconds,
principal,
)
.await
}
}
}
let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
#[allow(clippy::too_many_arguments)]
async fn handle_async_route<E, R>(
state: &DataPlaneState<E, R>,
app_id: AppId,
route_id: Uuid,
script_id: ScriptId,
script_name: &str,
path: String,
method: String,
headers: BTreeMap<String, String>,
body: Json_,
params: BTreeMap<String, String>,
query: BTreeMap<String, String>,
rest: String,
timeout_seconds: u32,
principal: Option<Principal>,
) -> Result<Response, ApiError>
where
E: ExecutorClient + 'static,
R: ScriptResolver + 'static,
{
let payload = HttpDispatchPayload {
script_name: script_name.to_string(),
path,
method,
headers,
body,
params,
query,
rest,
timeout_seconds,
};
let payload_value = serde_json::to_value(&payload)
.map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?;
let execution_id = ExecutionId::new();
state
.outbox
.enqueue_http(NewHttpOutbox {
app_id,
route_id,
script_id,
reply_to: None,
payload: payload_value,
origin_principal: principal.map(|p| p.user_id),
trigger_depth: 0,
root_execution_id: Some(execution_id),
})
.await
.map_err(|e| ApiError::OutboxWrite(e.to_string()))?;
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({
"accepted_at": Utc::now().to_rfc3339(),
"execution_id": execution_id.to_string(),
})),
)
.into_response())
}
#[allow(clippy::too_many_arguments)]
async fn handle_sync_route<E, R>(
state: &DataPlaneState<E, R>,
app_id: AppId,
route_id: Uuid,
script_id: ScriptId,
script_name: &str,
path: String,
method: String,
headers: BTreeMap<String, String>,
body: Json_,
params: BTreeMap<String, String>,
query: BTreeMap<String, String>,
rest: String,
timeout_seconds: u32,
principal: Option<Principal>,
) -> Result<Response, ApiError>
where
E: ExecutorClient + 'static,
R: ScriptResolver + 'static,
{
let payload = HttpDispatchPayload {
script_name: script_name.to_string(),
path: path.clone(),
method,
headers: headers.clone(),
body: body.clone(),
params,
query,
rest,
timeout_seconds,
};
let payload_value = serde_json::to_value(&payload)
.map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?;
// Register the inbox before writing the outbox row so the
// dispatcher can't race-deliver before the orchestrator is
// listening.
let (inbox_id, rx) = state.inbox.register();
let execution_id = ExecutionId::new();
let outbox_id = state
.outbox
.enqueue_http(NewHttpOutbox {
app_id,
route_id,
script_id,
reply_to: Some(inbox_id),
payload: payload_value,
origin_principal: principal.map(|p| p.user_id),
trigger_depth: 0,
root_execution_id: Some(execution_id),
})
.await
.map_err(|e| {
// Failed outbox write — abandon the inbox so the dispatcher
// can never deliver to a stale entry.
state.inbox.cancel(inbox_id);
ApiError::OutboxWrite(e.to_string())
})?;
// Wait for the dispatcher's delivery. Outer timeout = script
// wall-clock + a small buffer to cover dispatcher latency.
let wait_budget = Duration::from_secs(u64::from(timeout_seconds)) + Duration::from_secs(2);
let request_id = RequestId::new();
let started = Utc::now();
let outcome = state.executor.execute(&script.source, req, timeout).await;
let result = tokio::time::timeout(wait_budget, rx).await;
let finished = Utc::now();
let log = build_execution_log(
script.app_id,
matched.matched.script_id,
// Tear down the receiver if it's still alive. `inbox.cancel` is a
// no-op when the dispatcher already delivered.
let _ = state.inbox.cancel(inbox_id);
let response = match result {
Ok(Ok(InboxResult::Success(summary))) => http_response_from_summary(summary),
Ok(Ok(InboxResult::Failure { kind, message })) => failure_to_response(kind, &message),
Ok(Err(_recv)) => {
// Channel was closed without a value — dispatcher dropped
// the sender. Treat as platform failure.
tracing::warn!(
outbox_id = %outbox_id,
"inbox channel closed without delivery"
);
failure_to_response(
InboxFailureKind::Platform,
"dispatcher closed inbox without delivery",
)
}
Err(_elapsed) => {
// Outer timeout — either the script was too slow or the
// dispatcher is wedged. Returns 504 by default.
failure_to_response(InboxFailureKind::Timeout, "request timed out")
}
};
let log = build_inbox_execution_log(
app_id,
script_id,
request_id,
request_path,
request_headers,
request_body,
&outcome,
path,
headers,
body,
response.status().as_u16(),
started,
finished,
);
if let Err(e) = state.log_sink.record(log).await {
tracing::warn!(
error = %e,
script_id = %matched.matched.script_id,
%script_id,
"failed to persist execution log"
);
}
Ok(exec_response_to_http(outcome?))
Ok(response)
}
fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> Response {
let status =
StatusCode::from_u16(summary.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut http_headers = HeaderMap::new();
for (k, v) in summary.headers {
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
http_headers.insert(name, value);
}
}
http_headers
.entry(axum::http::header::CONTENT_TYPE)
.or_insert_with(|| HeaderValue::from_static("application/json"));
(status, http_headers, Json(summary.body)).into_response()
}
/// Map `InboxFailureKind` onto the design-notes §3 status-code table.
fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
let status = match kind {
InboxFailureKind::Validation => StatusCode::UNPROCESSABLE_ENTITY,
InboxFailureKind::Runtime => StatusCode::BAD_GATEWAY,
InboxFailureKind::Overloaded => StatusCode::SERVICE_UNAVAILABLE,
InboxFailureKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE,
InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(serde_json::json!({ "error": message }));
if matches!(kind, InboxFailureKind::Overloaded) {
return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response();
}
(status, body).into_response()
}
#[allow(clippy::too_many_arguments)]
fn build_inbox_execution_log(
app_id: AppId,
script_id: ScriptId,
request_id: RequestId,
request_path: String,
request_headers: BTreeMap<String, String>,
request_body: Json_,
response_code: u16,
started: chrono::DateTime<Utc>,
finished: chrono::DateTime<Utc>,
) -> ExecutionLog {
let duration_ms = u64::try_from(
finished
.signed_duration_since(started)
.num_milliseconds()
.max(0),
)
.unwrap_or(0);
let status = if (200..400).contains(&response_code) {
ExecutionStatus::Success
} else {
ExecutionStatus::Error
};
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,
request_headers,
request_body,
response_code: Some(response_code),
response_body: None,
script_logs: Json_::Array(vec![]),
duration_ms,
status,
created_at: started,
}
}
fn parse_query_string(s: &str) -> BTreeMap<String, String> {
@@ -421,6 +695,9 @@ pub enum ApiError {
#[error("execution error: {0}")]
Exec(#[from] ExecError),
#[error("outbox write failed: {0}")]
OutboxWrite(String),
}
impl IntoResponse for ApiError {
@@ -444,6 +721,13 @@ impl IntoResponse for ApiError {
let (status, message) = match &self {
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
E::OutboxWrite(e) => {
tracing::error!(error = %e, "outbox write failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
E::Resolver(e) => {
tracing::error!(error = %e, "resolver failure");
(