Run background work

Use this when: work must happen on its own—periodic cleanup, imports, or reconciliation—without a browser or API request triggering it.

The framework supervises workers. It is not a durable job queue: job storage, delivery guarantees, retries, and scheduling policy remain application decisions. This guide shows the lifecycle wiring around one operation.

1. Add the dependencies

Create examples/my-worker/Cargo.toml:

examples/my-worker/Cargo.tomltoml
[package]
name = "my-worker"
version = "0.1.0"
edition = "2024"
publish = false

[workspace]

[dependencies]
iet-app = { path = "../../crates/core/iet-app" }
iet-config = { path = "../../crates/core/iet-config" }
iet-config-env = { path = "../../crates/features/iet-config-env" }
iet-core = { path = "../../crates/core/iet-core" }
iet-runtime-tokio = { path = "../../crates/features/iet-runtime-tokio" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] }

iet-app defines the worker and shutdown contracts. iet-runtime-tokio runs and drains them. iet-core supplies AppResult/AppError; it does not start a server.

2. Write the worker

This complete program runs one placeholder operation every five seconds, then exits cleanly on Ctrl+C. Replace expire_tasks_once with your real operation.

examples/my-worker/src/main.rsrust
use std::time::Duration;
use iet_app::{Module, Worker};
use iet_config::ConfigSource;
use iet_core::AppResult;
use iet_runtime_tokio::TokioRuntime;

struct ExpireTasks;

impl Worker for ExpireTasks {
    async fn run<S: iet_app::Shutdown>(self, shutdown: S) -> AppResult<()> {
        // Tokio's first tick completes immediately, so maintenance starts now.
        let mut tick = tokio::time::interval(Duration::from_secs(5));
        loop {
            tokio::select! {
                // Stop promptly; do not begin another unit of work.
                _ = shutdown.clone().cancelled() => return Ok(()),
                _ = tick.tick() => expire_tasks_once().await?,
            }
        }
    }
}

impl Module for ExpireTasks {
    const NAME: &'static str = "expire-tasks";
    type Worker = Self;

    async fn prepare<C: ConfigSource>(self, _source: &C) -> AppResult<Self::Worker> {
        // Acquire owned resources here, such as a database handle.
        Ok(self)
    }
}

async fn expire_tasks_once() -> AppResult<()> {
    println!("checking for expired tasks");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let source = iet_config_env::EnvSource::snapshot("WORKER")?;

    iet_app::Application::new()
        .configuration(source)
        .runtime(TokioRuntime::new(Duration::from_secs(10))?)
        .inject(ExpireTasks)
        .run_until(async {
            let _ = tokio::signal::ctrl_c().await;
        })
        .await?;

    Ok(())
}

3. Run it

sh
cargo run --manifest-path examples/my-worker/Cargo.toml
Expected output
checking for expired tasks

It repeats every five seconds. Stop it with Ctrl+C. The operation's AppError propagates out of run, so the process does not silently continue after a failure.

One operation, several callers

expire_tasks_once can call the same service used by your page or HTTP handler. The worker supplies the trigger and lifecycle; it should not duplicate the business rule. This example is public and uses no database, so a real operation will add configuration, a session, and authorization as needed.

What the runtime guarantees

The framework handles

  • Starting each injected module's worker.
  • Reporting a worker failure and stopping the application.
  • Signalling shutdown and waiting up to the configured drain time.
  • Aborting remaining tasks if the drain deadline expires.

Your application handles

  • What each operation does and when it should run.
  • Job persistence, duplicate delivery, retries, and backoff.
  • Database sessions, transactions, and ownership checks.
  • Idempotency when an operation may run more than once.

If a worker returns Ok(()) unexpectedly, the runtime treats that as an unavailable worker rather than successful application completion. A deliberate one-shot process is a command, not a long-running worker.

Add another worker

inject accepts another Module with a unique name:

rust
// Fragment: each module prepares and starts independently.
let app = iet_app::Application::new()
    .configuration(source)
    .runtime(runtime)
    .inject(ExpireTasks)
    .inject(ImportTasks);

Starting and draining are supervised together. There is no built-in cross-worker ordering guarantee, so do not rely on one finishing before another starts unless you coordinate that explicitly.

Source: crates/core/iet-app/src/lib.rs and crates/features/iet-runtime-tokio/src/lib.rs.

IET Framework documentation — built with the IET Framework.