Skip to content
Taskvisor 0.9source v0.9.0

Coordinate work by key ​

Use a controller when work that shares a key must take turns. Each controller slot has at most one owner, including work still waiting for admission or physical completion.

Enable the controller ​

This section requires the controller feature. It is enabled by default, but each supervisor must install a controller explicitly:

rust
use taskvisor::{ControllerConfig, Supervisor, SupervisorConfig};

let _supervisor = Supervisor::builder(SupervisorConfig::default())
    .with_controller(ControllerConfig::default())
    .build();

Separate IDs, names, and slots ​

Direct add operations bypass controller admission. submit accepts a ControllerSpec. The controller applies its slot policy before asking the runtime registry to admit the task.

IdentityScope
TaskIdOne process-local registration or controller submission.
Task nameRegistry key inside one supervisor.
Controller slotAdmission key inside one supervisor controller.

Different task names can share a slot. Without with_slot, the task name is also the slot. A queued submission has an ID, but does not own a registered task name yet.

A busy slot may be waiting for admission, running work, or waiting for physical completion. Busy does not always mean that a task body is currently polling.

Choose a busy-slot policy ​

PolicyBusy-slot behavior
QueueAppend to the bounded FIFO queue. A later Replace can still displace the queue head.
ReplaceRequest the owner to stop; create or replace the queue head.
DropIfRunningReject the incoming submission without changing the owner or queue.

A replacement is not guaranteed to become the next owner. A newer Replace can displace it before admission. The registry can also reject it later. Replace changes only the queue head and preserves the FIFO tail. It does not use the per-slot max_slot_queue limit, but creating a new head can still reach max_total_pending.

rust
use taskvisor::{ControllerSpec, TaskFn, TaskRef, TaskSpec};

let task: TaskRef = TaskFn::arc(|_ctx| async { Ok(()) });
let request = ControllerSpec::queue(TaskSpec::once("customer-42-job", task))
    .with_slot("customer-42");

assert_eq!(request.task_spec().name(), "customer-42-job");
assert_eq!(request.slot_name(), "customer-42");

Watch admission and completion ​

submit creates an operation whose execute() confirms command intake only. Its watch() modifier makes execute() return a waiter. Read the submission identity through waiter.id(). The waiter resolves to Rejected if admission fails or to the registered task's final outcome if admission succeeds.

The call first waits for cleanup ownership and controller-command capacity. After the command is queued, returning to the caller and processing the command can race. The return from submit(request).watch().execute() is not an admission reply or proof that the task has started.

This diagram shows successful admission. Slot or registry rejection resolves the waiter to Rejected instead.

mermaid
sequenceDiagram
accTitle: Controller intake, admission, and slot release
accDescr: Caller return and command processing can race, and slot release waits for logical and physical completion.
participant Caller
participant Controller
participant Registry
participant Waiter as TaskWaiter
Caller->>Caller: Wait for ownership and command capacity
Caller-)Controller: Queue submission
par Return from the call
    Caller-->>Caller: Return waiter (intake only)
and Process the command
    Controller->>Controller: Apply slot policy and wait if queued
    Controller->>Controller: Wait for registry-command capacity
    Controller-)Registry: Request admission
    Registry->>Registry: Admit task and run attempts
    alt Actor finishes without force-abort
        Registry->>Registry: Join actor
        Registry-)Waiter: Final outcome
    else Force-abort with physical exit still pending
        Registry-)Waiter: ForceAborted
        Registry->>Registry: Wait for physical exit
    end
    Registry-)Controller: Physical completion signal
    Controller->>Controller: Release slot and consider queued work
end

Each attempt waits for a concurrency permit if a limit is configured. The slot becomes reusable only after the registered task physically completes and terminal reporting is done. Deferred destruction of retained user values can continue after slot release. See task management for waiting and fail-fast calls.

This example starts an owner, rejects a competing DropIfRunning submission, and then stops the owner:

