celers-core 0.3.0

Core traits and types for CeleRS distributed task queue
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Result tombstone tracking for forgotten/deleted task results.
//!
//! A *tombstone* is a small marker that records the fact that a task result
//! was explicitly deleted ("forgotten") rather than simply never having
//! existed. This distinction matters for clients that need to tell the
//! difference between:
//!
//! * a result that is still being computed / was never produced (absent), and
//! * a result that *did* exist but has since been purged (tombstoned).
//!
//! The [`crate::result::ResultTombstone`] type (defined in [`crate::result`])
//! carries the marker payload (`task_id`, `deleted_at`, `reason`,
//! `deleted_by`, optional `tombstone_ttl`). This module adds the *semantics*
//! around it: a tri-state existence enum, an in-memory registry, and a set of
//! helper methods, all of which are purely additive new types.
//!
//! # Example
//!
//! ```rust
//! use celers_core::result::ResultTombstone;
//! use celers_core::result_tombstone::{ResultExistence, TombstoneRegistry};
//! use uuid::Uuid;
//!
//! let registry = TombstoneRegistry::new();
//! let task_id = Uuid::new_v4();
//!
//! // Nothing recorded yet -> absent (never existed, as far as we know).
//! assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
//! assert!(!registry.is_tombstoned(task_id));
//!
//! // Mark the result as deleted.
//! registry.insert(ResultTombstone::new(task_id).with_reason("manual forget"));
//!
//! // Now it is distinguishable from "never existed".
//! assert!(registry.is_tombstoned(task_id));
//! match registry.lookup(task_id) {
//!     ResultExistence::Tombstoned(t) => assert_eq!(t.task_id, task_id),
//!     other => panic!("expected tombstone, got {other:?}"),
//! }
//! ```

use crate::result::ResultTombstone;
use crate::TaskId;
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;

/// Tri-state existence of a task result, distinguishing a *forgotten* result
/// (one that has a tombstone marker) from one that simply never existed.
///
/// This is the central abstraction that makes "deleted" distinguishable from
/// "never existed". Backends that support tombstones can map their stored
/// state onto this enum.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResultExistence {
    /// A live result value is present in the backend.
    Present,

    /// The result was explicitly deleted/forgotten and a tombstone marker
    /// records that fact. Carries the recorded tombstone.
    Tombstoned(ResultTombstone),

    /// No result and no tombstone — as far as the backend knows the result
    /// never existed (or its tombstone has itself expired).
    Absent,
}

impl ResultExistence {
    /// Returns `true` if a live result value is present.
    #[inline]
    #[must_use]
    pub const fn is_present(&self) -> bool {
        matches!(self, ResultExistence::Present)
    }

    /// Returns `true` if the result has been forgotten (a tombstone exists).
    #[inline]
    #[must_use]
    pub const fn is_tombstoned(&self) -> bool {
        matches!(self, ResultExistence::Tombstoned(_))
    }

    /// Returns `true` if the result is absent (never existed / fully purged).
    #[inline]
    #[must_use]
    pub const fn is_absent(&self) -> bool {
        matches!(self, ResultExistence::Absent)
    }

    /// Returns `true` if the result has been explicitly deleted, i.e. it is
    /// *not* simply absent. This is the key predicate that distinguishes a
    /// deleted result from one that never existed.
    #[inline]
    #[must_use]
    pub const fn was_deleted(&self) -> bool {
        matches!(self, ResultExistence::Tombstoned(_))
    }

    /// Borrow the tombstone marker if this existence is a tombstone.
    #[inline]
    #[must_use]
    pub const fn tombstone(&self) -> Option<&ResultTombstone> {
        match self {
            ResultExistence::Tombstoned(t) => Some(t),
            _ => None,
        }
    }

    /// Consume the existence, returning the tombstone marker if present.
    #[inline]
    #[must_use]
    pub fn into_tombstone(self) -> Option<ResultTombstone> {
        match self {
            ResultExistence::Tombstoned(t) => Some(t),
            _ => None,
        }
    }
}

/// Extension helpers for [`ResultTombstone`] that depend on the wall clock.
///
/// These are provided as a separate extension trait (rather than inherent
/// methods on `ResultTombstone` in `result.rs`) to keep the change additive
/// and self-contained in this module.
pub trait TombstoneExt {
    /// Returns the age of the tombstone (time elapsed since `deleted_at`).
    ///
    /// Returns [`Duration::ZERO`] if `deleted_at` is in the future (clock
    /// skew) so the result is always well defined.
    fn age(&self) -> Duration;

    /// Returns `true` if the tombstone itself has expired according to its
    /// `tombstone_ttl`. A tombstone with no TTL never expires.
    fn is_expired(&self) -> bool;

    /// Returns the remaining lifetime of the tombstone, if a TTL is set and it
    /// has not yet expired.
    fn time_until_expiration(&self) -> Option<Duration>;
}

