Skip to main content

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}