# 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:
```text
Registry<State>
├─ DbService -> Provider + Reloadable
├─ CacheService -> Provider + Runnable
└─ YourService -> Provider
Runtime<State>
└─ spawns every provider that exposes Runnable
```
## Features
Default features are:
```toml
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.
```rust
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`
```rust
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.
```rust
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.
```rust
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:
```rust
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.
```rust
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:
```rust
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.
```rust
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
```rust
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.
```rust
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:
```text
examples/minimal.rs
examples/axum.rs
```
Run them with:
```sh
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:
```toml
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:
- MIT license ([LICENSE-MIT](LICENSE-MIT))
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))