impl TombstoneExt for ResultTombstone {
    fn age(&self) -> Duration {
        let now = chrono::Utc::now();
        let diff = now - self.deleted_at;
        diff.to_std().unwrap_or(Duration::ZERO)
    }

    fn is_expired(&self) -> bool {
        match self.tombstone_ttl {
            Some(ttl) => self.age() >= ttl,
            None => false,
        }
    }

    fn time_until_expiration(&self) -> Option<Duration> {
        self.tombstone_ttl.and_then(|ttl| {
            let age = self.age();
            if age >= ttl {
                None
            } else {
                Some(ttl - age)
            }
        })
    }
}

/// A thread-safe in-memory registry of result tombstones.
///
/// This provides a backend-agnostic, fully testable place to record and query
/// tombstones. Distributed backends (Redis, SQL, …) can keep their own
/// persistent stores; this registry is useful for single-process setups, for
/// caching, and for tests.
///
/// Expired tombstones (those whose `tombstone_ttl` has elapsed) are treated as
/// [`ResultExistence::Absent`] on lookup and can be physically removed via
/// [`TombstoneRegistry::purge_expired`].
#[derive(Debug, Default)]
pub struct TombstoneRegistry {
    inner: RwLock<HashMap<TaskId, ResultTombstone>>,
}

impl TombstoneRegistry {
    /// Create a new, empty tombstone registry.
    #[must_use]
    pub fn new() -> Self {
        Self {
            inner: RwLock::new(HashMap::new()),
        }
    }

    /// Record a tombstone, overwriting any previous marker for the same task.
    ///
    /// Returns the previously stored tombstone for the task, if any.
    pub fn insert(&self, tombstone: ResultTombstone) -> Option<ResultTombstone> {
        let task_id = tombstone.task_id;
        let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
        guard.insert(task_id, tombstone)
    }

    /// Convenience: record that `task_id` was forgotten, with an optional
    /// reason. Returns the previously stored tombstone, if any.
    pub fn mark_forgotten(
        &self,
        task_id: TaskId,
        reason: Option<String>,
    ) -> Option<ResultTombstone> {
        let mut tombstone = ResultTombstone::new(task_id);
        if let Some(reason) = reason {
            tombstone = tombstone.with_reason(reason);
        }
        self.insert(tombstone)
    }

    /// Look up the existence of a task result.
    ///
    /// * Returns [`ResultExistence::Tombstoned`] if a non-expired tombstone is
    ///   recorded.
    /// * Returns [`ResultExistence::Absent`] if no tombstone is recorded, or if
    ///   the recorded tombstone has itself expired.
    ///
    /// Note: this registry only tracks tombstones, so it never returns
    /// [`ResultExistence::Present`]; combine with a live result lookup to get
    /// the full tri-state.
    #[must_use]
    pub fn lookup(&self, task_id: TaskId) -> ResultExistence {
        let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
        match guard.get(&task_id) {
            Some(tombstone) if !tombstone.is_expired() => {
                ResultExistence::Tombstoned(tombstone.clone())
            }
            _ => ResultExistence::Absent,
        }
    }

    /// Returns `true` if a non-expired tombstone is recorded for `task_id`.
    #[must_use]
    pub fn is_tombstoned(&self, task_id: TaskId) -> bool {
        let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
        guard
            .get(&task_id)
            .is_some_and(|tombstone| !tombstone.is_expired())
    }

    /// Fetch a clone of the recorded tombstone for `task_id`, ignoring
    /// expiration (returns the raw marker even if its TTL has elapsed).
    #[must_use]
    pub fn get(&self, task_id: TaskId) -> Option<ResultTombstone> {
        let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
        guard.get(&task_id).cloned()
    }

    /// Remove the tombstone for `task_id`, returning it if present.
    ///
    /// This is used when a previously forgotten task is re-created and its
    /// result genuinely exists again.
    pub fn remove(&self, task_id: TaskId) -> Option<ResultTombstone> {
        let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
        guard.remove(&task_id)
    }

    /// Number of tombstones currently recorded (including expired ones not yet
    /// purged).
    #[must_use]
    pub fn len(&self) -> usize {
        let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
        guard.len()
    }

