runtime-rs 0.1.4

Typed service registry and Tokio lifecycle runtime for boot, reload, background tasks, and graceful shutdown.
Documentation

runtime-rs

Tokio-native lifecycle and service composition for Rust applications.

runtime-rs gives your app one place to register services, boot them, reload them, run their background tasks, resolve them by Rust type, and shut them down gracefully:

  • register typed services once
  • boot / validate / reload / shutdown them in deterministic dependency order
  • resolve them later by concrete Rust type
  • remove the need for ad-hoc runtime injection
  • spawn long-running async runnable providers
  • drain accepted work through graceful shutdown gates
  • publish typed in-process lifecycle events with the events feature

The core idea:

Registry<State>
  ├─ DbService      -> Provider + Reloadable
  ├─ CacheService   -> Provider + Runnable
  └─ YourService    -> Provider

Runtime<State>
  └─ spawns every provider that exposes Runnable

Features

Default features are:

default = ["registry"]
full = ["registry", "support", "events"]
  • registry adds registry_ref() to SharedState for states that carry Registry<Self>. The Registry type itself is always available.
  • events enables LifecycleBus, adds events() to SharedState, and enables the dashmap dependency.
  • support enables Gate, Permit, GuardGroup, and Guard.

Your State

The crate does not own your app state. Your state only needs to implement SharedState. If you want registry.reload_all(&state).await, implement ReloadState too.

use async_trait::async_trait;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use runtime_rs::LifecycleBus;
use runtime_rs::{Registry, ReloadState, Result, SharedState};

#[derive(Clone)]
pub struct AppState(Arc<Inner>);

struct Inner {
    shutdown: CancellationToken,
    registry: Registry<AppState>,
    events: LifecycleBus,
}

impl Default for AppState {
    fn default() -> Self {
        Self(Arc::new(Inner {
            shutdown: CancellationToken::new(),
            registry: Registry::default(),
            events: LifecycleBus::new(),
        }))
    }
}

impl AppState {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn on_shutdown(&self) -> impl std::future::Future<Output = ()> + '_ {
        self.0.shutdown.cancelled()
    }
}

impl SharedState for AppState {
    fn shutdown_token(&self) -> CancellationToken {
        self.0.shutdown.clone()
    }

    fn registry_ref(&self) -> &Registry<Self> {
        &self.0.registry
    }

    fn events(&self) -> &LifecycleBus {
        &self.0.events
    }
}

#[async_trait]
impl ReloadState for AppState {
    async fn reload(&self) -> Result<()> {
        Ok(())
    }
}

The Registry

Registry<S> is the main piece. It stores Arc<T> values by TypeId, while also treating them as lifecycle providers.

That means one object can be all of these at once:

  • a concrete service you can resolve later: registry.resolve::<DbService>()
  • a lifecycle participant: boot, validate, shutdown
  • a hot-reload participant: Reloadable
  • a long-running background task: Runnable
use std::sync::Arc;

let state = AppState::new();
state
    .registry_ref()
    .insert(Arc::new(DbService::new()))
    .insert(Arc::new(CacheService::new()));

state.registry_ref().boot_all(&state).await?;
state.registry_ref().validate_all(&state)?;

let db = state.registry_ref().resolve::<DbService>().expect("DbService registered");

Register once. Boot, reload, run, and resolve by type.

Providers

A provider is any service that wants to join the application lifecycle.

use async_trait::async_trait;
use runtime_rs::{Provider, Result};

pub struct DbService;

#[async_trait]
impl Provider<AppState> for DbService {
    fn name(&self) -> &'static str {
        "db"
    }

    async fn boot(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Open pools, read config, build snapshots, publish ready state.
        Ok(())
    }

    fn validate(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Cheap preflight checks before boot.
        Ok(())
    }

    async fn shutdown(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Best-effort cleanup after shutdown begins.
        Ok(())
    }
}

Lifecycle Ordering

Lifecycle order is deterministic and type-aware. Providers can express concrete dependencies with ProviderOrder::before::<T>() / ProviderOrder::after::<T>() instead of relying on registration order or magic priority numbers.

The same lifecycle plan is used for:

  • validate_all
  • boot_all
  • reload_all
  • shutdown_all, in reverse order

Reload skips providers that are not Reloadable, but it keeps the same relative dependency order as boot. If a dependency cycle is introduced, the registry returns an error before running the lifecycle phase.

use runtime_rs::{Provider, ProviderOrder};

pub struct DbService;
pub struct CacheService;

impl Provider<AppState> for CacheService {
    fn order(&self) -> ProviderOrder {
        ProviderOrder::new().after::<DbService>()
    }
}

