taskvisor/controller/mod.rs
1//! # Slot-based admission control
2//!
3//! The controller is an optional layer before the supervisor registry.
4//! It decides when a submitted task may be registered.
5//!
6//! Use direct `add*` methods when you do not need this admission step.
7//! This module requires the `controller` feature.
8//!
9//! Use the controller when work with the same key must not overlap:
10//! - one deployment per environment;
11//! - one job per customer or resource;
12//! - one rebuild per index;
13//! - skip or replace duplicate work while a lane is busy.
14//!
15//! ## Slots and task names
16//!
17//! A **slot** is one sequential admission lane. The controller admits by slot, not by task name.
18//!
19//! A slot is the key returned by [`ControllerSpec::slot_name`].
20//! If no slot is set, it defaults to the task name.
21//! Use [`ControllerSpec::with_slot`] to group several different task names into one slot.
22//!
23//! Task names belong to the runtime registry. They must be unique across all
24//! currently registered tasks, even when those tasks use different slots.
25//!
26//! ```text
27//! task "deploy-main-42" ┐
28//! task "deploy-main-43" ├── slot "deploy-main"
29//! task "deploy-main-44" ┘
30//! ```
31//!
32//! These are different runtime tasks, but the controller admits them one at a
33//! time through the same slot. Different slots may be occupied at the same
34//! time. The supervisor's global concurrency limit still applies to attempts.
35//!
36//! ## Admission policies
37//!
38//! When a slot is idle, every policy tries to start admission.
39//! When it is busy, the policy decides what happens to the new submission.
40//!
41//! | Policy | Busy slot behavior |
42//! |------------------------------------|------------------------------------------------------------|
43//! | [`AdmissionPolicy::Queue`] | enqueue if the per-slot limit allows it; otherwise reject |
44//! | [`AdmissionPolicy::Replace`] | create or replace the queue head; retire owner if needed |
45//! | [`AdmissionPolicy::DropIfRunning`] | reject the submission |
46//!
47//! `Replace` changes only the next-item position: it creates or replaces the queue head.
48//! A displaced head is rejected; FIFO items behind it stay queued.
49//! A watched rejected submission resolves to [`TaskOutcome::Rejected`](crate::TaskOutcome::Rejected).
50//!
51//! ## Slot states
52//!
53//! In this table, `Next / Idle` means: start admission for the next queued item, or become `Idle` when no queued item can start.
54//!
55//! | Current state | Cause | Next state |
56//! |---------------|----------------------------------------------------------------|------------------------------------------------------|
57//! | `Idle` | a submission starts admission | `Admitting` |
58//! | `Admitting` | registry accepts registration | `Running` |
59//! | `Admitting` | registry rejects registration | Next / Idle |
60//! | `Admitting` | `Replace` arrives | `Terminating` |
61//! | `Running` | `Replace` arrives | `Terminating` |
62//! | `Running` | terminal registry cleanup completes | Next / Idle |
63//! | `Terminating` | pending registration is accepted | stay `Terminating`; request owner removal |
64//! | `Terminating` | pending registration is rejected or terminal cleanup completes | Next / Idle |
65//! | `Terminating` | another `Replace` arrives | stay `Terminating`; create or replace the queue head |
66//!
67//! Admitting + Replace is shown as `Terminating` in public snapshots.
68//! Taskvisor first waits for the registration decision.
69//! If registration succeeds, it then removes that owner before starting the replacement.
70//!
71//! Only `Replace` changes the public status to `Terminating`.
72//! ID-based `remove*` and `cancel*` requests do not change the status by themselves.
73//! The slot advances when the registry reports terminal cleanup.
74//! `Queue` and `DropIfRunning` also leave the current owner's status unchanged.
75//!
76//! After a registered owner, the next queued task starts only after terminal registry cleanup.
77//! At that point, the old managed runner has been joined and its task name is free.
78//! If registration is rejected, no registered owner exists; the controller can try the next queued item as soon as it receives that direct decision.
79//! Events are only for observability; they do not drive slot state.
80//!
81//! ## Public API
82//!
83//! - Build submissions with [`ControllerSpec::queue`], [`ControllerSpec::replace`], or [`ControllerSpec::drop_if_running`].
84//! - Configure with [`SupervisorBuilder::with_controller`](crate::SupervisorBuilder::with_controller).
85//! - Submit with [`SupervisorHandle::submit`](crate::SupervisorHandle::submit) or [`SupervisorHandle::submit_and_watch`](crate::SupervisorHandle::submit_and_watch).
86//! Their `try_*` forms return immediately instead of waiting when the command channel is full.
87//! - When application correlation must exist before lifecycle events can start,
88//! call [`SupervisorHandle::prepare_submission`](crate::SupervisorHandle::prepare_submission),
89//! store its [`TaskId`](crate::TaskId), then consume the returned [`PreparedSubmission`].
90//! - Remove or cancel by the [`TaskId`](crate::TaskId) returned from submission.
91//! - Read current slot state with [`SupervisorHandle::controller_snapshot`](crate::SupervisorHandle::controller_snapshot).
92//!
93//! Slots control admission only. There is no slot-wide cancel/remove operation.
94//! Canceling a registered owner by ID or name does not automatically purge a
95//! queued replacement for the same slot.
96//!
97//! ## Example
98//!
99//! ```rust,no_run
100//! use taskvisor::{
101//! ControllerConfig, ControllerSpec, Supervisor, SupervisorConfig, TaskFn, TaskSpec,
102//! };
103//!
104//! # #[tokio::main]
105//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
106//! let supervisor = Supervisor::builder(SupervisorConfig::default())
107//! .with_controller(ControllerConfig::default())
108//! .build();
109//! let handle = supervisor.serve();
110//!
111//! let task = TaskFn::arc("refresh-customer-42", |_ctx| async { Ok(()) });
112//! let request = ControllerSpec::queue(TaskSpec::once(task)).with_slot("customer-42");
113//! let (_id, waiter) = handle.submit_and_watch(request).await?;
114//!
115//! assert!(waiter.wait().await?.is_success());
116//! handle.shutdown().await?;
117//! # Ok(())
118//! # }
119//! ```
120//!
121//! A successful controller submission call confirms only that the controller command queue accepted the submission.
122//! Slot admission and registry registration happen later.
123//! Use `submit_and_watch` or `try_submit_and_watch` when the final result matters.
124//!
125//! The returned `TaskId` is allocated before runtime admission.
126//! If admission succeeds, the runtime uses the same ID.
127//! Before admission, it still identifies queued work for cancellation, outcomes, and event correlation.
128//! A [`PreparedSubmission`] exposes that ID before controller intake. Preparing
129//! alone does not enqueue work or publish an event.
130//!
131//! [`ControllerConfig::queue_capacity`] bounds the controller command queue and separately caps registry-backed remove/cancel operations.
132//! A new `Queue` submission is rejected when the slot's pending depth is already [`ControllerConfig::max_slot_queue`] or greater.
133//! The current owner is not part of this depth, but a replacement head is.
134//! `Replace` itself may create or replace that head even when the limit is zero.
135//!
136//! When the controller or registry rejects a watched submission before registration, its waiter resolves to [`TaskOutcome::Rejected`](crate::TaskOutcome::Rejected).
137//! After registration, the waiter resolves to the runtime task's terminal outcome.
138//!
139//! ## Events
140//!
141//! Controller-specific event kinds are:
142//! - [`EventKind::ControllerSlotTransition`](crate::EventKind::ControllerSlotTransition)
143//! - [`EventKind::ControllerSubmitted`](crate::EventKind::ControllerSubmitted)
144//! - [`EventKind::ControllerRejected`](crate::EventKind::ControllerRejected)
145//!
146//! Removing a submission that is still in a slot queue also publishes the standard [`EventKind::TaskRemoveRequested`](crate::EventKind::TaskRemoveRequested).
147//! A registry rejection, such as a duplicate task name, publishes the standard [`EventKind::TaskAddFailed`](crate::EventKind::TaskAddFailed).
148//!
149//! Events are best-effort observability. For one reliable final result, use `submit_and_watch` or `try_submit_and_watch`.
150//!
151//! ## Invariants
152//!
153//! - At most one registered runtime task may own a slot.
154//! - Queued submissions are not handed to the runtime until they become the slot owner.
155//! - `Queue` preserves FIFO order among submissions that remain pending.
156//! - `Replace` creates or overwrites only the queue head; later items stay in order.
157//! - The next owner starts only after reliable registry cleanup of the old owner.
158//! - Runtime shutdown closes controller intake, resolves pending work, and joins the controller loop before the shared shutdown result is returned.
159//!
160//! A snapshot is a rolling observability view, not a transaction.
161//! See [`ControllerSnapshot`] for its consistency limits.
162
163mod view;
164pub use view::{ControllerSnapshot, SlotStatusKind, SlotView};
165
166mod admission;
167pub use admission::AdmissionPolicy;
168
169mod config;
170pub use config::ControllerConfig;
171
172mod core;
173pub(crate) use core::Controller;
174
175mod error;
176pub use error::ControllerError;
177
178mod prepared;
179pub use prepared::PreparedSubmission;
180
181mod spec;
182pub use spec::ControllerSpec;
183
184mod slot;