Skip to main content

aion_store/
memory.rs

1//! `InMemoryStore` reference implementation and behavioural test suite.
2
3use std::collections::{BTreeMap, HashMap};
4use std::sync::{Mutex, MutexGuard, PoisonError};
5
6use aion_core::{
7    Event, TimerId, WorkflowFilter, WorkflowId, WorkflowStatus, WorkflowSummary, status_from_events,
8};
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11
12use crate::namespace::{
13    MintOutcome, NamespaceOrigin, NamespacePlacement, NamespaceRecord, NamespaceState,
14    NamespaceStore,
15};
16use crate::package::{PackageRecord, PackageRouteRecord, PackageStore};
17use crate::visibility::{ListWorkflowsFilter, VisibilityRecord, VisibilityStore};
18use crate::worker_deployment::{
19    DesiredState, PutOutcome, UndecodableWorkerDeployment, WorkerDeployment,
20    WorkerDeploymentDeleteOutcome, WorkerDeploymentListing, WorkerDeploymentPutResult,
21    WorkerDeploymentStore,
22};
23use crate::workloop::{
24    InvariantRecordSlot, InvariantStateRecord, UndecodableWorkloop, WorkloopDueProbe,
25    WorkloopListing, WorkloopRecord, WorkloopStore,
26};
27use crate::{
28    ReadableEventStore, RunSummary, StoreError, TimerEntry, TimerRetirement, WritableEventStore,
29    WriteToken,
30};
31
32/// Correct non-durable [`crate::EventStore`] implementation for tests and backend equivalence.
33#[derive(Debug, Default)]
34pub struct InMemoryStore {
35    state: Mutex<InMemoryState>,
36    namespaces: Mutex<BTreeMap<String, NamespaceRecord>>,
37    worker_deployments: Mutex<BTreeMap<String, Vec<u8>>>,
38    workloops: Mutex<BTreeMap<String, StoredWorkloopRow>>,
39    invariant_records: Mutex<BTreeMap<(String, String), Vec<u8>>>,
40}
41
42#[async_trait]
43impl VisibilityStore for InMemoryStore {
44    async fn record_visibility(&self, record: VisibilityRecord) -> Result<(), StoreError> {
45        let mut state = self.lock_state()?;
46        state
47            .visibility
48            .insert((record.workflow_id.clone(), record.run_id.clone()), record);
49        Ok(())
50    }
51
52    async fn list_workflows(
53        &self,
54        filter: ListWorkflowsFilter,
55    ) -> Result<Vec<crate::visibility::WorkflowSummary>, StoreError> {
56        let state = self.lock_state()?;
57        let mut summaries = state
58            .visibility
59            .values()
60            .cloned()
61            .map(crate::visibility::WorkflowSummary::from)
62            .filter(|summary| filter.matches(summary))
63            .collect::<Vec<_>>();
64        summaries.sort_by(|left, right| {
65            left.start_time.cmp(&right.start_time).then_with(|| {
66                left.workflow_id
67                    .to_string()
68                    .cmp(&right.workflow_id.to_string())
69            })
70        });
71        let offset = filter.offset.and_then(|value| usize::try_from(value).ok());
72        if let Some(offset) = offset {
73            summaries = summaries.into_iter().skip(offset).collect();
74        }
75        if let Some(limit) = filter.limit.and_then(|value| usize::try_from(value).ok()) {
76            summaries.truncate(limit);
77        }
78        Ok(summaries)
79    }
80
81    async fn count_workflows(&self, filter: ListWorkflowsFilter) -> Result<u64, StoreError> {
82        let state = self.lock_state()?;
83        Ok(state
84            .visibility
85            .values()
86            .cloned()
87            .map(crate::visibility::WorkflowSummary::from)
88            .filter(|summary| filter.matches(summary))
89            .count()
90            .try_into()
91            .unwrap_or(u64::MAX))
92    }
93}
94
95#[derive(Debug, Default)]
96struct InMemoryState {
97    histories: HashMap<WorkflowId, Vec<Event>>,
98    timers: HashMap<(WorkflowId, TimerId), TimerEntry>,
99    visibility: HashMap<(WorkflowId, aion_core::RunId), VisibilityRecord>,
100    packages: HashMap<(String, String), PackageRecord>,
101    package_routes: HashMap<String, String>,
102}
103
104impl InMemoryStore {
105    fn lock_state(&self) -> Result<MutexGuard<'_, InMemoryState>, StoreError> {
106        self.state
107            .lock()
108            .map_err(|error| StoreError::Backend(format!("in-memory store lock poisoned: {error}")))
109    }
110
111    /// Poison-tolerant lock over the namespace registry map.
112    ///
113    /// The namespace operations have no fallible body beyond the lock, so a
114    /// poisoned guard is recovered in place (matching the recording test-double
115    /// pattern in `testing.rs`) rather than surfaced as a `StoreError`.
116    fn lock_namespaces(&self) -> MutexGuard<'_, BTreeMap<String, NamespaceRecord>> {
117        self.namespaces
118            .lock()
119            .unwrap_or_else(PoisonError::into_inner)
120    }
121
122    fn lock_worker_deployments(&self) -> MutexGuard<'_, BTreeMap<String, Vec<u8>>> {
123        self.worker_deployments
124            .lock()
125            .unwrap_or_else(PoisonError::into_inner)
126    }
127
128    /// Write backend bytes directly for persistence conformance testing.
129    ///
130    /// This deliberately bypasses the entity codec so the shared suite can pin
131    /// poisoned-row listing, replacement, and deletion behavior on every backend.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`StoreError::Serialization`] when `name` is empty or whitespace.
136    pub fn write_raw_worker_deployment(
137        &self,
138        name: &str,
139        bytes: Vec<u8>,
140    ) -> Result<(), StoreError> {
141        if name.trim().is_empty() {
142            return Err(StoreError::Serialization(
143                "worker deployment name must not be empty".to_owned(),
144            ));
145        }
146        self.lock_worker_deployments()
147            .insert(name.to_owned(), bytes);
148        Ok(())
149    }
150
151    fn lock_workloops(&self) -> MutexGuard<'_, BTreeMap<String, StoredWorkloopRow>> {
152        self.workloops
153            .lock()
154            .unwrap_or_else(PoisonError::into_inner)
155    }
156
157    fn lock_invariant_records(&self) -> MutexGuard<'_, BTreeMap<(String, String), Vec<u8>>> {
158        self.invariant_records
159            .lock()
160            .unwrap_or_else(PoisonError::into_inner)
161    }
162
163    /// Write workloop backend bytes directly for persistence conformance
164    /// testing (poisoned-row listing behavior), bypassing the entity codec.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`StoreError::Serialization`] when `loop_id` is empty.
169    pub fn write_raw_workloop(&self, loop_id: &str, bytes: Vec<u8>) -> Result<(), StoreError> {
170        if loop_id.trim().is_empty() {
171            return Err(StoreError::Serialization(
172                "workloop id must not be empty".to_owned(),
173            ));
174        }
175        // A raw row's due instant comes from the probe when the bytes carry
176        // one; poisoned bytes index as never-due (skipped by the sweep,
177        // surfaced by the listing).
178        let next_check_at =
179            WorkloopDueProbe::decode(&bytes).map_or(None, |probe| probe.next_check_at);
180        self.lock_workloops().insert(
181            loop_id.to_owned(),
182            StoredWorkloopRow {
183                next_check_at,
184                bytes,
185            },
186        );
187        Ok(())
188    }
189}
190
191/// One stored workloop row: the encoded record plus its due instant, cached
192/// at write time so the due sweep filters on a comparison rather than a full
193/// decode — the store-level half of the R13.3 zero-cost-sleep measurement.
194#[derive(Debug)]
195struct StoredWorkloopRow {
196    next_check_at: Option<DateTime<Utc>>,
197    bytes: Vec<u8>,
198}
199
200#[async_trait]
201impl WorkloopStore for InMemoryStore {
202    async fn put_workloop(&self, record: WorkloopRecord) -> Result<(), StoreError> {
203        let bytes = record.encode()?;
204        self.lock_workloops().insert(
205            record.loop_id.to_string(),
206            StoredWorkloopRow {
207                next_check_at: record.next_check_at,
208                bytes,
209            },
210        );
211        Ok(())
212    }
213
214    async fn get_workloop(
215        &self,
216        loop_id: &WorkflowId,
217    ) -> Result<Option<WorkloopRecord>, StoreError> {
218        self.lock_workloops()
219            .get(&loop_id.to_string())
220            .map(|row| WorkloopRecord::decode(&row.bytes))
221            .transpose()
222    }
223
224    async fn list_workloops(&self) -> Result<WorkloopListing, StoreError> {
225        let mut listing = WorkloopListing::default();
226        for (loop_id, row) in self.lock_workloops().iter() {
227            match WorkloopRecord::decode(&row.bytes) {
228                Ok(record) => listing.workloops.push(record),
229                Err(error) => listing.undecodable.push(UndecodableWorkloop {
230                    loop_id: loop_id.clone(),
231                    error: error.to_string(),
232                }),
233            }
234        }
235        Ok(listing)
236    }
237
238    async fn due_workloops(&self, as_of: DateTime<Utc>) -> Result<Vec<WorkloopRecord>, StoreError> {
239        let mut due = Vec::new();
240        // The due filter is a cached-instant comparison per row; only DUE rows
241        // are decoded, so a thousand sleeping loops cost a thousand
242        // comparisons, not a thousand spec re-validations (R13.3, measured).
243        for row in self.lock_workloops().values() {
244            if row.next_check_at.is_none_or(|at| at > as_of) {
245                continue;
246            }
247            match WorkloopRecord::decode(&row.bytes) {
248                Ok(record) => due.push(record),
249                Err(error) => {
250                    // Undecodable rows are excluded from the hot sweep path but
251                    // never silently: they stay visible via list_workloops.
252                    tracing::warn!(%error, "skipping undecodable workloop row in due sweep");
253                }
254            }
255        }
256        Ok(due)
257    }
258
259    async fn remove_workloop(&self, loop_id: &WorkflowId) -> Result<bool, StoreError> {
260        Ok(self.lock_workloops().remove(&loop_id.to_string()).is_some())
261    }
262
263    async fn put_invariant_record(
264        &self,
265        record: InvariantStateRecord,
266        prune_before: DateTime<Utc>,
267    ) -> Result<u64, StoreError> {
268        let key = (record.loop_id.to_string(), record.invariant.clone());
269        let mut slots = self.lock_invariant_records();
270        let mut slot = match slots.get(&key) {
271            Some(bytes) => InvariantRecordSlot::decode(bytes)?.rotated(record),
272            None => InvariantRecordSlot {
273                current: record,
274                previous: Vec::new(),
275            },
276        };
277        // The prune runs on the SAME slot value the rotation produced, inside
278        // the one lock acquisition: retention is applied to the generation
279        // this write just rotated in, never to a slot re-read afterwards.
280        let removed = slot.prune(prune_before);
281        slots.insert(key, slot.encode()?);
282        Ok(removed)
283    }
284
285    async fn current_invariant_record(
286        &self,
287        loop_id: &WorkflowId,
288        invariant: &str,
289    ) -> Result<Option<InvariantStateRecord>, StoreError> {
290        self.lock_invariant_records()
291            .get(&(loop_id.to_string(), invariant.to_owned()))
292            .map(|bytes| InvariantRecordSlot::decode(bytes).map(|slot| slot.current))
293            .transpose()
294    }
295
296    async fn invariant_record_generations(
297        &self,
298        loop_id: &WorkflowId,
299        invariant: &str,
300    ) -> Result<Vec<InvariantStateRecord>, StoreError> {
301        self.lock_invariant_records()
302            .get(&(loop_id.to_string(), invariant.to_owned()))
303            .map(|bytes| InvariantRecordSlot::decode(bytes).map(|slot| slot.previous))
304            .transpose()
305            .map(Option::unwrap_or_default)
306    }
307}
308
309fn history_head(history: &[Event]) -> u64 {
310    history.iter().map(Event::seq).max().unwrap_or_default()
311}
312
313fn history_in_sequence_order(history: &[Event]) -> Vec<Event> {
314    let mut ordered = history.to_vec();
315    ordered.sort_by_key(Event::seq);
316    ordered
317}
318
319#[async_trait]
320impl PackageStore for InMemoryStore {
321    async fn put_package(&self, record: PackageRecord) -> Result<(), StoreError> {
322        let primary = record.workflow_type.clone();
323        self.put_package_with_routes(record, &[primary]).await
324    }
325
326    async fn put_package_with_routes(
327        &self,
328        record: PackageRecord,
329        route_workflow_types: &[String],
330    ) -> Result<(), StoreError> {
331        let mut state = self.lock_state()?;
332        for workflow_type in route_workflow_types {
333            state
334                .package_routes
335                .insert(workflow_type.clone(), record.content_hash.clone());
336        }
337        state.packages.insert(
338            (record.workflow_type.clone(), record.content_hash.clone()),
339            record,
340        );
341        Ok(())
342    }
343
344    async fn list_packages(&self) -> Result<Vec<PackageRecord>, StoreError> {
345        let state = self.lock_state()?;
346        let mut records: Vec<PackageRecord> = state.packages.values().cloned().collect();
347        records.sort_by(|left, right| {
348            left.deployed_at
349                .cmp(&right.deployed_at)
350                .then_with(|| left.workflow_type.cmp(&right.workflow_type))
351                .then_with(|| left.content_hash.cmp(&right.content_hash))
352        });
353        Ok(records)
354    }
355
356    async fn delete_package(
357        &self,
358        workflow_type: &str,
359        content_hash: &str,
360    ) -> Result<(), StoreError> {
361        let mut state = self.lock_state()?;
362        state
363            .packages
364            .remove(&(workflow_type.to_owned(), content_hash.to_owned()));
365        Ok(())
366    }
367
368    async fn put_package_route(
369        &self,
370        workflow_type: &str,
371        content_hash: &str,
372    ) -> Result<(), StoreError> {
373        let mut state = self.lock_state()?;
374        state
375            .package_routes
376            .insert(workflow_type.to_owned(), content_hash.to_owned());
377        Ok(())
378    }
379
380    async fn list_package_routes(&self) -> Result<Vec<PackageRouteRecord>, StoreError> {
381        let state = self.lock_state()?;
382        let mut routes: Vec<PackageRouteRecord> = state
383            .package_routes
384            .iter()
385            .map(|(workflow_type, content_hash)| PackageRouteRecord {
386                workflow_type: workflow_type.clone(),
387                content_hash: content_hash.clone(),
388            })
389            .collect();
390        routes.sort_by(|left, right| left.workflow_type.cmp(&right.workflow_type));
391        Ok(routes)
392    }
393}
394
395#[async_trait]
396impl NamespaceStore for InMemoryStore {
397    async fn register_namespace(
398        &self,
399        name: &str,
400        origin: NamespaceOrigin,
401    ) -> Result<MintOutcome, StoreError> {
402        let now = Utc::now();
403        let mut namespaces = self.lock_namespaces();
404        if let Some(existing) = namespaces.get_mut(name) {
405            existing.bump_last_seen(now);
406            Ok(MintOutcome::AlreadyExisted)
407        } else {
408            namespaces.insert(
409                name.to_owned(),
410                NamespaceRecord::new_minted(name, origin, now),
411            );
412            Ok(MintOutcome::Created)
413        }
414    }
415
416    async fn put_namespace(&self, record: NamespaceRecord) -> Result<MintOutcome, StoreError> {
417        let now = Utc::now();
418        let mut namespaces = self.lock_namespaces();
419        if let Some(existing) = namespaces.get_mut(&record.name) {
420            // Idempotent on an existing name: reconcile as already-existing
421            // rather than overwriting the durable record wholesale. Only the
422            // staleness signal is refreshed.
423            existing.bump_last_seen(now);
424            Ok(MintOutcome::AlreadyExisted)
425        } else {
426            namespaces.insert(record.name.clone(), record);
427            Ok(MintOutcome::Created)
428        }
429    }
430
431    async fn list_namespaces(&self) -> Result<Vec<NamespaceRecord>, StoreError> {
432        let namespaces = self.lock_namespaces();
433        let mut records: Vec<NamespaceRecord> = namespaces.values().cloned().collect();
434        records.sort_by(|left, right| {
435            left.created_at
436                .cmp(&right.created_at)
437                .then_with(|| left.name.cmp(&right.name))
438        });
439        Ok(records)
440    }
441
442    async fn get_namespace(&self, name: &str) -> Result<Option<NamespaceRecord>, StoreError> {
443        let namespaces = self.lock_namespaces();
444        Ok(namespaces.get(name).cloned())
445    }
446
447    async fn set_namespace_placement(
448        &self,
449        name: &str,
450        placement: NamespacePlacement,
451    ) -> Result<Option<()>, StoreError> {
452        let now = Utc::now();
453        let mut namespaces = self.lock_namespaces();
454        let Some(existing) = namespaces.get_mut(name) else {
455            // Placement targets an already-minted namespace: an absent row is a
456            // not-found the caller surfaces, never a silent mint here.
457            return Ok(None);
458        };
459        existing.placement = placement;
460        existing.bump_last_seen(now);
461        Ok(Some(()))
462    }
463
464    async fn deprecate_namespace(&self, name: &str) -> Result<(), StoreError> {
465        let mut namespaces = self.lock_namespaces();
466        if let Some(existing) = namespaces.get_mut(name) {
467            existing.state = NamespaceState::Deprecated;
468        }
469        // A missing row is an idempotent no-op: deprecation never strands
470        // durable history and an absent registry entry is not an error.
471        Ok(())
472    }
473}
474
475#[async_trait]
476impl WorkerDeploymentStore for InMemoryStore {
477    async fn put_worker_deployment(
478        &self,
479        mut record: WorkerDeployment,
480    ) -> Result<WorkerDeploymentPutResult, StoreError> {
481        let mut deployments = self.lock_worker_deployments();
482        let outcome = if let Some(previous) = deployments.get(&record.name) {
483            match WorkerDeployment::decode(previous) {
484                Ok(previous) => {
485                    record = record.preserving_previous(&previous, Utc::now());
486                }
487                Err(error) => {
488                    tracing::warn!(
489                        deployment_key = %record.name,
490                        %error,
491                        "replacing undecodable prior worker-deployment bytes"
492                    );
493                }
494            }
495            PutOutcome::Replaced
496        } else {
497            PutOutcome::Created
498        };
499        deployments.insert(record.name.clone(), record.encode()?);
500        Ok(WorkerDeploymentPutResult {
501            outcome,
502            deployment: record,
503        })
504    }
505
506    async fn get_worker_deployment(
507        &self,
508        name: &str,
509    ) -> Result<Option<WorkerDeployment>, StoreError> {
510        self.lock_worker_deployments()
511            .get(name)
512            .map(|bytes| WorkerDeployment::decode(bytes))
513            .transpose()
514    }
515
516    async fn list_worker_deployments(&self) -> Result<WorkerDeploymentListing, StoreError> {
517        let mut listing = WorkerDeploymentListing::default();
518        for (name, bytes) in self.lock_worker_deployments().iter() {
519            match WorkerDeployment::decode(bytes) {
520                Ok(record) => listing.deployments.push(record),
521                Err(error) => listing.undecodable.push(UndecodableWorkerDeployment {
522                    name: name.clone(),
523                    error: error.to_string(),
524                }),
525            }
526        }
527        Ok(listing)
528    }
529
530    async fn set_desired_state(
531        &self,
532        name: &str,
533        desired: DesiredState,
534    ) -> Result<Option<WorkerDeployment>, StoreError> {
535        let mut deployments = self.lock_worker_deployments();
536        let Some(bytes) = deployments.get(name) else {
537            return Ok(None);
538        };
539        let mut record = WorkerDeployment::decode(bytes)?;
540        record.change_desired_state(desired, Utc::now());
541        deployments.insert(name.to_owned(), record.encode()?);
542        Ok(Some(record))
543    }
544
545    async fn delete_worker_deployment(
546        &self,
547        name: &str,
548    ) -> Result<WorkerDeploymentDeleteOutcome, StoreError> {
549        let Some(bytes) = self.lock_worker_deployments().remove(name) else {
550            return Ok(WorkerDeploymentDeleteOutcome {
551                existed: false,
552                deployment: None,
553            });
554        };
555        Ok(WorkerDeploymentDeleteOutcome {
556            existed: true,
557            deployment: WorkerDeployment::decode(&bytes).ok(),
558        })
559    }
560}
561
562#[async_trait]
563impl WritableEventStore for InMemoryStore {
564    async fn append(
565        &self,
566        _token: WriteToken,
567        workflow_id: &WorkflowId,
568        events: &[Event],
569        expected_seq: u64,
570    ) -> Result<(), StoreError> {
571        let mut state = self.lock_state()?;
572        let current_head = state
573            .histories
574            .get(workflow_id)
575            .map_or(0, |history| history_head(history));
576
577        if current_head != expected_seq {
578            return Err(StoreError::SequenceConflict {
579                expected: expected_seq,
580                found: current_head,
581            });
582        }
583
584        if events.is_empty() {
585            return Ok(());
586        }
587
588        for (next_seq, event) in (expected_seq + 1..).zip(events.iter()) {
589            if event.seq() != next_seq {
590                return Err(StoreError::Backend(format!(
591                    "event sequence must be contiguous: expected {next_seq}, got {}",
592                    event.seq()
593                )));
594            }
595        }
596
597        state
598            .histories
599            .entry(workflow_id.clone())
600            .or_default()
601            .extend(events.iter().cloned());
602        Ok(())
603    }
604}
605
606#[async_trait]
607impl ReadableEventStore for InMemoryStore {
608    async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError> {
609        let state = self.lock_state()?;
610        Ok(state
611            .histories
612            .get(workflow_id)
613            .map_or_else(Vec::new, |history| history_in_sequence_order(history)))
614    }
615
616    async fn read_history_from(
617        &self,
618        workflow_id: &WorkflowId,
619        from_seq: u64,
620    ) -> Result<Vec<Event>, StoreError> {
621        let state = self.lock_state()?;
622        Ok(state
623            .histories
624            .get(workflow_id)
625            .map_or_else(Vec::new, |history| {
626                let mut events = history
627                    .iter()
628                    .filter(|event| event.seq() >= from_seq)
629                    .cloned()
630                    .collect::<Vec<_>>();
631                events.sort_by_key(Event::seq);
632                events
633            }))
634    }
635
636    async fn read_run_chain(
637        &self,
638        workflow_id: &WorkflowId,
639    ) -> Result<Vec<RunSummary>, StoreError> {
640        let state = self.lock_state()?;
641        let Some(history) = state.histories.get(workflow_id) else {
642            return Ok(Vec::new());
643        };
644
645        crate::run_chain::run_chain_from_history(history)
646    }
647
648    async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
649        let state = self.lock_state()?;
650        let mut workflow_ids = state.histories.keys().cloned().collect::<Vec<_>>();
651        workflow_ids.sort_by_key(ToString::to_string);
652        Ok(workflow_ids)
653    }
654
655    async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError> {
656        let state = self.lock_state()?;
657        let mut active = state
658            .histories
659            .iter()
660            .filter(|(_, history)| {
661                matches!(
662                    status_from_events(&history_in_sequence_order(history)),
663                    WorkflowStatus::Running
664                )
665            })
666            .map(|(workflow_id, _)| workflow_id.clone())
667            .collect::<Vec<_>>();
668        active.sort_by_key(ToString::to_string);
669        Ok(active)
670    }
671
672    async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError> {
673        let state = self.lock_state()?;
674        let mut paused = state
675            .histories
676            .iter()
677            .filter(|(_, history)| {
678                matches!(
679                    status_from_events(&history_in_sequence_order(history)),
680                    WorkflowStatus::Paused
681                )
682            })
683            .map(|(workflow_id, _)| workflow_id.clone())
684            .collect::<Vec<_>>();
685        paused.sort_by_key(ToString::to_string);
686        Ok(paused)
687    }
688
689    async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError> {
690        let state = self.lock_state()?;
691        let mut summaries = state
692            .histories
693            .values()
694            .filter_map(|history| {
695                WorkflowSummary::from_history(&history_in_sequence_order(history))
696            })
697            .filter(|summary| filter.matches(summary))
698            .collect::<Vec<_>>();
699        summaries.sort_by(|left, right| {
700            left.started_at.cmp(&right.started_at).then_with(|| {
701                left.workflow_id
702                    .to_string()
703                    .cmp(&right.workflow_id.to_string())
704            })
705        });
706        Ok(summaries)
707    }
708
709    async fn schedule_timer(
710        &self,
711        workflow_id: &WorkflowId,
712        timer_id: &TimerId,
713        fire_at: DateTime<Utc>,
714        armed_seq: u64,
715    ) -> Result<(), StoreError> {
716        let mut state = self.lock_state()?;
717        state.timers.insert(
718            (workflow_id.clone(), timer_id.clone()),
719            TimerEntry {
720                workflow_id: workflow_id.clone(),
721                timer_id: timer_id.clone(),
722                fire_at,
723                armed_seq,
724            },
725        );
726        Ok(())
727    }
728
729    async fn retire_timer(
730        &self,
731        workflow_id: &WorkflowId,
732        timer_id: &TimerId,
733        fire_at: DateTime<Utc>,
734        armed_seq: u64,
735    ) -> Result<TimerRetirement, StoreError> {
736        let mut state = self.lock_state()?;
737        let key = (workflow_id.clone(), timer_id.clone());
738        // Remove only the arming the caller decided against: a row whose
739        // `(fire_at, armed_seq)` identity differs — a new instant, or the
740        // SAME instant re-armed under a newer `TimerStarted` — is the
741        // replacement timer's durable claim to a recovery fire
742        // (`Superseded`); the absent-row case is the idempotent `Retired`
743        // success ("nothing left to retire"), never an error. The single
744        // `lock_state()` held across get-and-remove is what makes the
745        // compare atomic against a concurrent `schedule_timer`.
746        match state.timers.get(&key) {
747            Some(entry) if entry.fire_at == fire_at && entry.armed_seq == armed_seq => {
748                state.timers.remove(&key);
749                Ok(TimerRetirement::Retired)
750            }
751            Some(_) => Ok(TimerRetirement::Superseded),
752            None => Ok(TimerRetirement::Retired),
753        }
754    }
755
756    async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError> {
757        let state = self.lock_state()?;
758        let mut timers = state
759            .timers
760            .values()
761            .filter(|entry| entry.fire_at <= as_of)
762            .cloned()
763            .collect::<Vec<_>>();
764        timers.sort_by(|left, right| {
765            left.fire_at
766                .cmp(&right.fire_at)
767                .then_with(|| {
768                    left.workflow_id
769                        .to_string()
770                        .cmp(&right.workflow_id.to_string())
771                })
772                .then_with(|| left.timer_id.to_string().cmp(&right.timer_id.to_string()))
773        });
774        Ok(timers)
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use std::sync::Arc;
781
782    use aion_core::{
783        Event, EventEnvelope, Payload, TimerId, WorkflowError, WorkflowFilter, WorkflowId,
784        WorkflowStatus,
785    };
786    use chrono::{DateTime, Utc};
787    use serde_json::json;
788    use tokio::task;
789    use uuid::Uuid;
790
791    use super::InMemoryStore;
792    use crate::{ReadableEventStore, StoreError, TimerEntry, WritableEventStore, WriteToken};
793
794    fn write_token() -> WriteToken {
795        WriteToken::recorder()
796    }
797
798    fn recorded_at(offset_seconds: i64) -> DateTime<Utc> {
799        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
800    }
801
802    fn workflow_id(value: u128) -> WorkflowId {
803        WorkflowId::new(Uuid::from_u128(value))
804    }
805
806    fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
807        EventEnvelope {
808            seq,
809            recorded_at: recorded_at(i64::try_from(seq).unwrap_or_default()),
810            workflow_id: workflow_id.clone(),
811        }
812    }
813
814    fn run_id(value: u128) -> aion_core::RunId {
815        aion_core::RunId::new(Uuid::from_u128(value))
816    }
817
818    fn payload(label: &str) -> Payload {
819        Payload::from_json(&json!({ "label": label })).unwrap_or_else(|error| {
820            Payload::new(
821                aion_core::ContentType::Json,
822                format!("{{\"payload_error\":\"{error}\"}}").into_bytes(),
823            )
824        })
825    }
826
827    fn workflow_started(seq: u64, workflow_id: &WorkflowId, workflow_type: &str) -> Event {
828        Event::WorkflowStarted {
829            envelope: envelope(seq, workflow_id),
830            workflow_type: workflow_type.to_owned(),
831            input: payload("input"),
832            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
833            parent_run_id: None,
834            parent_workflow_id: None,
835            package_version: aion_core::PackageVersion::new("a".repeat(64)),
836        }
837    }
838
839    fn workflow_completed(seq: u64, workflow_id: &WorkflowId) -> Event {
840        Event::WorkflowCompleted {
841            envelope: envelope(seq, workflow_id),
842            result: payload("result"),
843        }
844    }
845
846    fn workflow_failed(seq: u64, workflow_id: &WorkflowId) -> Event {
847        Event::WorkflowFailed {
848            envelope: envelope(seq, workflow_id),
849            error: WorkflowError {
850                message: String::from("failed"),
851                details: None,
852            },
853        }
854    }
855
856    #[tokio::test]
857    async fn read_history_returns_empty_for_unknown_workflow() -> Result<(), StoreError> {
858        let store = InMemoryStore::default();
859
860        assert_eq!(store.read_history(&workflow_id(1)).await?, Vec::new());
861        Ok(())
862    }
863
864    #[tokio::test]
865    async fn append_preserves_sequence_order() -> Result<(), StoreError> {
866        let store = InMemoryStore::default();
867        let workflow_id = workflow_id(1);
868        let first = workflow_started(1, &workflow_id, "checkout");
869        let second = workflow_completed(2, &workflow_id);
870
871        store
872            .append(write_token(), &workflow_id, std::slice::from_ref(&first), 0)
873            .await?;
874        store
875            .append(
876                write_token(),
877                &workflow_id,
878                std::slice::from_ref(&second),
879                1,
880            )
881            .await?;
882
883        assert_eq!(store.read_history(&workflow_id).await?, vec![first, second]);
884        Ok(())
885    }
886
887    #[tokio::test]
888    async fn list_active_returns_only_running_workflows() -> Result<(), StoreError> {
889        let store = InMemoryStore::default();
890        let running = workflow_id(1);
891        let completed = workflow_id(2);
892
893        store
894            .append(
895                write_token(),
896                &running,
897                &[workflow_started(1, &running, "checkout")],
898                0,
899            )
900            .await?;
901        store
902            .append(
903                write_token(),
904                &completed,
905                &[
906                    workflow_started(1, &completed, "checkout"),
907                    workflow_completed(2, &completed),
908                ],
909                0,
910            )
911            .await?;
912
913        assert_eq!(store.list_active().await?, vec![running]);
914        Ok(())
915    }
916
917    fn workflow_paused(seq: u64, workflow_id: &WorkflowId) -> Event {
918        Event::WorkflowPaused {
919            envelope: envelope(seq, workflow_id),
920            run_id: run_id(1),
921            reason: None,
922            operator: None,
923        }
924    }
925
926    /// #204 GATE-2 recovery mechanism: a paused run is EXCLUDED from `list_active`
927    /// (== Running) — so it is not respawned on restart — and is the sole content
928    /// of `list_paused` (== Paused), the durable source the dispatch-hold is
929    /// rebuilt from.
930    #[tokio::test]
931    async fn list_paused_and_list_active_partition_by_projected_status() -> Result<(), StoreError> {
932        let store = InMemoryStore::default();
933        let running = workflow_id(1);
934        let paused = workflow_id(2);
935
936        store
937            .append(
938                write_token(),
939                &running,
940                &[workflow_started(1, &running, "checkout")],
941                0,
942            )
943            .await?;
944        store
945            .append(
946                write_token(),
947                &paused,
948                &[
949                    workflow_started(1, &paused, "checkout"),
950                    workflow_paused(2, &paused),
951                ],
952                0,
953            )
954            .await?;
955
956        assert_eq!(
957            store.list_active().await?,
958            vec![running],
959            "a paused run is excluded from list_active (not respawned)"
960        );
961        assert_eq!(
962            store.list_paused().await?,
963            vec![paused],
964            "list_paused returns exactly the paused run (the hold rebuild source)"
965        );
966        Ok(())
967    }
968
969    #[tokio::test]
970    async fn list_workflow_ids_returns_running_and_terminal_histories() -> Result<(), StoreError> {
971        let store = InMemoryStore::default();
972        let running = workflow_id(2);
973        let completed = workflow_id(1);
974
975        store
976            .append(
977                write_token(),
978                &running,
979                &[workflow_started(1, &running, "checkout")],
980                0,
981            )
982            .await?;
983        store
984            .append(
985                write_token(),
986                &completed,
987                &[
988                    workflow_started(1, &completed, "checkout"),
989                    workflow_completed(2, &completed),
990                ],
991                0,
992            )
993            .await?;
994
995        assert_eq!(store.list_workflow_ids().await?, vec![completed, running]);
996        Ok(())
997    }
998
999    #[tokio::test]
1000    async fn read_run_chain_projects_run_id_from_started_event() -> Result<(), StoreError> {
1001        let store = InMemoryStore::default();
1002        let workflow_id = workflow_id(1);
1003
1004        store
1005            .append(
1006                write_token(),
1007                &workflow_id,
1008                &[
1009                    workflow_started(1, &workflow_id, "checkout"),
1010                    workflow_completed(2, &workflow_id),
1011                ],
1012                0,
1013            )
1014            .await?;
1015
1016        let chain = store.read_run_chain(&workflow_id).await?;
1017
1018        assert_eq!(chain.len(), 1);
1019        // run_id comes from the WorkflowStarted event (hardcoded to from_u128(1) in the helper)
1020        assert_eq!(chain[0].run_id, run_id(1));
1021        assert_eq!(chain[0].status, WorkflowStatus::Completed);
1022        assert_eq!(chain[0].closed_at, Some(recorded_at(2)));
1023        Ok(())
1024    }
1025
1026    #[tokio::test]
1027    async fn query_uses_core_filter_semantics() -> Result<(), StoreError> {
1028        let store = InMemoryStore::default();
1029        let running_checkout = workflow_id(1);
1030        let completed_checkout = workflow_id(2);
1031        let failed_billing = workflow_id(3);
1032
1033        store
1034            .append(
1035                write_token(),
1036                &running_checkout,
1037                &[workflow_started(1, &running_checkout, "checkout")],
1038                0,
1039            )
1040            .await?;
1041        store
1042            .append(
1043                write_token(),
1044                &completed_checkout,
1045                &[
1046                    workflow_started(1, &completed_checkout, "checkout"),
1047                    workflow_completed(2, &completed_checkout),
1048                ],
1049                0,
1050            )
1051            .await?;
1052        store
1053            .append(
1054                write_token(),
1055                &failed_billing,
1056                &[
1057                    workflow_started(1, &failed_billing, "billing"),
1058                    workflow_failed(2, &failed_billing),
1059                ],
1060                0,
1061            )
1062            .await?;
1063
1064        let filter = WorkflowFilter {
1065            workflow_type: Some(String::from("checkout")),
1066            status: Some(WorkflowStatus::Completed),
1067            started_after: Some(recorded_at(1)),
1068            started_before: Some(recorded_at(1)),
1069            parent: None,
1070        };
1071        let summaries = store.query(&filter).await?;
1072
1073        assert_eq!(summaries.len(), 1);
1074        assert_eq!(summaries[0].workflow_id, completed_checkout);
1075        assert_eq!(summaries[0].status, WorkflowStatus::Completed);
1076        Ok(())
1077    }
1078
1079    #[tokio::test]
1080    async fn stale_expected_sequence_writes_nothing() -> Result<(), StoreError> {
1081        let store = InMemoryStore::default();
1082        let workflow_id = workflow_id(1);
1083        let first = workflow_started(1, &workflow_id, "checkout");
1084
1085        store
1086            .append(write_token(), &workflow_id, std::slice::from_ref(&first), 0)
1087            .await?;
1088        let conflict = store
1089            .append(
1090                write_token(),
1091                &workflow_id,
1092                &[workflow_completed(2, &workflow_id)],
1093                0,
1094            )
1095            .await;
1096
1097        assert_eq!(
1098            conflict,
1099            Err(StoreError::SequenceConflict {
1100                expected: 0,
1101                found: 1,
1102            })
1103        );
1104        assert_eq!(store.read_history(&workflow_id).await?, vec![first]);
1105        Ok(())
1106    }
1107
1108    #[tokio::test]
1109    async fn append_rejects_non_contiguous_event_sequences() -> Result<(), StoreError> {
1110        let store = InMemoryStore::default();
1111        let wf = workflow_id(1);
1112
1113        let result = store
1114            .append(
1115                write_token(),
1116                &wf,
1117                &[
1118                    workflow_started(1, &wf, "checkout"),
1119                    workflow_completed(5, &wf),
1120                ],
1121                0,
1122            )
1123            .await;
1124
1125        assert!(result.is_err());
1126        assert!(matches!(result, Err(StoreError::Backend(_))));
1127        assert_eq!(store.read_history(&wf).await?, Vec::new());
1128        Ok(())
1129    }
1130
1131    #[tokio::test]
1132    async fn concurrent_appends_on_same_expected_sequence_conflict_once() -> Result<(), StoreError>
1133    {
1134        let store = Arc::new(InMemoryStore::default());
1135        let workflow_id = workflow_id(1);
1136        let first_store = Arc::clone(&store);
1137        let first_workflow = workflow_id.clone();
1138        let second_store = Arc::clone(&store);
1139        let second_workflow = workflow_id.clone();
1140
1141        let first = task::spawn(async move {
1142            first_store
1143                .append(
1144                    write_token(),
1145                    &first_workflow,
1146                    &[workflow_started(1, &first_workflow, "checkout")],
1147                    0,
1148                )
1149                .await
1150        });
1151        let second = task::spawn(async move {
1152            second_store
1153                .append(
1154                    write_token(),
1155                    &second_workflow,
1156                    &[workflow_completed(1, &second_workflow)],
1157                    0,
1158                )
1159                .await
1160        });
1161
1162        let results = [
1163            first
1164                .await
1165                .map_err(|error| StoreError::Backend(format!("append task failed: {error}")))?,
1166            second
1167                .await
1168                .map_err(|error| StoreError::Backend(format!("append task failed: {error}")))?,
1169        ];
1170
1171        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1172        assert_eq!(
1173            results
1174                .iter()
1175                .filter(|result| matches!(
1176                    result,
1177                    Err(StoreError::SequenceConflict {
1178                        expected: 0,
1179                        found: 1
1180                    })
1181                ))
1182                .count(),
1183            1
1184        );
1185        assert_eq!(store.read_history(&workflow_id).await?.len(), 1);
1186        Ok(())
1187    }
1188
1189    #[tokio::test]
1190    async fn rescheduling_same_timer_replaces_prior_fire_at() -> Result<(), StoreError> {
1191        let store = InMemoryStore::default();
1192        let workflow_id = workflow_id(1);
1193        let timer_id = TimerId::anonymous(1);
1194        let first_fire_at = recorded_at(10);
1195        let replacement_fire_at = recorded_at(30);
1196
1197        store
1198            .schedule_timer(&workflow_id, &timer_id, first_fire_at, 1)
1199            .await?;
1200        store
1201            .schedule_timer(&workflow_id, &timer_id, replacement_fire_at, 2)
1202            .await?;
1203
1204        assert_eq!(store.expired_timers(first_fire_at).await?, Vec::new());
1205        assert_eq!(
1206            store.expired_timers(replacement_fire_at).await?,
1207            vec![TimerEntry {
1208                workflow_id,
1209                timer_id,
1210                fire_at: replacement_fire_at,
1211                armed_seq: 2,
1212            }]
1213        );
1214        Ok(())
1215    }
1216
1217    #[tokio::test]
1218    async fn expired_timers_include_boundary_and_exclude_future() -> Result<(), StoreError> {
1219        let store = InMemoryStore::default();
1220        let workflow_id = workflow_id(1);
1221        let past_timer = TimerId::anonymous(1);
1222        let boundary_timer = TimerId::anonymous(2);
1223        let future_timer = TimerId::anonymous(3);
1224        let as_of = recorded_at(20);
1225
1226        store
1227            .schedule_timer(&workflow_id, &future_timer, recorded_at(30), 1)
1228            .await?;
1229        store
1230            .schedule_timer(&workflow_id, &boundary_timer, as_of, 2)
1231            .await?;
1232        store
1233            .schedule_timer(&workflow_id, &past_timer, recorded_at(10), 3)
1234            .await?;
1235
1236        assert_eq!(
1237            store.expired_timers(as_of).await?,
1238            vec![
1239                TimerEntry {
1240                    workflow_id: workflow_id.clone(),
1241                    timer_id: past_timer,
1242                    fire_at: recorded_at(10),
1243                    armed_seq: 3,
1244                },
1245                TimerEntry {
1246                    workflow_id,
1247                    timer_id: boundary_timer,
1248                    fire_at: as_of,
1249                    armed_seq: 2,
1250                },
1251            ]
1252        );
1253        Ok(())
1254    }
1255}
1256
1257#[cfg(test)]
1258mod namespace_tests {
1259    #![allow(clippy::expect_used)]
1260
1261    use super::InMemoryStore;
1262    use crate::namespace::{
1263        MintOutcome, NamespaceOrigin, NamespacePlacement, NamespaceRecord, NamespaceState,
1264    };
1265    use crate::{NamespaceStore, StoreError};
1266    use chrono::{TimeZone, Utc};
1267    use std::collections::BTreeSet;
1268
1269    fn labels(values: &[&str]) -> BTreeSet<String> {
1270        values.iter().map(|v| (*v).to_owned()).collect()
1271    }
1272
1273    /// `set_namespace_placement` updates ONLY the placement (+ `last_seen`) of an
1274    /// existing record, leaves the other fields untouched, is idempotent, and is a
1275    /// not-found (`Ok(None)`) for an absent namespace — never a silent mint.
1276    #[tokio::test]
1277    async fn set_placement_updates_only_placement_and_reports_not_found() -> Result<(), StoreError>
1278    {
1279        let store = InMemoryStore::default();
1280        store
1281            .register_namespace("orders", NamespaceOrigin::Explicit)
1282            .await?;
1283        let original = store
1284            .get_namespace("orders")
1285            .await?
1286            .expect("namespace must persist");
1287        assert_eq!(original.placement, NamespacePlacement::Unplaced);
1288
1289        let placement = NamespacePlacement::Prefer {
1290            nodes: labels(&["n1", "n2"]),
1291        };
1292        assert_eq!(
1293            store
1294                .set_namespace_placement("orders", placement.clone())
1295                .await?,
1296            Some(())
1297        );
1298        let updated = store
1299            .get_namespace("orders")
1300            .await?
1301            .expect("namespace must persist");
1302        assert_eq!(updated.placement, placement);
1303        // Only placement + last_seen changed; identity/lifecycle preserved.
1304        assert_eq!(updated.origin, original.origin);
1305        assert_eq!(updated.created_at, original.created_at);
1306        assert_eq!(updated.state, original.state);
1307
1308        // Idempotent: re-applying the same placement is a successful no-op.
1309        assert_eq!(
1310            store.set_namespace_placement("orders", placement).await?,
1311            Some(())
1312        );
1313
1314        // Absent namespace: not-found, and nothing minted.
1315        assert_eq!(
1316            store
1317                .set_namespace_placement("ghost", NamespacePlacement::Unplaced)
1318                .await?,
1319            None
1320        );
1321        assert!(store.get_namespace("ghost").await?.is_none());
1322        Ok(())
1323    }
1324
1325    #[tokio::test]
1326    async fn register_creates_if_absent_and_persists() -> Result<(), StoreError> {
1327        let store = InMemoryStore::default();
1328
1329        let outcome = store
1330            .register_namespace("orders", NamespaceOrigin::WorkerMint)
1331            .await?;
1332
1333        assert_eq!(outcome, MintOutcome::Created);
1334        let record = store
1335            .get_namespace("orders")
1336            .await?
1337            .expect("namespace must persist");
1338        assert_eq!(record.name, "orders");
1339        assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
1340        assert_eq!(record.state, NamespaceState::Active);
1341        assert_eq!(record.created_at, record.last_seen);
1342        Ok(())
1343    }
1344
1345    #[tokio::test]
1346    async fn second_register_already_existed_bumps_last_seen_only() -> Result<(), StoreError> {
1347        let store = InMemoryStore::default();
1348
1349        let first = store
1350            .register_namespace("orders", NamespaceOrigin::WorkerMint)
1351            .await?;
1352        assert_eq!(first, MintOutcome::Created);
1353        let original = store
1354            .get_namespace("orders")
1355            .await?
1356            .expect("namespace must persist");
1357
1358        // A different origin on re-register must NOT overwrite the recorded origin.
1359        let second = store
1360            .register_namespace("orders", NamespaceOrigin::Explicit)
1361            .await?;
1362        assert_eq!(second, MintOutcome::AlreadyExisted);
1363
1364        let touched = store
1365            .get_namespace("orders")
1366            .await?
1367            .expect("namespace must persist");
1368        assert_eq!(touched.created_at, original.created_at);
1369        assert_eq!(touched.origin, NamespaceOrigin::WorkerMint);
1370        assert!(touched.last_seen >= original.last_seen);
1371        Ok(())
1372    }
1373
1374    #[tokio::test]
1375    async fn put_namespace_is_idempotent_on_existing_name() -> Result<(), StoreError> {
1376        let store = InMemoryStore::default();
1377        let now = Utc
1378            .with_ymd_and_hms(2026, 6, 30, 12, 0, 0)
1379            .single()
1380            .expect("valid instant");
1381
1382        let mut record = NamespaceRecord::new_minted("billing", NamespaceOrigin::Explicit, now);
1383        record.config.kind = Some("tenant".to_owned());
1384
1385        let created = store.put_namespace(record.clone()).await?;
1386        assert_eq!(created, MintOutcome::Created);
1387
1388        // A second put with a DIFFERENT record body must reconcile as
1389        // AlreadyExisted and must not overwrite the stored record wholesale.
1390        let mut replacement =
1391            NamespaceRecord::new_minted("billing", NamespaceOrigin::WorkerMint, now);
1392        replacement.config.kind = None;
1393        let again = store.put_namespace(replacement).await?;
1394        assert_eq!(again, MintOutcome::AlreadyExisted);
1395
1396        let stored = store
1397            .get_namespace("billing")
1398            .await?
1399            .expect("namespace must persist");
1400        assert_eq!(stored.origin, NamespaceOrigin::Explicit);
1401        assert_eq!(stored.config.kind.as_deref(), Some("tenant"));
1402        Ok(())
1403    }
1404
1405    #[tokio::test]
1406    async fn list_orders_by_created_at_then_name() -> Result<(), StoreError> {
1407        let store = InMemoryStore::default();
1408        let earlier = Utc
1409            .with_ymd_and_hms(2026, 6, 30, 12, 0, 0)
1410            .single()
1411            .expect("valid instant");
1412        let later = Utc
1413            .with_ymd_and_hms(2026, 6, 30, 13, 0, 0)
1414            .single()
1415            .expect("valid instant");
1416
1417        // Two share `earlier` (tiebreak by name), one is `later`.
1418        store
1419            .put_namespace(NamespaceRecord::new_minted(
1420                "zeta",
1421                NamespaceOrigin::Explicit,
1422                earlier,
1423            ))
1424            .await?;
1425        store
1426            .put_namespace(NamespaceRecord::new_minted(
1427                "alpha",
1428                NamespaceOrigin::Explicit,
1429                earlier,
1430            ))
1431            .await?;
1432        store
1433            .put_namespace(NamespaceRecord::new_minted(
1434                "beta",
1435                NamespaceOrigin::Explicit,
1436                later,
1437            ))
1438            .await?;
1439
1440        let listed: Vec<String> = store
1441            .list_namespaces()
1442            .await?
1443            .into_iter()
1444            .map(|record| record.name)
1445            .collect();
1446
1447        assert_eq!(listed, vec!["alpha", "zeta", "beta"]);
1448        Ok(())
1449    }
1450
1451    #[tokio::test]
1452    async fn get_returns_none_for_absent_name() -> Result<(), StoreError> {
1453        let store = InMemoryStore::default();
1454        assert!(store.get_namespace("missing").await?.is_none());
1455        Ok(())
1456    }
1457
1458    #[tokio::test]
1459    async fn deprecate_sets_state_and_is_idempotent() -> Result<(), StoreError> {
1460        let store = InMemoryStore::default();
1461        store
1462            .register_namespace("orders", NamespaceOrigin::WorkerMint)
1463            .await?;
1464
1465        store.deprecate_namespace("orders").await?;
1466        let deprecated = store
1467            .get_namespace("orders")
1468            .await?
1469            .expect("namespace must persist");
1470        assert_eq!(deprecated.state, NamespaceState::Deprecated);
1471
1472        // Deprecating again is a no-op, not an error.
1473        store.deprecate_namespace("orders").await?;
1474        let still = store
1475            .get_namespace("orders")
1476            .await?
1477            .expect("namespace must persist");
1478        assert_eq!(still.state, NamespaceState::Deprecated);
1479
1480        // Deprecating an absent namespace is also an idempotent no-op.
1481        store.deprecate_namespace("never-seen").await?;
1482        assert!(store.get_namespace("never-seen").await?.is_none());
1483        Ok(())
1484    }
1485}