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:
@@ -26,10 +26,11 @@ use picloud_manager_core::{
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
use picloud_orchestrator_core::{
|
||||
data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, LocalExecutorClient,
|
||||
data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, InboxRegistry,
|
||||
LocalExecutorClient,
|
||||
};
|
||||
use picloud_shared::{
|
||||
ExecutionLogSink, InboxResolver, KvService, NoopDeadLetterService, NoopInboxResolver,
|
||||
ExecutionLogSink, InboxResolver, KvService, NoopDeadLetterService, OutboxWriter,
|
||||
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
||||
WIRE_VERSION,
|
||||
};
|
||||
@@ -106,7 +107,14 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
// Triggers framework storage. The outbox event emitter routes
|
||||
// KV mutations into the outbox; the dispatcher fans them out.
|
||||
let trigger_repo: Arc<dyn TriggerRepo> = Arc::new(PostgresTriggerRepo::new(pool.clone()));
|
||||
let outbox_repo: Arc<dyn OutboxRepo> = Arc::new(PostgresOutboxRepo::new(pool.clone()));
|
||||
// PostgresOutboxRepo implements both `OutboxRepo` (the dispatcher
|
||||
// surface) and `OutboxWriter` (the orchestrator surface). Construct
|
||||
// the concrete Arc once, clone it into each trait view — same
|
||||
// allocation, two vtables (mirrors how `members_concrete` above is
|
||||
// used as both `AppMembersRepository` and `AuthzRepo`).
|
||||
let outbox_concrete = Arc::new(PostgresOutboxRepo::new(pool.clone()));
|
||||
let outbox_repo: Arc<dyn OutboxRepo> = outbox_concrete.clone();
|
||||
let outbox_writer: Arc<dyn OutboxWriter> = outbox_concrete;
|
||||
let dl_repo: Arc<dyn DeadLetterRepo> = Arc::new(PostgresDeadLetterRepo::new(pool.clone()));
|
||||
let abandoned_repo: Arc<dyn AbandonedRepo> = Arc::new(PostgresAbandonedRepo::new(pool.clone()));
|
||||
let trigger_config = TriggerConfig::from_env();
|
||||
@@ -159,13 +167,16 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
|
||||
// Dispatcher — single tokio task that polls the outbox and routes
|
||||
// due rows to the executor. Shares the `ExecutionGate` with sync
|
||||
// HTTP per design notes §2 (one cap for everything). NoopInboxResolver
|
||||
// until commit 6 wires the real in-process inbox registry.
|
||||
// HTTP per design notes §2 (one cap for everything).
|
||||
let dispatcher_script_repo: Arc<dyn ScriptRepository> =
|
||||
Arc::new(PostgresScriptRepoHandle(script_repo.clone()));
|
||||
let principals: Arc<dyn PrincipalResolver> =
|
||||
Arc::new(AdminPrincipalResolver::new(auth.users.clone()));
|
||||
let inbox: Arc<dyn InboxResolver> = Arc::new(NoopInboxResolver);
|
||||
// The InboxRegistry is constructed once and shared between the
|
||||
// orchestrator (registers receivers, awaits) and the dispatcher
|
||||
// (delivers results). Two Arc views on the same allocation.
|
||||
let inbox_registry = Arc::new(InboxRegistry::new());
|
||||
let inbox_resolver: Arc<dyn InboxResolver> = inbox_registry.clone();
|
||||
Dispatcher {
|
||||
outbox: outbox_repo.clone(),
|
||||
triggers: trigger_repo.clone(),
|
||||
@@ -175,7 +186,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
principals,
|
||||
executor: executor.clone(),
|
||||
gate,
|
||||
inbox,
|
||||
inbox: inbox_resolver,
|
||||
config: trigger_config,
|
||||
instance_id: format!("picloud-{}", std::process::id()),
|
||||
}
|
||||
@@ -202,6 +213,8 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
log_sink,
|
||||
app_domains: app_domain_table.clone(),
|
||||
routes: route_table,
|
||||
inbox: inbox_registry,
|
||||
outbox: outbox_writer,
|
||||
};
|
||||
// Silence unused-import warnings for repos handed to the
|
||||
// dispatcher in this commit; commit 8 wires them into the
|
||||
|
||||
Reference in New Issue
Block a user