Skip to main content

mako_engine/
deadline.rs

1//! Deadline tracking for regulatory process timers.
2//!
3//! Every MaKo process is subject to hard regulatory deadlines defined in the
4//! BDEW Application Handbooks, and this module stores them. It does not compute
5//! them: [`mako_fristen`] does, and is the only place that should.
6//!
7//! Three different clocks produce a `due_at`, and they are not interchangeable:
8//!
9//! | Clock | Window | Helper |
10//! |---|---|---|
11//! | CONTRL | 6 wall-clock hours | [`mako_fristen::contrl_due_at`] |
12//! | APERAK | 45 min Strom weekday; Gas next Werktag 12:00 or 3 Werktage | [`mako_fristen::aperak_strom_due_at`] and the Gas pair |
13//! | Antwortfrist | per Prüfidentifikator | [`mako_fristen::antwort::antwort_deadline`] |
14//!
15//! **There is no flat GPKE window.** GPKE Teil 2 states each answer Frist as a
16//! clock time on the first Werktag after the Übertragungstag, so a duration is
17//! wrong in both directions — and wrong silently in the loose one, where it
18//! reports a lapsed Frist as still running.
19//!
20//! The `DeadlineStore` persists these timers per process stream. A background
21//! scheduler polls [`DeadlineStore::due_now`] and dispatches a
22//! `TimeoutDeadline` command to the owning process when a deadline lapses.
23//! The process workflow then handles the command — e.g. by escalating the
24//! case or switching to a failure path.
25//!
26//! # Usage
27//!
28//! ```rust,ignore
29//! // The window comes from the table, never from a literal:
30//! let due = mako_fristen::antwort::antwort_deadline(pid, received_at)
31//!     .expect("a PID with a published Antwortfrist");
32//!
33//! let deadline = Deadline::new(
34//!     process.stream_id().clone(),
35//!     process.process_id(),
36//!     process.tenant_id(),
37//!     process.workflow_id().clone(),
38//!     "aperak-response-window",
39//!     due,
40//! );
41//! deadline_store.register(&deadline).await?;
42//!
43//! // When the counterparty responds in time, cancel the deadline:
44//! deadline_store.cancel(deadline.deadline_id()).await?;
45//!
46//! // Background scheduler (runs every N minutes):
47//! let result = deadline_store.due_now(100).await?;
48//! for d in result.deadlines {
49//!     process_handle.execute(TimeoutDeadline { deadline_id: d.deadline_id() }).await?;
50//!     deadline_store.cancel(d.deadline_id()).await?;
51//! }
52//! ```
53//!
54
55use std::sync::Arc;
56
57#[cfg(any(test, feature = "testing"))]
58use std::collections::HashMap;
59#[cfg(any(test, feature = "testing"))]
60use tokio::sync::RwLock;
61
62use time::OffsetDateTime;
63
64use crate::{
65    error::EngineError,
66    ids::{DeadlineId, ProcessId, StreamId, TenantId},
67    version::WorkflowId,
68};
69
70// ── Deadline ──────────────────────────────────────────────────────────────────
71
72/// A registered regulatory deadline for a single process stream.
73///
74/// Create with [`Deadline::new`], persist via [`DeadlineStore::register`], and
75/// cancel via [`DeadlineStore::cancel`] when the process advances past the
76/// deadline before it fires.
77///
78/// The `label` field identifies the deadline type (e.g.
79/// `"aperak-response-window"`) and is used by the scheduler to dispatch the
80/// correct timeout command.
81#[expect(clippy::struct_field_names)]
82#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
83pub struct Deadline {
84    /// Unique identifier for this deadline entry.
85    deadline_id: DeadlineId,
86
87    /// The process stream this deadline belongs to.
88    stream_id: StreamId,
89
90    /// The process instance this deadline belongs to.
91    process_id: ProcessId,
92
93    /// The tenant that owns this process.
94    tenant_id: TenantId,
95
96    /// The workflow that owns this process (name + format version).
97    ///
98    /// Stored so the deadline scheduler can reconstruct a [`ProcessIdentity`]
99    /// and route the `TimeoutExpired` command to the correct workflow type
100    /// without a separate registry lookup.
101    ///
102    /// [`ProcessIdentity`]: crate::ids::ProcessIdentity
103    workflow_id: WorkflowId,
104
105    /// Human-readable label identifying the deadline type.
106    label: Box<str>,
107
108    /// When this deadline expires.
109    #[serde(with = "time::serde::rfc3339")]
110    due_at: OffsetDateTime,
111
112    /// When this deadline was registered.
113    #[serde(with = "time::serde::rfc3339")]
114    created_at: OffsetDateTime,
115}
116
117impl Deadline {
118    /// Construct a new deadline.
119    ///
120    /// `deadline_id` and `created_at` are generated automatically.
121    ///
122    /// `workflow_id` must match the [`WorkflowId`] under which the owning
123    /// process was started (i.e. `process.workflow_id().clone()`). The
124    /// deadline scheduler uses it to reconstruct a [`ProcessIdentity`] and
125    /// route the `TimeoutExpired` command to the correct workflow type.
126    ///
127    /// [`ProcessIdentity`]: crate::ids::ProcessIdentity
128    #[must_use]
129    pub fn new(
130        stream_id: StreamId,
131        process_id: ProcessId,
132        tenant_id: TenantId,
133        workflow_id: WorkflowId,
134        label: impl Into<Box<str>>,
135        due_at: OffsetDateTime,
136    ) -> Self {
137        Self {
138            deadline_id: DeadlineId::new(),
139            stream_id,
140            process_id,
141            tenant_id,
142            workflow_id,
143            label: label.into(),
144            due_at,
145            created_at: OffsetDateTime::now_utc(),
146        }
147    }
148
149    /// Return `true` when this deadline has passed relative to `now`.
150    ///
151    /// ```rust
152    /// use mako_engine::deadline::Deadline;
153    /// use mako_engine::ids::{ProcessId, StreamId, TenantId};
154    /// use mako_engine::version::WorkflowId;
155    /// use time::{Duration, OffsetDateTime};
156    ///
157    /// let past = Deadline::new(
158    ///     StreamId::new("process/x"),
159    ///     ProcessId::new(),
160    ///     TenantId::new(),
161    ///     WorkflowId::new("gpke-supplier-change", "FV2025-10-01"),
162    ///     "aperak-response-window",
163    ///     OffsetDateTime::now_utc() - Duration::seconds(1),
164    /// );
165    /// assert!(past.is_due(OffsetDateTime::now_utc()));
166    /// ```
167    #[must_use]
168    pub fn is_due(&self, now: OffsetDateTime) -> bool {
169        self.due_at <= now
170    }
171
172    /// The unique identifier of this deadline.
173    #[must_use]
174    pub fn deadline_id(&self) -> DeadlineId {
175        self.deadline_id
176    }
177
178    /// The stream this deadline belongs to.
179    #[must_use]
180    pub fn stream_id(&self) -> &StreamId {
181        &self.stream_id
182    }
183
184    /// The process instance this deadline belongs to.
185    #[must_use]
186    pub fn process_id(&self) -> ProcessId {
187        self.process_id
188    }
189
190    /// The tenant that owns this process.
191    #[must_use]
192    pub fn tenant_id(&self) -> TenantId {
193        self.tenant_id
194    }
195
196    /// The workflow that owns this process.
197    ///
198    /// Used by the deadline scheduler to reconstruct a [`ProcessIdentity`]
199    /// and route the `TimeoutExpired` command to the correct workflow type.
200    ///
201    /// [`ProcessIdentity`]: crate::ids::ProcessIdentity
202    #[must_use]
203    pub fn workflow_id(&self) -> &WorkflowId {
204        &self.workflow_id
205    }
206
207    /// The human-readable label identifying the deadline type (e.g.
208    /// `"aperak-response-window"`).
209    #[must_use]
210    pub fn label(&self) -> &str {
211        &self.label
212    }
213
214    /// When this deadline expires.
215    #[must_use]
216    pub fn due_at(&self) -> OffsetDateTime {
217        self.due_at
218    }
219
220    /// When this deadline was registered.
221    #[must_use]
222    pub fn created_at(&self) -> OffsetDateTime {
223        self.created_at
224    }
225}
226
227// ── DueNowResult ──────────────────────────────────────────────────────────────
228
229/// Result of a [`DeadlineStore::due_now`] poll.
230///
231/// When `has_more` is `true`, the store has additional expired deadlines beyond
232/// the returned `deadlines`. The scheduler should drain in a loop until
233/// `has_more` is `false` to avoid leaving unfired deadlines in the store.
234///
235/// ```rust
236/// # tokio_test::block_on(async {
237/// # use mako_engine::deadline::{InMemoryDeadlineStore, DeadlineStore, Deadline};
238/// # use mako_engine::ids::{ProcessId, StreamId, TenantId};
239/// # use time::OffsetDateTime;
240/// let store = InMemoryDeadlineStore::new();
241/// loop {
242///     let result = store.due_now(50).await.unwrap();
243///     for deadline in result.deadlines {
244///         // dispatch TimeoutDeadline command …
245///         store.cancel(deadline.deadline_id()).await.unwrap();
246///     }
247///     if !result.has_more { break; }
248/// }
249/// # });
250/// ```
251#[derive(Debug, Clone)]
252pub struct DueNowResult {
253    /// Expired deadlines, ordered soonest-first.
254    pub deadlines: Vec<Deadline>,
255    /// `true` when the store contains more expired deadlines beyond `deadlines`.
256    pub has_more: bool,
257}
258
259// ── DeadlineStore ─────────────────────────────────────────────────────────────
260
261/// Storage contract for process deadlines.
262///
263/// ## Scheduler contract
264///
265/// A background timer task should poll this store periodically:
266///
267/// 1. Call [`DeadlineStore::due_now`] to retrieve expired deadlines.
268/// 2. Dispatch a `TimeoutDeadline` command to each owning process.
269/// 3. Call [`DeadlineStore::cancel`] to remove the fired deadline.
270///
271/// Cancelling a deadline before the scheduler fires it prevents a spurious
272/// `TimeoutDeadline` command from being dispatched to the process. Always
273/// cancel deadlines when the process advances past them naturally (e.g. when
274/// the expected counterparty response arrives in time).
275///
276/// ## Blanket `Arc` implementation
277///
278/// `Arc<S>` implements `DeadlineStore` whenever `S: DeadlineStore`, enabling
279/// shared access from both the scheduler and command handlers.
280#[allow(async_fn_in_trait)]
281pub trait DeadlineStore: Send + Sync {
282    /// Register a new deadline.
283    ///
284    /// Upserts by `deadline_id`: if a deadline with the same ID already
285    /// exists it is replaced.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`EngineError::Deadline`] on storage failure.
290    #[must_use = "dropping a register Result silently loses a regulatory APERAK deadline"]
291    async fn register(&self, deadline: &Deadline) -> Result<(), EngineError>;
292
293    /// Cancel a registered deadline by ID.
294    ///
295    /// No-op when the deadline does not exist.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`EngineError::Deadline`] on storage failure.
300    #[must_use = "dropping a cancel Result silently hides a storage failure"]
301    async fn cancel(&self, id: DeadlineId) -> Result<(), EngineError>;
302
303    /// Return up to `limit` deadlines whose `due_at <= now_utc()`, ordered
304    /// soonest-first.
305    ///
306    /// When the store contains more expired deadlines than `limit`, the
307    /// returned [`DueNowResult::has_more`] is `true`. Callers should drain
308    /// in a loop until `has_more` is `false`.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`EngineError::Deadline`] on storage failure.
313    #[must_use = "dropping a due_now Result silently discards fired deadlines"]
314    async fn due_now(&self, limit: usize) -> Result<DueNowResult, EngineError>;
315
316    /// Return all active deadlines for `stream_id`, in registration order.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`EngineError::Deadline`] on storage failure.
321    #[must_use = "dropping a for_stream Result silently discards deadline data"]
322    async fn for_stream(&self, stream_id: &StreamId) -> Result<Vec<Deadline>, EngineError>;
323
324    /// Total number of registered deadlines.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`EngineError::Deadline`] on storage failure.
329    #[must_use = "dropping a len Result silently discards a store error"]
330    async fn len(&self) -> Result<usize, EngineError>;
331
332    /// Return `true` when no deadlines are registered.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`EngineError::Deadline`] on storage failure.
337    async fn is_empty(&self) -> Result<bool, EngineError> {
338        Ok(self.len().await? == 0)
339    }
340
341    /// Count deadlines whose `due_at ≤ now` that have not yet been cancelled.
342    ///
343    /// Indicates scheduler lag: a non-zero value means `TimeoutExpired` commands
344    /// are not being dispatched in time, which is a compliance violation.
345    ///
346    /// The default implementation delegates to [`due_now`] with a limit of
347    /// 10 000; if there are more overdue deadlines, returns 10 000 (capped).
348    /// Implementations can override for a more efficient point-count query.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`EngineError::Deadline`] on storage failure.
353    ///
354    /// [`due_now`]: DeadlineStore::due_now
355    async fn overdue_count(&self) -> Result<usize, EngineError> {
356        const LIMIT: usize = 10_000;
357        let result = self.due_now(LIMIT).await?;
358        Ok(if result.has_more {
359            LIMIT
360        } else {
361            result.deadlines.len()
362        })
363    }
364}
365
366// ── Arc<S> blanket impl ───────────────────────────────────────────────────────
367
368impl<S: DeadlineStore> DeadlineStore for Arc<S> {
369    async fn register(&self, deadline: &Deadline) -> Result<(), EngineError> {
370        self.as_ref().register(deadline).await
371    }
372
373    async fn cancel(&self, id: DeadlineId) -> Result<(), EngineError> {
374        self.as_ref().cancel(id).await
375    }
376
377    async fn due_now(&self, limit: usize) -> Result<DueNowResult, EngineError> {
378        self.as_ref().due_now(limit).await
379    }
380
381    async fn for_stream(&self, stream_id: &StreamId) -> Result<Vec<Deadline>, EngineError> {
382        self.as_ref().for_stream(stream_id).await
383    }
384
385    async fn len(&self) -> Result<usize, EngineError> {
386        self.as_ref().len().await
387    }
388
389    async fn overdue_count(&self) -> Result<usize, EngineError> {
390        self.as_ref().overdue_count().await
391    }
392}
393
394// ── NoopDeadlineStore ─────────────────────────────────────────────────────────
395
396/// A [`DeadlineStore`] that never persists anything.
397///
398/// `register` succeeds silently; `due_now` always returns an empty list.
399/// Use this as the default when deadline tracking is not needed.
400///
401/// # ⚠️ Silent deadline loss
402///
403/// `NoopDeadlineStore` **discards every deadline registration silently**. No
404/// scheduler timeout will ever fire. Missed deadlines are a compliance
405/// violation under BNetzA monitoring. Do not use in production.
406///
407/// This type is available in all build configurations so it can serve as a
408/// default type parameter in [`EngineBuilder`]. However, `EngineBuilder::new`
409/// (which wires this as the default) is only available with the `testing`
410/// feature or in `cfg(test)`. Production binaries must call
411/// [`EngineBuilder::with_stores`] instead.
412///
413/// [`EngineBuilder`]: crate::builder::EngineBuilder
414/// [`EngineBuilder::with_stores`]: crate::builder::EngineBuilder::with_stores
415#[derive(Debug, Clone, Copy, Default)]
416#[must_use = "NoopDeadlineStore discards all deadlines silently — use a persistent DeadlineStore in production"]
417#[cfg_attr(
418    not(any(test, feature = "testing")),
419    deprecated = "NoopDeadlineStore must not be instantiated in production builds; use a durable DeadlineStore instead"
420)]
421pub struct NoopDeadlineStore;
422
423#[cfg(any(test, feature = "testing"))]
424impl DeadlineStore for NoopDeadlineStore {
425    async fn register(&self, _deadline: &Deadline) -> Result<(), EngineError> {
426        Ok(())
427    }
428
429    async fn cancel(&self, _id: DeadlineId) -> Result<(), EngineError> {
430        Ok(())
431    }
432
433    async fn due_now(&self, _limit: usize) -> Result<DueNowResult, EngineError> {
434        Ok(DueNowResult {
435            deadlines: Vec::new(),
436            has_more: false,
437        })
438    }
439
440    async fn for_stream(&self, _stream_id: &StreamId) -> Result<Vec<Deadline>, EngineError> {
441        Ok(Vec::new())
442    }
443
444    async fn len(&self) -> Result<usize, EngineError> {
445        Ok(0)
446    }
447}
448
449// ── InMemoryDeadlineStore ─────────────────────────────────────────────────────
450
451/// An in-memory [`DeadlineStore`] for tests and development.
452///
453/// Backed by a `HashMap` protected by a `Mutex`. Cloning shares the
454/// underlying data via `Arc` — all clones see the same deadlines.
455///
456/// **Not production-safe.** Use this for:
457/// - Unit and integration tests
458/// - Examples and local development
459/// - Verifying the scheduler loop without an external timer service
460///
461/// Only available in `#[cfg(test)]` or with the `testing` feature enabled.
462#[cfg(any(test, feature = "testing"))]
463#[derive(Debug, Default, Clone)]
464pub struct InMemoryDeadlineStore {
465    inner: Arc<RwLock<HashMap<DeadlineId, Deadline>>>,
466}
467
468#[cfg(any(test, feature = "testing"))]
469impl InMemoryDeadlineStore {
470    /// Create an empty deadline store.
471    #[must_use]
472    pub fn new() -> Self {
473        Self::default()
474    }
475
476    /// Return `true` when no deadlines are registered.
477    pub async fn is_empty(&self) -> bool {
478        self.inner.read().await.is_empty()
479    }
480}
481
482#[cfg(any(test, feature = "testing"))]
483impl DeadlineStore for InMemoryDeadlineStore {
484    async fn register(&self, deadline: &Deadline) -> Result<(), EngineError> {
485        self.inner
486            .write()
487            .await
488            .insert(deadline.deadline_id, deadline.clone());
489        Ok(())
490    }
491
492    async fn cancel(&self, id: DeadlineId) -> Result<(), EngineError> {
493        self.inner.write().await.remove(&id);
494        Ok(())
495    }
496
497    async fn due_now(&self, limit: usize) -> Result<DueNowResult, EngineError> {
498        let now = OffsetDateTime::now_utc();
499        let map = self.inner.read().await;
500        let mut due: Vec<_> = map.values().filter(|d| d.is_due(now)).cloned().collect();
501        // Soonest-first: the scheduler processes the most urgent deadlines first.
502        due.sort_by_key(|d| d.due_at);
503        // Probe one extra to detect whether more remain.
504        let has_more = due.len() > limit;
505        due.truncate(limit);
506        Ok(DueNowResult {
507            deadlines: due,
508            has_more,
509        })
510    }
511
512    async fn for_stream(&self, stream_id: &StreamId) -> Result<Vec<Deadline>, EngineError> {
513        let map = self.inner.read().await;
514        Ok(map
515            .values()
516            .filter(|d| &d.stream_id == stream_id)
517            .cloned()
518            .collect())
519    }
520
521    async fn len(&self) -> Result<usize, EngineError> {
522        Ok(self.inner.read().await.len())
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use time::Duration;
530
531    fn make_deadline(due_at: OffsetDateTime) -> Deadline {
532        Deadline::new(
533            StreamId::new("process/test"),
534            ProcessId::new(),
535            TenantId::new(),
536            WorkflowId::new("test-workflow", "FV2025-10-01"),
537            "aperak-response-window",
538            due_at,
539        )
540    }
541
542    #[tokio::test]
543    async fn register_and_cancel() {
544        let store = InMemoryDeadlineStore::new();
545        let d = make_deadline(OffsetDateTime::now_utc() + Duration::days(5));
546        let id = d.deadline_id;
547
548        store.register(&d).await.unwrap();
549        assert_eq!(store.len().await.unwrap(), 1);
550
551        store.cancel(id).await.unwrap();
552        assert!(store.is_empty().await);
553    }
554
555    #[tokio::test]
556    async fn due_now_only_returns_overdue() {
557        let store = InMemoryDeadlineStore::new();
558        let past = make_deadline(OffsetDateTime::now_utc() - Duration::seconds(1));
559        let future = make_deadline(OffsetDateTime::now_utc() + Duration::days(5));
560
561        store.register(&past).await.unwrap();
562        store.register(&future).await.unwrap();
563
564        let due = store.due_now(100).await.unwrap();
565        assert_eq!(due.deadlines.len(), 1);
566        assert_eq!(due.deadlines[0].label.as_ref(), "aperak-response-window");
567        assert!(!due.has_more);
568    }
569
570    #[tokio::test]
571    async fn due_now_ordered_soonest_first() {
572        let store = InMemoryDeadlineStore::new();
573        let t1 = OffsetDateTime::now_utc() - Duration::seconds(60);
574        let t2 = OffsetDateTime::now_utc() - Duration::seconds(10);
575        let t3 = OffsetDateTime::now_utc() - Duration::seconds(1);
576
577        // Register out of order to verify sorting.
578        store.register(&make_deadline(t3)).await.unwrap();
579        store.register(&make_deadline(t1)).await.unwrap();
580        store.register(&make_deadline(t2)).await.unwrap();
581
582        let due = store.due_now(10).await.unwrap();
583        assert_eq!(due.deadlines.len(), 3);
584        assert!(due.deadlines[0].due_at <= due.deadlines[1].due_at);
585        assert!(due.deadlines[1].due_at <= due.deadlines[2].due_at);
586        assert!(!due.has_more);
587    }
588
589    #[tokio::test]
590    async fn for_stream_filters_by_stream() {
591        let store = InMemoryDeadlineStore::new();
592        let stream1 = StreamId::new("process/aaa");
593        let stream2 = StreamId::new("process/bbb");
594        let d1 = Deadline::new(
595            stream1.clone(),
596            ProcessId::new(),
597            TenantId::new(),
598            WorkflowId::new("test-workflow", "FV2025-10-01"),
599            "label",
600            OffsetDateTime::now_utc() + Duration::days(1),
601        );
602        let d2 = Deadline::new(
603            stream2.clone(),
604            ProcessId::new(),
605            TenantId::new(),
606            WorkflowId::new("test-workflow", "FV2025-10-01"),
607            "label",
608            OffsetDateTime::now_utc() + Duration::days(1),
609        );
610
611        store.register(&d1).await.unwrap();
612        store.register(&d2).await.unwrap();
613
614        let for1 = store.for_stream(&stream1).await.unwrap();
615        assert_eq!(for1.len(), 1);
616        assert_eq!(for1[0].stream_id, stream1);
617    }
618
619    #[tokio::test]
620    async fn register_upserts_on_same_id() {
621        let store = InMemoryDeadlineStore::new();
622        let mut d = make_deadline(OffsetDateTime::now_utc() + Duration::days(5));
623        store.register(&d).await.unwrap();
624
625        let new_due = OffsetDateTime::now_utc() + Duration::days(10);
626        d.due_at = new_due;
627        store.register(&d).await.unwrap();
628
629        assert_eq!(
630            store.len().await.unwrap(),
631            1,
632            "upsert must not create a duplicate"
633        );
634        let found = store.for_stream(&d.stream_id).await.unwrap();
635        assert_eq!(found[0].due_at, new_due);
636    }
637
638    #[tokio::test]
639    async fn noop_store_succeeds_silently() {
640        let store = NoopDeadlineStore;
641        let d = make_deadline(OffsetDateTime::now_utc() - Duration::seconds(1));
642        store.register(&d).await.unwrap();
643        assert!(store.due_now(10).await.unwrap().deadlines.is_empty());
644        assert!(store.is_empty().await.unwrap());
645    }
646
647    #[tokio::test]
648    async fn clone_shares_state() {
649        let store1 = InMemoryDeadlineStore::new();
650        let store2 = store1.clone();
651        let d = make_deadline(OffsetDateTime::now_utc() + Duration::days(1));
652        store1.register(&d).await.unwrap();
653        assert_eq!(store2.len().await.unwrap(), 1);
654    }
655
656    #[tokio::test]
657    async fn due_now_has_more_signals_truncation() {
658        let store = InMemoryDeadlineStore::new();
659        let past = OffsetDateTime::now_utc() - Duration::seconds(1);
660        for _ in 0..5 {
661            store.register(&make_deadline(past)).await.unwrap();
662        }
663
664        // Request fewer than available — has_more must be true.
665        let r = store.due_now(3).await.unwrap();
666        assert_eq!(r.deadlines.len(), 3);
667        assert!(r.has_more);
668
669        // Request all — has_more must be false.
670        let r2 = store.due_now(10).await.unwrap();
671        assert_eq!(r2.deadlines.len(), 5);
672        assert!(!r2.has_more);
673    }
674}