Registration order can now stay ergonomic:

use std::sync::Arc;

state.registry_ref().insert(Arc::new(CacheService));
state.registry_ref().insert(Arc::new(DbService));

let names = state.registry_ref().lifecycle_names()?;
println!("Lifecycle order: {}", names.join(" -> "));

state.registry_ref().boot_all(&state).await?;
// DbService boots before CacheService because CacheService says:
// ProviderOrder::new().after::<DbService>()

boot_priority() and Reloadable::priority() are still available as coarse tie-breakers among otherwise-ready providers. Prefer ProviderOrder for real dependencies. run_priority() controls only runtime task spawn order.

Runnable Providers

Long-running loops live in Runnable::run(), not in boot(). Runnable::run() is an async trait method and receives self: Arc<Self>, so providers can be spawned without returning a boxed task future.

use std::sync::Arc;
use async_trait::async_trait;
use runtime_rs::{Provider, Result, Runnable};

#[async_trait]
impl Runnable<AppState> for CacheService {
    async fn run(
        self: Arc<Self>,
        state: AppState
    ) -> Result<()> {
        state.on_shutdown().await;
        Ok(())
    }
}

impl Provider<AppState> for CacheService {
    fn as_runnable(self: Arc<Self>) -> Option<Arc<dyn Runnable<AppState>>> {
        Some(self)
    }
}

Then the runtime starts every runnable provider:

use runtime_rs::{Runtime, SharedState};

let state = AppState::new();
let mut runtime = Runtime::<AppState>::default();

runtime.spawn_all(state.registry_ref(), state.clone());
state.initiate_shutdown();
runtime.wait_until_shutdown(&state).await?;
runtime.drain().await?;
# Ok::<(), runtime_rs::Error>(())

Reloadable Providers

Reload is a capability, not a second registry.

use async_trait::async_trait;
use runtime_rs::{Provider, Reloadable, Result};

#[async_trait]
impl Reloadable<AppState> for DbService {
    async fn reload(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Rebuild runtime snapshot and atomically publish it.
        Ok(())
    }
}

impl Provider<AppState> for DbService {
    fn as_reloadable(&self) -> Option<&dyn Reloadable<AppState>> {
        Some(self)
    }
}

Use registry.reload_one("db", &state).await for targeted reloads or registry.reload_all(&state).await for full reloads.

reload_all first calls ReloadState::reload() on your state, then walks the same lifecycle order used by boot_all and calls Reloadable::reload() on reloadable providers.

Gates

The support feature enables optional runtime support tools. They are not required by Registry or Runtime, but they are useful when implementing servers, connection loops, and request handlers.

Gate is a graceful shutdown admission/drain tool. It is inspired by axum's server graceful-shutdown pattern, but lifted out of HTTP.

It can:

  • reject new work after graceful shutdown starts
  • track accepted in-flight work with Permit
  • wait until all permits drop
  • force shutdown after a grace period
  • optionally apply simple max-in-flight admission control
use std::time::Duration;
use runtime_rs::Gate;

let gate = Gate::new(Some(1024), Duration::from_millis(100));
let permit = gate.enter().await?;

gate.graceful_shutdown(Some(Duration::from_secs(30)));
gate.wait_all_done().await;
# Ok::<(), runtime_rs::gate::Error>(())

GuardGroup is a smaller RAII in-flight counter for places where you only need “count active work and wait until zero” without admission control.

Lifecycle Events

The events feature enables LifecycleBus, a typed process-local event bus. Use it when services need a loose in-process signal without depending on each other directly.

use runtime_rs::LifecycleBus;

#[derive(Clone, Debug)]
struct ConfigReloaded;

let bus = LifecycleBus::new();
let mut rx = bus.subscribe::<ConfigReloaded>();
bus.emit(ConfigReloaded);

Examples

The library crate keeps small single-file examples:

examples/minimal.rs
examples/axum.rs

Run them with:

cargo run --example minimal --features registry
cargo run --example axum --features registry

examples/minimal.rs demonstrates type-based boot order by registering IdleService before DbProvider while DbProvider declares ProviderOrder::new().before::<IdleService>(); the example prints Planned boot order: db -> idle -> control before boot starts, then Boot order: db -> idle -> control after boot completes.

axum is a dev-dependency only. It is there to show integration style; it is not part of runtime-rs for library users.

Dependencies

The dependency set is intentionally small and canonical for Tokio-based services:

async-trait = "0.1"
dashmap = "6"
tokio = { version = "1", features = ["macros", "rt", "sync", "time"] }
tokio-util = "0.7"
tracing = "0.1"

dashmap is only required when the events feature is enabled.

License

Licensed under either of: