baukit-runtime 0.10.9

Process lifecycle and service composition primitives for Baukit applications.
Documentation

baukit-runtime

baukit-runtime owns what an entire process needs regardless of what it serves: shutdown signaling, a bounded drain, supervised background tasks, build identity, and binding the public and operations listeners. Products keep their configuration, routes, and application state.

use std::{sync::Arc, time::Duration};

use axum::Router;
use baukit_runtime::{
    ProcessKind, RestartPolicy, ServiceInfo, ShutdownOrder, ShutdownToken, TaskSupervisor,
    build_info, serve_listener_pair_with_shutdown_order,
};
use baukit_telemetry::{DeploymentEnvironment, TelemetryBuilder};
use tokio::net::TcpListener;
# async fn run_outbox_until(_: ShutdownToken) {}
# fn product_routes() -> Router { Router::new() }
# fn operations_routes() -> Router { Router::new() }

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let service = ServiceInfo::new(
        "orders",
        ProcessKind::Api,
        build_info!(),
        DeploymentEnvironment::Local,
    );
    let telemetry =
        Arc::new(TelemetryBuilder::new(service.telemetry_identity().clone()).init()?);

    let shutdown = ShutdownToken::new(Duration::from_secs(30));
    let signal_task = shutdown.spawn_signal_listener();

    let mut workers = TaskSupervisor::new(shutdown.clone());
    let worker_shutdown = shutdown.child_token();
    workers.spawn("outbox", RestartPolicy::Restart { max_restarts: 3 }, move || {
        let worker_shutdown = worker_shutdown.clone();
        async move { run_outbox_until(worker_shutdown).await }
    });

    let api = TcpListener::bind("127.0.0.1:0").await?;
    let ops = TcpListener::bind("127.0.0.1:0").await?;
    let listener_result = serve_listener_pair_with_shutdown_order(
        api,
        Router::new().merge(product_routes()),
        ops,
        Router::new().merge(operations_routes()),
        shutdown.clone(),
        ShutdownOrder::OpsOutlivesApi,
    )
    .await;

    shutdown.trigger();
    let _ = workers.join().await;

    // Telemetry shutdown is synchronous; keep it off the async executor.
    let telemetry_for_flush = Arc::clone(&telemetry);
    shutdown
        .run_during_drain(async move {
            tokio::task::spawn_blocking(move || telemetry_for_flush.shutdown()).await
        })
        .await???;

    signal_task.abort();
    listener_result?;
    Ok(())
}

Draining on a deadline

ShutdownToken::new takes the maximum time the process may spend draining, and every piece of shutdown work shares that one budget. Not a per-task timeout: one deadline for all of it.

An orchestrator sends SIGTERM and then kills the container after its own grace period. A process that gives each of six shutdown steps thirty seconds can spend three minutes shutting down and get SIGKILLed partway through, which is exactly the ungraceful exit the sequence was supposed to avoid. Sharing one deadline means the process finishes what it can and exits on its own terms.

run_during_drain runs asynchronous cleanup inside that budget. on_drain registers a fast synchronous hook that fires once on the first trigger, before cancellation wakes async work. That hook is the seam for closing a baukit_ops::TrafficGate with no watcher task and no dependency from runtime onto ops.

Listener ordering

ShutdownOrder::Together stops both listeners at once. OpsOutlivesApi keeps operations serving until the API has drained or the deadline expires.

OpsOutlivesApi is the one you usually want. If the ops listener dies first, the orchestrator's readiness probe starts failing while the API is still finishing in-flight requests, and the events land in the wrong order: the platform reports the process as unhealthy rather than as shutting down normally. Keeping ops alive lets the process say "draining" until it is actually done.

Both listeners arrive already bound, so addresses and socket options stay in the composition root. If either listener fails, shutdown is triggered and the peer is drained.

Supervised tasks

TaskSupervisor ties named background tasks to one shutdown token. RestartPolicy::FailProcess brings the process down as soon as the task stops. Restart { max_restarts } restarts up to that many times, then brings the process down.

There is no infinite-restart option on purpose. A task that keeps dying and keeps restarting is a service that looks alive to its orchestrator while doing no work, and that is a worse outage than a crash because nothing escalates. Exhausting the budget triggers shutdown and lets the platform restart the whole process with the backoff and alerting it already has.

Give each task the child_token, not the parent. Triggering a child also triggers the shared process token, so a task that decides to stop takes the process with it rather than leaving it half-running.

Build identity

build_info! captures version, commit, and Rust version from the crate that invokes it. GIT_COMMIT comes from the build pipeline and falls back to "unknown" locally. ServiceInfo holds the canonical ServiceIdentity alongside that build metadata, so runtime, logs, metrics, and traces cannot disagree about which process this is.

Scope

No product configuration, no telemetry policy, no routes. The crate composes what a process needs and leaves everything above it alone.

TLS crypto provider

Baukit uses aws-lc-rs for rustls. Products must keep that provider consistent across normal and dev dependencies. Use sqlx tls-rustls-aws-lc-rs, not tls-rustls. For testcontainers, set default-features = false and add features = ["aws-lc-rs"] for Docker fixtures. reqwest's rustls feature already selects aws-lc-rs.

Check the product graph with cargo tree -e features -i rustls. If rustls has both ring and aws_lc_rs enabled, its client and server config builders can panic at startup unless the process explicitly installs a provider. Baukit's workspace tests exercise both builders without installing one.