ADR 0015: Application Runtime and Consumer Model¶
Status: Proposed Date: 2026-05-24
Context¶
Today the facade App (crates/spikard/src/lib.rs) always builds an Axum router and
calls Server::run_with_config — running a service requires an HTTP listener. The
service toolbox (see the Roadmap) needs to run message-broker
consumers and task workers, possibly with no HTTP server at all (a pure consumer
service). We need a runtime that hosts HTTP and non-HTTP work together with a single
lifecycle, and a consumer model that reuses the existing FFI callback machinery rather
than inventing a new one.
Decision¶
- Unified
Applicationruntime inspikard-app: hosts an optional HTTP server plus any number of broker consumers, task workers, and schedulers.Application::run()starts whichever components are configured under one sharedCancellationTokenand one shutdown-signal handler (lifted fromcrates/spikard-http/src/server/mod.rs), and selects across them. AnApplicationwith no routes and one consumer is valid — this is how a pure-consumer service runs. - Consumer and worker loops reuse
BackgroundRuntime(crates/spikard-http/src/background.rs): an mpsc/poll source, aJoinSet, aSemaphorefor the concurrency cap, aCancellationToken, and a bounded graceful drain. The broker consume loop selects on the broker stream versus cancellation; the task worker is the durable variant (ADR 0018). - Handler traits mirror
HandlerandWebSocketHandlerand are implemented by bindings wrapping a host callback inArc<dyn _>via the existing async FFI machinery (pyo3 async, napiThreadsafeFunction, Magnus, Rustler) — no new FFI mechanism.MessageHandler::handle(InboundMessage) -> Ack, whereAck = Ack | Nack { requeue } | Retry { after }. - Acknowledgement and retry are unified at the trait boundary: each backend adapter
maps
Ackto its native primitive (Kafka offset commit, AMQPbasic.ack/nack, NATS ack/nak/term, MQTT puback, RedisXACK).InboundMessage.delivery_countplus aConsumerConfigmax-retries policy routes exhausted messages to a dead-letter destination. - The facade
Appgains.consumer(...),.worker(...),.schedule(...),.storage(...),.cache(...), and.database(...)builders alongside the existing.route/.websocket/.sse, and stops requiring an HTTP server.
Consequences¶
- Graceful shutdown is shared: on signal, stop pulling new work, drain in-flight up to a timeout, then force-stop. In-flight messages that do not complete are nacked or left uncommitted so they redeliver — at-least-once by default.
- The handler traits, runtimes, and
Applicationarealef(skip)'d; only the DTOs (InboundMessage,Ack, the configs) cross to bindings. - Existing HTTP-only apps are unaffected: an
Applicationwith routes and no consumers behaves exactly as today.