rust
use std::sync::Arc;
use tokio::sync::Notify;
use taskvisor::prelude::*;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let supervisor = Supervisor::builder(SupervisorConfig::default())
        .with_controller(ControllerConfig::default())
        .try_build()?;
    let handle = supervisor.serve()?;

    let owner_started = Arc::new(Notify::new());
    let owner: TaskRef = {
        let owner_started = Arc::clone(&owner_started);
        TaskFn::arc(move |ctx| {
            let owner_started = Arc::clone(&owner_started);
            async move {
                owner_started.notify_one();
                ctx.cancelled().await;
                Err(TaskError::Canceled)
            }
        })
    };

    let owner_request = ControllerSpec::queue(TaskSpec::once("tenant-42/owner", owner))
        .with_slot("tenant-42");
    let owner_waiter = handle.submit(owner_request).watch().execute().await?;
    let owner_id = owner_waiter.id();
    owner_started.notified().await;

    let contender: TaskRef = TaskFn::arc(|_ctx| async { Ok(()) });
    let contender_request = ControllerSpec::drop_if_running(TaskSpec::once(
        "tenant-42/contender",
        contender,
    ))
    .with_slot("tenant-42");
    let contender_waiter = handle
        .submit(contender_request)
        .watch()
        .execute()
        .await?;

    assert!(matches!(
        contender_waiter.wait().await?,
        TaskOutcome::Rejected {
            kind: RejectionKind::SlotBusy,
            ..
        }
    ));

    assert!(handle.cancel(owner_id).execute().await?);
    assert!(matches!(
        owner_waiter.wait().await?,
        TaskOutcome::Canceled
    ));

    handle.shutdown().await?;
    Ok(())
}

The notification proves that the first task has started before the contender is submitted. That owner waits for cancellation and keeps the slot occupied. The two waiters deliver the contender's rejection and the owner's final outcome directly.

prepare_submission allocates a task ID before intake. It does not reserve a name, slot, queue position, or runtime capacity.

Read diagnostic state ​

controller_snapshot reads each slot separately. Its ControllerSnapshot can already be stale when returned. Do not treat it as a transaction boundary.

Know timeout and cancellation scope ​

Attempt timeout starts only after registry admission and after Task::spawn returns the attempt future. It does not limit time spent in a controller queue. Controller submission has no built-in end-to-end deadline.

The ownership_timeout(duration) submission modifier limits only the wait for cleanup ownership before command intake. The timer stops when Taskvisor acquires the ownership permit. It does not cover controller-command capacity, a busy-slot queue, slot admission, registry-command capacity, task execution, or final outcome delivery. The operation created by PreparedSubmission::submit() uses the same boundary. A timeout consumes the prepared value, but produces no command or lifecycle event for its reserved ID.

Slots govern admission, not cancellation. There is no slot-wide cancel or remove operation. Stop queued work by task ID and registered work by task ID or task name. Removing or canceling work that is still queued or waiting for registry-command capacity removes it before it runs. Its waiter resolves to Rejected with RejectionKind::RemovedFromQueue, not Canceled.

Bound controller resources ​

ControllerConfig bounds separate stages:

SettingWhat it boundsWhen full
queue_capacityOrdered controller commands.execute() waits; try_intake() returns ControllerError::Full.
max_slot_queuePending depth behind one owner, including a replacement head.New Queue submissions get watched Rejected with QueueFull.
max_total_pendingSlot queues and registry-capacity waits.New pending entries get watched Rejected with ResourceLimit.
max_controller_slotsTracked slots.New slots get watched Rejected with ResourceLimit.
admission_capacityWaits for registry-command capacity.New waits get watched Rejected with ResourceLimit.
identity_operation_capacityConcurrent registry-backed ID remove/cancel operations.execute().await returns RuntimeError::ResourceLimitReached.

Queued controller removal happens before the identity-operation limit is checked. Buffered controller commands and work already handed to the registry do not count toward max_total_pending. Replacing an existing queue head does not need another pending unit. See the API reference for defaults and configuration for runtime and ownership limits.

Runnable controller examples:

Source: submission intake, slot placement, queue policies, slot lifecycle, and completion signals.

Open-source task execution components.