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