Background services
Register application-lifetime work on the builder, open a DI scope per job, and cooperate with shutdown.
Use BackgroundServiceTrait for periodic work that is independent of a connection or a single message. WsAppBuilder::add_background_service::<T>() connects the worker to the application's lifecycle: Lily owns preparation, startup, cancellation, and awaiting termination.
Dependencies and registration#
The first application's lilyrs dependency is sufficient; no separate background-service or injection feature is required. Since this example uses a timer, you can write the Tokio dependency as follows:
lilyrs = { version = "0.1.0", features = ["websocket"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }Save the file below as src/background.rs and add mod background; to the crate root. In the first application's builder chain, call .add_background_service::<background::ProcessingWorker>() before build. Alternatively, the file's with_worker(builder) function performs the same registration. Controller link-time discovery is independent of this registration.
Open a scope for every job#
use std::{sync::Arc, time::Duration};
use lilyrs::websocket::{
ApplicationScopeFactory, BackgroundCancellation, BackgroundServiceTrait,
Injectable, InjectionError, ProcessContext, ServiceTrait, WsAppBuilder, async_trait,
};
#[derive(Injectable)]
#[service(lifetime = "Scoped")]
struct ProcessingService;
impl ServiceTrait for ProcessingService {}
impl ProcessingService {
async fn process_next(&self, stopping: BackgroundCancellation) -> Result<(), InjectionError> {
if stopping.is_cancelled() { return Ok(()); }
// Replace this with one bounded application operation.
tokio::task::yield_now().await;
Ok(())
}
}
pub struct ProcessingWorker {
scopes: Arc<ApplicationScopeFactory>,
}
#[async_trait]
impl BackgroundServiceTrait for ProcessingWorker {
type Error = InjectionError;
async fn new(scopes: Arc<ApplicationScopeFactory>) -> Result<Self, Self::Error> {
Ok(Self { scopes })
}
async fn execute_async(&mut self, stopping: BackgroundCancellation) -> Result<(), Self::Error> {
while !stopping.is_cancelled() {
let signal = stopping.clone();
self.scopes.create_scope(ProcessContext::new())?
.run(move |extensions| Box::pin(async move {
extensions.get_service::<ProcessingService>(None).await?
.process_next(signal).await
}))
.await?;
tokio::select! {
_ = stopping.cancelled() => break,
_ = tokio::time::sleep(Duration::from_secs(1)) => {},
}
}
Ok(())
}
}
pub fn with_worker(builder: WsAppBuilder) -> WsAppBuilder {
builder.add_background_service::<ProcessingWorker>()
}Download the complete example.
The example body of process_next represents your workload; replace it with bounded data access or one application unit of work. The worker does not retain a Scoped ProcessingService in a field. It opens a scope for each iteration through ApplicationScopeFactory; ServiceScope::run resolves the service in the correct ProcessContext and waits for scope disposal. Do not carry the callback's services or Extensions outside the scope or start detached tasks using them.
When does it run?#
new runs during build. execute_async starts once, after listener bind and any Required backplane subscription are ready; it does not wait for Optional backplane readiness. Failed startup and close-before-start do not execute the worker. Application readiness does not wait for the worker's first job to finish; if a dependency needs that rule, establish a health registration in your application.
The same concrete worker type is registered once per application. Registering it in separate HTTP and WebSocket applications creates two independent workers. Ok(()) is normal completion for a one-shot or disabled worker. An unhandled error or panic initiates host shutdown; there is no automatic retry or restart. Handle expected shutdown cancellation and return Ok(()); implement bounded domain retry policy inside the worker.
Two cancellation types#
BackgroundCancellation is an alias of the shared lily_cancellation::ExecutionCancellation type. The lilyrs::websocket::ExecutionCancellation used by message actions/middleware is a different type. A message timeout or peer disconnect does not cancel the worker; its signal follows host shutdown. Do not use message cancellation as the worker parameter.
Operations must preserve their own bounded durations and cancellation contracts. Cancellation alone does not roll back a database commit. While cooperating with shutdown, a worker may open a finalization scope; scope admission closes when workers terminate or forced stopping begins.
Shutdown and ownership#
Workers share the application's [lifecycle].shutdown_timeout_secs budget. After cancellation, Lily tracks bounded cooperation and actual task joins, scope cleanup, backplane/owned DI disposal, and telemetry flush. Each phase does not start a fresh budget with a separate worker timeout. Dropping a waiter does not remove these owners.
A synchronously blocking future poll or destructor cannot be forcibly terminated. If termination cannot be verified, shutdown is incomplete and the dependencies in use are retained. An external container remains caller-owned. The shutdown guide explains how to interpret the outcome.
A worker's cooperation during shutdown does not grant the private outbound continuation authority of an active WebSocket callback. During shutdown, the dispatcher rejects new external sends; do not assume a final notification reaches a closing peer. Tasks you start separately with tokio::spawn do not become managed automatically through this worker registration.
Observation and testing#
Worker lifecycle events use the application's tracing runtime; the pre-flush shutdown checkpoint includes worker/scope counters. Use tracing and health to observe job outcomes and readiness requirements separately.
Test successful startup, close-before-start, per-job scope sharing/cleanup, cooperative cancellation, and an unhandled worker error shutting down the host. Do not infer scope disposal or task join from cancellation having been signaled. Live external-system operations require separate integration tests.
Sources for this page v0.1.0
This guide is based on the reviewed framework commit.