Skip to main content

sema_core/runtime/
ids.rs

1use std::marker::PhantomData;
2use std::num::NonZeroU64;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5static NEXT_RUNTIME_ID: AtomicU64 = AtomicU64::new(1);
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
8#[error("runtime identity space is exhausted")]
9pub struct IdExhausted;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
12#[error("runtime identity must be nonzero")]
13pub struct InvalidRuntimeId;
14
15macro_rules! scalar_id {
16    ($name:ident) => {
17        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18        pub struct $name(NonZeroU64);
19
20        impl $name {
21            pub fn get(self) -> u64 {
22                self.0.get()
23            }
24        }
25
26        impl private::Sealed for $name {
27            fn from_nonzero(value: NonZeroU64) -> Self {
28                Self(value)
29            }
30        }
31    };
32}
33
34scalar_id!(TaskId);
35scalar_id!(ScopeId);
36scalar_id!(WaitId);
37scalar_id!(WaitGeneration);
38scalar_id!(OperationId);
39scalar_id!(SettlementSeq);
40scalar_id!(CompletionKind);
41
42impl TaskId {
43    pub fn try_from_raw(raw: u64) -> Result<Self, InvalidRuntimeId> {
44        NonZeroU64::new(raw).map(Self).ok_or(InvalidRuntimeId)
45    }
46}
47
48impl CompletionKind {
49    pub fn try_from_raw(raw: u64) -> Result<Self, InvalidRuntimeId> {
50        NonZeroU64::new(raw).map(Self).ok_or(InvalidRuntimeId)
51    }
52}
53
54#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
55pub struct RuntimeId(NonZeroU64);
56
57impl RuntimeId {
58    pub(crate) fn allocate() -> Result<Self, IdExhausted> {
59        allocate_atomic(&NEXT_RUNTIME_ID).map(Self)
60    }
61
62    pub fn get(self) -> u64 {
63        self.0.get()
64    }
65}
66
67/// A task's complete identity across every runtime in the process.
68///
69/// [`TaskId`] values are local to one runtime and may collide. Native registries
70/// that outlive a task quantum use this composite identity for ownership and
71/// cancellation cleanup.
72#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct RuntimeTaskId {
74    runtime: RuntimeId,
75    task: TaskId,
76}
77
78impl RuntimeTaskId {
79    pub fn new(runtime: RuntimeId, task: TaskId) -> Self {
80        Self { runtime, task }
81    }
82
83    pub fn runtime(self) -> RuntimeId {
84        self.runtime
85    }
86
87    pub fn task(self) -> TaskId {
88        self.task
89    }
90}
91
92fn allocate_atomic(counter: &AtomicU64) -> Result<NonZeroU64, IdExhausted> {
93    let raw = counter
94        .fetch_update(
95            Ordering::Relaxed,
96            Ordering::Relaxed,
97            |current| match current {
98                0 => None,
99                u64::MAX => Some(0),
100                value => Some(value + 1),
101            },
102        )
103        .map_err(|_| IdExhausted)?;
104    NonZeroU64::new(raw).ok_or(IdExhausted)
105}
106
107mod private {
108    use super::RuntimeId;
109    use std::num::NonZeroU64;
110
111    pub trait Sealed: Sized {
112        fn from_nonzero(value: NonZeroU64) -> Self;
113    }
114
115    pub trait ScopedSealed: Sized {
116        fn from_parts(runtime: RuntimeId, local: NonZeroU64) -> Self;
117    }
118}
119
120#[doc(hidden)]
121pub trait RuntimeIdType: private::Sealed {}
122
123impl<T: private::Sealed> RuntimeIdType for T {}
124
125#[derive(Clone, Debug)]
126pub struct IdCounter<I> {
127    next: Option<NonZeroU64>,
128    marker: PhantomData<fn() -> I>,
129}
130
131impl<I: RuntimeIdType> Default for IdCounter<I> {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl<I: RuntimeIdType> IdCounter<I> {
138    pub fn new() -> Self {
139        Self {
140            next: NonZeroU64::new(1),
141            marker: PhantomData,
142        }
143    }
144
145    pub fn allocate(&mut self) -> Result<I, IdExhausted> {
146        let current = self.next.ok_or(IdExhausted)?;
147        self.next = current.get().checked_add(1).and_then(NonZeroU64::new);
148        Ok(I::from_nonzero(current))
149    }
150
151    pub fn is_exhausted(&self) -> bool {
152        self.next.is_none()
153    }
154
155    #[cfg(test)]
156    fn starting_at(next: u64) -> Self {
157        Self {
158            next: NonZeroU64::new(next),
159            marker: PhantomData,
160        }
161    }
162}
163
164macro_rules! scoped_id {
165    ($name:ident) => {
166        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
167        pub struct $name {
168            runtime: RuntimeId,
169            local: NonZeroU64,
170        }
171
172        impl $name {
173            pub fn runtime(self) -> RuntimeId {
174                self.runtime
175            }
176
177            pub fn local(self) -> u64 {
178                self.local.get()
179            }
180
181            pub fn get(self) -> u64 {
182                self.local.get()
183            }
184        }
185    };
186}
187
188scoped_id!(RootId);
189scoped_id!(PromiseId);
190scoped_id!(ChannelId);
191scoped_id!(ResourceGateId);
192
193#[doc(hidden)]
194pub trait RuntimeScopedIdType: private::ScopedSealed {}
195
196impl<T: private::ScopedSealed> RuntimeScopedIdType for T {}
197
198macro_rules! scoped_id_type {
199    ($name:ident) => {
200        impl private::ScopedSealed for $name {
201            fn from_parts(runtime: RuntimeId, local: NonZeroU64) -> Self {
202                Self { runtime, local }
203            }
204        }
205    };
206}
207
208scoped_id_type!(RootId);
209scoped_id_type!(PromiseId);
210scoped_id_type!(ChannelId);
211scoped_id_type!(ResourceGateId);
212
213#[derive(Debug)]
214pub struct RuntimeScopedIdCounter<I> {
215    runtime: RuntimeId,
216    local: IdCounter<NonZeroU64>,
217    marker: PhantomData<fn() -> I>,
218}
219
220impl private::Sealed for NonZeroU64 {
221    fn from_nonzero(value: NonZeroU64) -> Self {
222        value
223    }
224}
225
226impl<I: RuntimeScopedIdType> RuntimeScopedIdCounter<I> {
227    /// Mint a scoped-id counter for `runtime`. Runtime-internal registries
228    /// (channels, promises, resource gates) that live inside one runtime's
229    /// state cell construct their own counter from the runtime identity the
230    /// completion registrar issued, so a scoped id always carries its owning
231    /// runtime for cross-runtime misuse detection.
232    pub fn new(runtime: RuntimeId) -> Self {
233        Self {
234            runtime,
235            local: IdCounter::new(),
236            marker: PhantomData,
237        }
238    }
239
240    pub fn allocate(&mut self) -> Result<I, IdExhausted> {
241        self.local
242            .allocate()
243            .map(|local| <I as private::ScopedSealed>::from_parts(self.runtime, local))
244    }
245
246    pub fn is_exhausted(&self) -> bool {
247        self.local.is_exhausted()
248    }
249}
250
251/// The complete, single-owner set of scoped ID allocators for one runtime.
252///
253/// This value is issued together with the completion registrar and is neither
254/// cloneable nor constructible by runtime consumers.
255#[doc(hidden)]
256pub struct RuntimeScopedIdIssuers {
257    root: RuntimeScopedIdCounter<RootId>,
258    promise: RuntimeScopedIdCounter<PromiseId>,
259    channel: RuntimeScopedIdCounter<ChannelId>,
260}
261
262impl RuntimeScopedIdIssuers {
263    pub(crate) fn new(runtime: RuntimeId) -> Self {
264        Self {
265            root: RuntimeScopedIdCounter::new(runtime),
266            promise: RuntimeScopedIdCounter::new(runtime),
267            channel: RuntimeScopedIdCounter::new(runtime),
268        }
269    }
270
271    #[doc(hidden)]
272    pub fn into_parts(
273        self,
274    ) -> (
275        RuntimeScopedIdCounter<RootId>,
276        RuntimeScopedIdCounter<PromiseId>,
277        RuntimeScopedIdCounter<ChannelId>,
278    ) {
279        (self.root, self.promise, self.channel)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::{SemaError, Value};
287    use std::collections::BTreeMap;
288
289    #[test]
290    fn counter_starts_at_one() {
291        let mut counter = IdCounter::<TaskId>::new();
292        assert_eq!(counter.allocate().expect("ID available").get(), 1);
293    }
294
295    #[test]
296    fn counter_issues_max_once_then_stays_exhausted() {
297        let mut counter = IdCounter::<TaskId>::starting_at(u64::MAX);
298        assert_eq!(
299            counter.allocate().expect("last ID available").get(),
300            u64::MAX
301        );
302        assert_eq!(counter.allocate(), Err(IdExhausted));
303        assert_eq!(counter.allocate(), Err(IdExhausted));
304    }
305
306    #[test]
307    fn condition_ids_are_emitted_as_integers() {
308        // Numeric condition fields are integers per the plan's language-facing
309        // contract (callers do arithmetic on :duration-ms / :operation-id).
310        // Ids are monotonic counters and durations are bounded, so every real
311        // value fits an i64; a large-but-representable id round-trips losslessly.
312        let big: u64 = 1_000_000_000_000;
313        let operation_id = IdCounter::<OperationId>::starting_at(big)
314            .allocate()
315            .expect("large operation ID");
316        let condition =
317            SemaError::timeout_condition("timed out", "runtime/wait", big, Some(operation_id));
318        let expected = BTreeMap::from([
319            (Value::keyword("type"), Value::keyword("timeout")),
320            (Value::keyword("message"), Value::string("timed out")),
321            (Value::keyword("operation"), Value::string("runtime/wait")),
322            (Value::keyword("duration-ms"), Value::int(big as i64)),
323            (Value::keyword("operation-id"), Value::int(big as i64)),
324        ]);
325
326        assert!(matches!(condition, SemaError::Condition(value) if value == Value::map(expected)));
327    }
328
329    #[test]
330    fn atomic_allocator_issues_max_once_then_stays_exhausted() {
331        let counter = AtomicU64::new(u64::MAX);
332        assert_eq!(
333            allocate_atomic(&counter).expect("last ID available").get(),
334            u64::MAX
335        );
336        assert_eq!(allocate_atomic(&counter), Err(IdExhausted));
337        assert_eq!(allocate_atomic(&counter), Err(IdExhausted));
338    }
339
340    #[test]
341    fn runtime_ids_are_process_global_and_unique() {
342        let first = RuntimeId::allocate().expect("runtime ID available");
343        let second = RuntimeId::allocate().expect("runtime ID available");
344        assert!(first < second);
345    }
346
347    #[test]
348    fn scoped_ids_include_runtime_and_local_identity() {
349        let runtime = RuntimeId::allocate().expect("runtime ID available");
350        let mut counter = RuntimeScopedIdCounter::<PromiseId>::new(runtime);
351        let id = counter.allocate().expect("promise ID available");
352        assert_eq!(id.runtime(), runtime);
353        assert_eq!(id.local(), 1);
354    }
355
356    #[test]
357    fn scoped_ids_with_equal_locals_are_distinct_across_runtimes() {
358        let first_runtime = RuntimeId::allocate().expect("runtime ID available");
359        let second_runtime = RuntimeId::allocate().expect("runtime ID available");
360
361        macro_rules! assert_scoped_identity {
362            ($id_type:ty) => {{
363                let mut first = RuntimeScopedIdCounter::<$id_type>::new(first_runtime);
364                let mut second = RuntimeScopedIdCounter::<$id_type>::new(second_runtime);
365                let first_id = first.allocate().expect("scoped ID available");
366                let second_id = second.allocate().expect("scoped ID available");
367
368                assert_eq!(first_id.local(), 1);
369                assert_eq!(second_id.local(), 1);
370                assert_ne!(first_id, second_id);
371            }};
372        }
373
374        assert_scoped_identity!(RootId);
375        assert_scoped_identity!(PromiseId);
376        assert_scoped_identity!(ChannelId);
377        assert_scoped_identity!(ResourceGateId);
378    }
379
380    #[test]
381    fn runtime_task_ids_with_equal_local_tasks_are_distinct() {
382        let first_runtime = RuntimeId::allocate().expect("runtime ID available");
383        let second_runtime = RuntimeId::allocate().expect("runtime ID available");
384        let local_task = TaskId::try_from_raw(7).expect("task ID is nonzero");
385
386        let first = RuntimeTaskId::new(first_runtime, local_task);
387        let second = RuntimeTaskId::new(second_runtime, local_task);
388
389        assert_eq!(first.runtime(), first_runtime);
390        assert_eq!(first.task(), local_task);
391        assert_ne!(first, second);
392    }
393
394    #[test]
395    fn every_identity_has_the_required_value_traits() {
396        fn assert_traits<T: Copy + Clone + std::fmt::Debug + Eq + Ord + std::hash::Hash>() {}
397
398        assert_traits::<RuntimeId>();
399        assert_traits::<RuntimeTaskId>();
400        assert_traits::<RootId>();
401        assert_traits::<TaskId>();
402        assert_traits::<ScopeId>();
403        assert_traits::<PromiseId>();
404        assert_traits::<ChannelId>();
405        assert_traits::<ResourceGateId>();
406        assert_traits::<WaitId>();
407        assert_traits::<WaitGeneration>();
408        assert_traits::<OperationId>();
409        assert_traits::<SettlementSeq>();
410        assert_traits::<CompletionKind>();
411    }
412}