    /// Returns `true` if no tombstones are recorded.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
        guard.is_empty()
    }

    /// Physically remove all expired tombstones, returning the number removed.
    pub fn purge_expired(&self) -> usize {
        let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
        let before = guard.len();
        guard.retain(|_, tombstone| !tombstone.is_expired());
        before - guard.len()
    }

    /// Remove every tombstone, returning the number removed.
    pub fn clear(&self) -> usize {
        let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
        let count = guard.len();
        guard.clear();
        count
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use uuid::Uuid;

    #[test]
    fn absent_when_never_recorded() {
        let registry = TombstoneRegistry::new();
        let task_id = Uuid::new_v4();
        assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
        assert!(registry.lookup(task_id).is_absent());
        assert!(!registry.is_tombstoned(task_id));
        assert!(!registry.lookup(task_id).was_deleted());
    }

    #[test]
    fn distinguishes_deleted_from_absent() {
        let registry = TombstoneRegistry::new();
        let deleted = Uuid::new_v4();
        let never = Uuid::new_v4();

        registry.insert(ResultTombstone::new(deleted).with_reason("forget"));

        // Deleted: tombstoned, was_deleted() true.
        let existence = registry.lookup(deleted);
        assert!(existence.is_tombstoned());
        assert!(existence.was_deleted());
        assert!(registry.is_tombstoned(deleted));
        assert_eq!(
            existence.tombstone().and_then(|t| t.reason.clone()),
            Some("forget".to_string())
        );

        // Never recorded: absent, was_deleted() false.
        let existence = registry.lookup(never);
        assert!(existence.is_absent());
        assert!(!existence.was_deleted());
        assert!(!registry.is_tombstoned(never));
    }

    #[test]
    fn mark_forgotten_helper() {
        let registry = TombstoneRegistry::new();
        let task_id = Uuid::new_v4();

        let prev = registry.mark_forgotten(task_id, Some("expired ttl".to_string()));
        assert!(prev.is_none());
        assert!(registry.is_tombstoned(task_id));

        let prev = registry.mark_forgotten(task_id, None);
        assert!(prev.is_some());
        assert_eq!(prev.and_then(|t| t.reason), Some("expired ttl".to_string()));
    }

    #[test]
    fn remove_restores_absent() {
        let registry = TombstoneRegistry::new();
        let task_id = Uuid::new_v4();

        registry.insert(ResultTombstone::new(task_id));
        assert!(registry.is_tombstoned(task_id));

        let removed = registry.remove(task_id);
        assert!(removed.is_some());
        assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
    }

    #[test]
    fn expired_tombstone_is_absent_on_lookup() {
        let registry = TombstoneRegistry::new();
        let task_id = Uuid::new_v4();

        // Build a tombstone that is already expired by back-dating deleted_at.
        let mut tombstone = ResultTombstone::new(task_id).with_ttl(Duration::from_secs(1));
        tombstone.deleted_at = chrono::Utc::now() - chrono::Duration::seconds(10);

        registry.insert(tombstone);

        // Raw get still returns it, but lookup treats it as absent.
        assert!(registry.get(task_id).is_some());
        assert!(!registry.is_tombstoned(task_id));
        assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
    }

    #[test]
    fn purge_expired_removes_only_expired() {
        let registry = TombstoneRegistry::new();
        let live = Uuid::new_v4();
        let expired = Uuid::new_v4();

        registry.insert(ResultTombstone::new(live).with_ttl(Duration::from_secs(3600)));

        let mut old = ResultTombstone::new(expired).with_ttl(Duration::from_secs(1));
        old.deleted_at = chrono::Utc::now() - chrono::Duration::seconds(10);
        registry.insert(old);

        assert_eq!(registry.len(), 2);
        let purged = registry.purge_expired();
        assert_eq!(purged, 1);
        assert_eq!(registry.len(), 1);
        assert!(registry.is_tombstoned(live));
        assert!(registry.get(expired).is_none());
    }

    #[test]
    fn tombstone_ext_age_and_expiry() {
        let task_id = Uuid::new_v4();

        // No TTL -> never expires.
        let no_ttl = ResultTombstone::new(task_id);
        assert!(!no_ttl.is_expired());
        assert!(no_ttl.time_until_expiration().is_none());

        // With TTL, not yet expired.
        let fresh = ResultTombstone::new(task_id).with_ttl(Duration::from_secs(3600));
        assert!(!fresh.is_expired());
        assert!(fresh.time_until_expiration().is_some());

        // Back-dated -> expired.
        let mut old = ResultTombstone::new(task_id).with_ttl(Duration::from_secs(1));
        old.deleted_at = chrono::Utc::now() - chrono::Duration::seconds(10);
        assert!(old.is_expired());
        assert!(old.time_until_expiration().is_none());
        assert!(old.age() >= Duration::from_secs(9));
    }

    #[test]
    fn into_tombstone_consumes() {
        let task_id = Uuid::new_v4();
        let existence = ResultExistence::Tombstoned(ResultTombstone::new(task_id));
        let tombstone = existence.into_tombstone();
        assert_eq!(tombstone.map(|t| t.task_id), Some(task_id));

        assert!(ResultExistence::Absent.into_tombstone().is_none());
        assert!(ResultExistence::Present.into_tombstone().is_none());
    }

    #[test]
    fn clear_and_counts() {
        let registry = TombstoneRegistry::new();
        assert!(registry.is_empty());
        registry.insert(ResultTombstone::new(Uuid::new_v4()));
        registry.insert(ResultTombstone::new(Uuid::new_v4()));
        assert_eq!(registry.len(), 2);
        assert!(!registry.is_empty());
        assert_eq!(registry.clear(), 2);
        assert!(registry.is_empty());
    }
}