moirai_executor/registry/registry.rs
1use std::{
2 ptr::NonNull,
3 sync::{
4 Arc, RwLock,
5 atomic::{AtomicU64, Ordering},
6 },
7};
8
9use super::super::task::TaskMetadata;
10use super::directory::{BlockDirectory, BlockLookup};
11use super::retention::RetentionPolicy;
12use super::state::{TaskState, TaskStateBlock, task_location};
13use super::token::{SchedulerStateLease, TaskLifecycleToken};
14
15/// Outcome of a cooperative cancel request against a registered task.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) enum CancelOutcome {
18 /// The cancel flag was set; the task body is skipped if it has not started.
19 Requested,
20 /// The task already completed; cancelling is a no-op.
21 AlreadyCompleted,
22}
23
24/// What the registry knows about a task id.
25enum Observation<R> {
26 /// The id's block was retired: the task completed and its state was released.
27 Retired,
28 /// No task is registered under the id.
29 Unregistered,
30 /// The task's state, as seen by the observer.
31 Registered(R),
32}
33
34/// A task id issued by the registry and not yet registered.
35///
36/// Registration consumes it and nothing else constructs one, so a slot has at
37/// most one registrant and the block can write it without a claim.
38#[derive(Debug)]
39pub(super) struct IssuedId(u64);
40
41impl IssuedId {
42 pub(super) const fn get(&self) -> u64 {
43 self.0
44 }
45
46 /// Block index and slot index of the id.
47 pub(super) fn location(&self) -> (usize, usize) {
48 task_location(self.0)
49 }
50}
51
52/// Public task registry facade used by executor lifecycle tracking and tests.
53///
54/// Registration and lookup take `&self` so the executor can share one registry
55/// without an outer mutex. Every spawn used to serialize on that mutex ahead of
56/// the lock-free scheduler: measured on an 8-core pin, executor spawn ran
57/// 3.18 M/s with one producer and *fell* to 2.97 M/s with eight, while the same
58/// scheduler reached without the registry rose from 6.18 M/s to 8.85 M/s.
59///
60/// The id counter is atomic, and the block directory takes its lock in read
61/// mode for the common path — a block is created once per 1024 ids, and slot
62/// insertion itself only needs `&TaskStateBlock`.
63///
64/// Storage is bounded by a [`RetentionPolicy`] when one is set: settled blocks
65/// are released, and [`TaskRegistry::is_completed`] keeps answering `true` for
66/// their tasks. Without a policy every block stays resident until
67/// [`TaskRegistry::cleanup_completed`] releases it.
68#[derive(Debug)]
69pub struct TaskRegistry {
70 pub(super) blocks: RwLock<BlockDirectory>,
71 pub(super) next_id: AtomicU64,
72 pub(super) retention: Option<RetentionPolicy>,
73}
74
75impl TaskRegistry {
76 /// Create a registry that retains every task until it is cleaned up
77 /// explicitly.
78 #[must_use]
79 pub const fn new() -> Self {
80 Self {
81 blocks: RwLock::new(BlockDirectory::new()),
82 next_id: AtomicU64::new(1),
83 retention: None,
84 }
85 }
86
87 /// Create a registry whose completed tasks are released under `policy`.
88 #[must_use]
89 pub fn with_retention(policy: RetentionPolicy) -> Self {
90 Self {
91 retention: Some(policy),
92 ..Self::new()
93 }
94 }
95
96 /// Issue the next task id.
97 ///
98 /// The counter is the only source of ids, so every id is issued once; the
99 /// returned [`IssuedId`] is consumed by registration, which is what lets a
100 /// slot be written without a claim.
101 pub(super) fn issue_id(&self) -> IssuedId {
102 IssuedId(self.next_id.fetch_add(1, Ordering::Relaxed))
103 }
104
105 /// Register a new task and return its ID.
106 ///
107 /// The task stays queued until the caller drives it by id:
108 /// [`TaskRegistry::mark_started`], then [`TaskRegistry::mark_completed`].
109 /// Until it completes, its block is never retired, so both calls reach the
110 /// task. Executor code that owns the task's lifecycle uses a token instead.
111 pub fn register_task(&self) -> u64 {
112 let id = self.issue_id();
113 let task_id = id.get();
114 self.register_owned(id).release();
115 task_id
116 }
117
118 /// Register a new task and return its ID plus lifecycle mutation token.
119 #[cfg(any(test, feature = "registry-diagnostics"))]
120 pub(crate) fn register_next_task(&self) -> (u64, TaskLifecycleToken) {
121 let id = self.issue_id();
122 let task_id = id.get();
123 (task_id, self.register_owned(id))
124 }
125
126 /// Register a task whose lifecycle cannot outlive this registry.
127 ///
128 /// # Safety
129 ///
130 /// The caller must keep this registry's blocks alive until the returned
131 /// lifecycle token is consumed or dropped. Block retirement remains safe
132 /// while the token is live because registration marks the slot active and
133 /// only a block with no active slot retires.
134 pub(crate) unsafe fn register_next_scheduled_task(
135 &self,
136 ) -> (u64, TaskLifecycleToken<SchedulerStateLease>) {
137 let id = self.issue_id();
138 let task_id = id.get();
139 // The scheduled token borrows the slot rather than owning the block, so
140 // this path never needs the `Arc`; keeping the insert under the shared
141 // guard avoids a refcount bump on every spawn.
142 let state = self.insert_slot(id);
143 (
144 task_id,
145 // SAFETY: forwarded from this method's caller contract.
146 unsafe { TaskLifecycleToken::new_scheduled(state) },
147 )
148 }
149
150 pub(super) fn register_owned(&self, id: IssuedId) -> TaskLifecycleToken {
151 let (block_index, slot_index) = id.location();
152 let block = self.ensure_block(block_index);
153 // SAFETY: `id` was issued once by the counter and registration consumes it.
154 let state = unsafe { block.insert(slot_index) };
155 TaskLifecycleToken::new_owned(block, state)
156 }
157
158 /// Register a slot and return only its state pointer.
159 ///
160 /// The owned-token path needs the block `Arc`; the scheduled path does not,
161 /// and it is the one every spawn takes. Resolving the block under the
162 /// shared guard and inserting there keeps that path free of a refcount
163 /// bump. Falls back to the growing path when the block does not exist yet,
164 /// which happens once per 1024 ids.
165 fn insert_slot(&self, id: IssuedId) -> NonNull<TaskState> {
166 let (block_index, slot_index) = id.location();
167 {
168 let blocks = self
169 .blocks
170 .read()
171 .expect("task registry block directory is never poisoned");
172 if let BlockLookup::Live(block) = blocks.lookup(block_index) {
173 // SAFETY: `id` was issued once by the counter and registration
174 // consumes it.
175 return unsafe { block.insert(slot_index) };
176 }
177 }
178 let block = self.ensure_block(block_index);
179 // SAFETY: as above.
180 unsafe { block.insert(slot_index) }
181 }
182
183 /// Mark a task as started.
184 pub fn mark_started(&self, task_id: u64, worker_id: usize) {
185 self.with_state(task_id, |state| {
186 state.mark_started(worker_id);
187 });
188 }
189
190 /// Mark a task as completed.
191 pub fn mark_completed(&self, task_id: u64) {
192 self.with_state(task_id, TaskState::mark_completed);
193 }
194
195 /// Check if a task is completed.
196 ///
197 /// A task whose block was released under the retention policy is completed.
198 #[must_use]
199 pub fn is_completed(&self, task_id: u64) -> bool {
200 self.completion(task_id) == Some(true)
201 }
202
203 /// Report whether a task completed, from one observation of the registry.
204 ///
205 /// `None` is an id that was never registered. A task whose block was
206 /// released under the retention policy is `Some(true)`. A caller that must
207 /// tell an unknown id from a finished one reads this once: two separate
208 /// lookups can straddle the release of the task's block.
209 #[must_use]
210 pub fn completion(&self, task_id: u64) -> Option<bool> {
211 match self.observe(task_id, TaskState::is_completed) {
212 Observation::Retired => Some(true),
213 Observation::Unregistered => None,
214 Observation::Registered(completed) => Some(completed),
215 }
216 }
217
218 /// Get task metadata, or `None` for an unregistered task and for one whose
219 /// metadata the retention policy already released.
220 #[must_use]
221 pub fn get_metadata(&self, task_id: u64) -> Option<TaskMetadata> {
222 self.with_state(task_id, |state| state.snapshot(task_id))
223 }
224
225 /// Get count of active tasks.
226 #[must_use]
227 pub fn active_count(&self) -> usize {
228 self.blocks
229 .read()
230 .expect("task registry block directory is never poisoned")
231 .resident_blocks()
232 .flat_map(|block| block.states())
233 .filter(|state| !state.is_completed())
234 .count()
235 }
236
237 /// Get count of completed tasks whose state is still resident.
238 #[must_use]
239 pub fn completed_count(&self) -> usize {
240 self.blocks
241 .read()
242 .expect("task registry block directory is never poisoned")
243 .resident_blocks()
244 .flat_map(|block| block.states())
245 .filter(|state| state.is_completed())
246 .count()
247 }
248
249 /// Resolve the block for `block_index`, creating it if absent.
250 ///
251 /// The read path is the common one: a block is created once per 1024 ids,
252 /// so all but that registration take the lock in shared mode and never
253 /// exclude a concurrent spawn. The length is re-checked under the write
254 /// lock because another producer may have grown the directory between the
255 /// two acquisitions. Creating a block advances the retention sweep by one
256 /// window, after the directory lock is released.
257 ///
258 /// Every caller holds an issued id whose slot is not yet registered, so the
259 /// block cannot have retired.
260 pub(super) fn ensure_block(&self, block_index: usize) -> Arc<TaskStateBlock> {
261 if let BlockLookup::Live(block) = self
262 .blocks
263 .read()
264 .expect("task registry block directory is never poisoned")
265 .lookup(block_index)
266 {
267 return Arc::clone(block);
268 }
269 let ensured = self
270 .blocks
271 .write()
272 .expect("task registry block directory is never poisoned")
273 .ensure(block_index);
274 let (block, created) =
275 ensured.expect("invariant: a block holding an issued, unregistered id never retires");
276 if created {
277 self.sweep_step();
278 }
279 block
280 }
281
282 /// Run `f` against the state of `task_id`, if it is resident.
283 pub(super) fn with_state<R>(&self, task_id: u64, f: impl FnOnce(&TaskState) -> R) -> Option<R> {
284 match self.observe(task_id, f) {
285 Observation::Registered(value) => Some(value),
286 Observation::Retired | Observation::Unregistered => None,
287 }
288 }
289
290 /// Run `f` against the state slot for `task_id` and report how the id stands.
291 ///
292 /// Callers take the block by `Arc` rather than borrowing through the
293 /// directory guard, so the shared lock is released before `f` runs.
294 fn observe<R>(&self, task_id: u64, f: impl FnOnce(&TaskState) -> R) -> Observation<R> {
295 let (block_index, slot_index) = task_location(task_id);
296 let block = {
297 let blocks = self
298 .blocks
299 .read()
300 .expect("task registry block directory is never poisoned");
301 match blocks.lookup(block_index) {
302 BlockLookup::Live(block) => Arc::clone(block),
303 BlockLookup::Retired => return Observation::Retired,
304 BlockLookup::Absent => return Observation::Unregistered,
305 }
306 };
307 block
308 .get(slot_index)
309 .map_or(Observation::Unregistered, |state| {
310 Observation::Registered(f(state))
311 })
312 }
313
314 /// Request cooperative cancellation of a task.
315 ///
316 /// Returns `None` when the task is unknown. Running tasks are not
317 /// preempted: a task that already started keeps running to completion and
318 /// reports `Requested` here without effect. A task whose state the
319 /// retention policy released completed earlier and reports
320 /// `AlreadyCompleted`.
321 pub(crate) fn request_cancel(&self, task_id: u64) -> Option<CancelOutcome> {
322 match self.observe(task_id, |state| {
323 if state.is_completed() {
324 CancelOutcome::AlreadyCompleted
325 } else {
326 state.request_cancel();
327 CancelOutcome::Requested
328 }
329 }) {
330 Observation::Retired => Some(CancelOutcome::AlreadyCompleted),
331 Observation::Unregistered => None,
332 Observation::Registered(outcome) => Some(outcome),
333 }
334 }
335
336 /// Register a waker to be notified when the task completes.
337 ///
338 /// Every distinct waker registered before completion is woken by it; a
339 /// waker that `will_wake` one already registered is normally not added
340 /// again (`Waker::will_wake` is best-effort). A task that already completed
341 /// wakes the waker at once and keeps nothing. Returns `false` for an id the registry does not hold.
342 pub fn register_waker(&self, task_id: u64, waker: &std::task::Waker) -> bool {
343 self.with_state(task_id, |state| state.register_waker(waker))
344 .is_some()
345 }
346}
347
348impl Default for TaskRegistry {
349 fn default() -> Self {
350 Self::new()
351 }
352}