Skip to main content

turnframe_test/stores/
counting.rs

1//! A store that counts every call and delegates to the in-memory one.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
6
7use chrono::{DateTime, Utc};
8use turnframe_core::case::CaseKey;
9use turnframe_core::error::ExecutionError;
10use turnframe_core::event::{EventRedaction, OutboxEntry};
11use turnframe_core::ids::{
12    AccountId, CaseRevision, CommandId, ConversationId, EventId, InteractionId, OptionId, OutboxId,
13    RedactionAuthority, TurnId,
14};
15use turnframe_core::interaction::{Interaction, InteractionStatus};
16use turnframe_core::replay::{ReplayRecord, TurnPhase};
17use turnframe_core::response::AssistantTurn;
18use turnframe_store::commit::{CommitBundle, CommitReceipt, CommitStore};
19use turnframe_store::conversation::{
20    ConversationReader, ConversationRecord, ConversationWriter, RecoveryScope, StoredTurn,
21    StoredUserTurn, TurnPhaseMarker,
22};
23use turnframe_store::error::StoreError;
24use turnframe_store::events::{
25    EventBatch, EventCursor, EventJournalReader, EventJournalWriter, EventPage, StoredEvent,
26};
27use turnframe_store::interaction::{
28    InteractionReader, InteractionRecord, InteractionWriter, InvalidationReason, ResolutionOutcome,
29};
30use turnframe_store::journal::{
31    CommandJournalEntry, CommandJournalReader, CommandJournalWriter, JournalAdmission,
32    JournalOutcome,
33};
34use turnframe_store::memory::MemoryStores;
35use turnframe_store::outbox::{OutboxReader, OutboxRecord, OutboxWriter};
36use turnframe_store::replay::{ReplayReader, ReplayWriter};
37
38/// How many times each store method was called.
39///
40/// Keys are `"<role>.<method>"`, e.g. `"journal.begin"`, so they read the way
41/// the persistence contract is written and sort by role.
42#[derive(Default)]
43pub struct StoreCallCounter {
44    counts: Mutex<BTreeMap<&'static str, usize>>,
45}
46
47impl StoreCallCounter {
48    /// A counter with nothing recorded.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Records one call.
55    pub fn record(&self, method: &'static str) {
56        *self.lock().entry(method).or_insert(0) += 1;
57    }
58
59    /// How many times `method` was called, `0` when never.
60    #[must_use]
61    pub fn count(&self, method: &str) -> usize {
62        self.lock().get(method).copied().unwrap_or(0)
63    }
64
65    /// Every method that was called at least once, in key order.
66    #[must_use]
67    pub fn counts(&self) -> Vec<(&'static str, usize)> {
68        self.lock()
69            .iter()
70            .map(|(method, count)| (*method, *count))
71            .collect()
72    }
73
74    /// Total calls across every method.
75    #[must_use]
76    pub fn total(&self) -> usize {
77        self.lock().values().sum()
78    }
79
80    /// Forgets everything recorded.
81    pub fn reset(&self) {
82        self.lock().clear();
83    }
84
85    /// Locks, recovering from poisoning: a test that already failed must not
86    /// cascade into unrelated failures.
87    fn lock(&self) -> MutexGuard<'_, BTreeMap<&'static str, usize>> {
88        self.counts.lock().unwrap_or_else(PoisonError::into_inner)
89    }
90}
91
92impl fmt::Debug for StoreCallCounter {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.debug_struct("StoreCallCounter")
95            .field("total", &self.total())
96            .field("methods", &self.counts())
97            .finish()
98    }
99}
100
101/// Every persistence trait, counted and delegated to one [`MemoryStores`].
102///
103/// It adds nothing to the semantics: the answers, the errors and the injected
104/// failures are the in-memory store's own. Only the tally is new.
105#[derive(Debug)]
106pub struct CountingStores {
107    inner: Arc<MemoryStores>,
108    counter: Arc<StoreCallCounter>,
109}
110
111impl CountingStores {
112    /// Wraps `inner`, recording into `counter`.
113    #[must_use]
114    pub fn new(inner: Arc<MemoryStores>, counter: Arc<StoreCallCounter>) -> Self {
115        Self { inner, counter }
116    }
117
118    /// The store being counted.
119    #[must_use]
120    pub fn inner(&self) -> &Arc<MemoryStores> {
121        &self.inner
122    }
123
124    /// The tally.
125    #[must_use]
126    pub fn counter(&self) -> &Arc<StoreCallCounter> {
127        &self.counter
128    }
129}
130
131/// Generates one counted, delegating implementation of a store trait.
132///
133/// The whole `impl` block is generated at once because `#[async_trait]` has to
134/// see the `async fn`s: a macro invocation *inside* the block would still be
135/// unexpanded when the attribute runs. Delegation is written as
136/// `<MemoryStores as Trait>::method` because several of these traits declare a
137/// method called `get`.
138///
139/// Each role is implemented as its reader half and its writer half, which is
140/// how the persistence contract is split; the combined trait
141/// (`ConversationStore`, `ReplayStore`, …) follows from its blanket
142/// implementation. Both halves of a role share one counter prefix, so a caller
143/// still asks for `"conversations.load_turn"` without knowing which half it
144/// landed on.
145macro_rules! counting_impl {
146    (
147        $trait:path, $prefix:literal,
148        $( fn $name:ident ( $( $arg:ident : $ty:ty ),* $(,)? ) -> $ret:ty ; )*
149    ) => {
150        #[async_trait::async_trait]
151        impl $trait for CountingStores {
152            $(
153                async fn $name(&self, $( $arg : $ty ),*) -> $ret {
154                    self.counter.record(concat!($prefix, ".", stringify!($name)));
155                    <MemoryStores as $trait>::$name(&self.inner, $( $arg ),*).await
156                }
157            )*
158        }
159    };
160}
161
162counting_impl! {
163    ConversationReader, "conversations",
164    fn load_conversation(
165        account: &AccountId,
166        id: &ConversationId,
167    ) -> Result<ConversationRecord, StoreError>;
168    fn load_recent_turns(
169        account: &AccountId,
170        conversation: &ConversationId,
171        limit: usize,
172    ) -> Result<Vec<StoredTurn>, StoreError>;
173    fn load_turn(account: &AccountId, turn_id: &TurnId) -> Result<StoredTurn, StoreError>;
174    fn turn_phase(
175        account: &AccountId,
176        turn_id: &TurnId,
177    ) -> Result<TurnPhaseMarker, StoreError>;
178    fn list_unfinished_turns(
179        scope: RecoveryScope,
180        limit: usize,
181    ) -> Result<Vec<TurnPhaseMarker>, StoreError>;
182}
183
184counting_impl! {
185    ConversationWriter, "conversations",
186    fn create_conversation(record: ConversationRecord) -> Result<(), StoreError>;
187    fn append_user_turn(turn: StoredUserTurn) -> Result<(), StoreError>;
188    fn append_assistant_turn(
189        account: &AccountId,
190        turn: AssistantTurn,
191    ) -> Result<(), StoreError>;
192    fn set_turn_phase(
193        account: &AccountId,
194        turn_id: &TurnId,
195        phase: TurnPhase,
196    ) -> Result<TurnPhaseMarker, StoreError>;
197}
198
199counting_impl! {
200    InteractionReader, "interactions",
201    fn get(
202        account: &AccountId,
203        id: &InteractionId,
204    ) -> Result<InteractionRecord, StoreError>;
205    fn list_open_for_conversation(
206        account: &AccountId,
207        conversation: &ConversationId,
208    ) -> Result<Vec<Interaction>, StoreError>;
209    fn list_open_for_case(
210        account: &AccountId,
211        case_key: &CaseKey,
212    ) -> Result<Vec<Interaction>, StoreError>;
213    fn blocking_answered_at(
214        account: &AccountId,
215        case_key: &CaseKey,
216        revision: CaseRevision,
217    ) -> Result<bool, StoreError>;
218}
219
220counting_impl! {
221    InteractionWriter, "interactions",
222    fn insert(interaction: Interaction) -> Result<(), StoreError>;
223    fn insert_replacing_blocking(
224        interaction: Interaction,
225    ) -> Result<Vec<InteractionId>, StoreError>;
226    fn begin_resolution(
227        account: &AccountId,
228        id: &InteractionId,
229        expected_status: InteractionStatus,
230        option_id: OptionId,
231        resolved_by: TurnId,
232    ) -> Result<InteractionRecord, StoreError>;
233    fn finish_resolution(
234        account: &AccountId,
235        id: &InteractionId,
236        outcome: ResolutionOutcome,
237    ) -> Result<InteractionRecord, StoreError>;
238    fn invalidate_for_case(
239        account: &AccountId,
240        case_key: &CaseKey,
241        new_revision: CaseRevision,
242        reason: InvalidationReason,
243    ) -> Result<Vec<InteractionId>, StoreError>;
244    fn invalidate_case_cards(
245        account: &AccountId,
246        case_key: &CaseKey,
247        reason: InvalidationReason,
248    ) -> Result<Vec<InteractionId>, StoreError>;
249    fn expire_due(now: DateTime<Utc>) -> Result<Vec<InteractionId>, StoreError>;
250}
251
252counting_impl! {
253    CommandJournalReader, "journal",
254    fn get(
255        account: &AccountId,
256        command_id: &CommandId,
257    ) -> Result<CommandJournalEntry, StoreError>;
258    fn for_turn(
259        account: &AccountId,
260        turn_id: &TurnId,
261    ) -> Result<Vec<CommandJournalEntry>, StoreError>;
262    fn pending_for_turn(
263        account: &AccountId,
264        turn_id: &TurnId,
265    ) -> Result<Vec<CommandJournalEntry>, StoreError>;
266}
267
268counting_impl! {
269    CommandJournalWriter, "journal",
270    fn begin(entry: CommandJournalEntry) -> Result<JournalAdmission, StoreError>;
271    fn mark_executing(
272        account: &AccountId,
273        command_id: &CommandId,
274    ) -> Result<(), StoreError>;
275    fn complete(
276        account: &AccountId,
277        command_id: &CommandId,
278        outcome: JournalOutcome,
279    ) -> Result<(), StoreError>;
280    fn fail(
281        account: &AccountId,
282        command_id: &CommandId,
283        error: &ExecutionError,
284    ) -> Result<(), StoreError>;
285}
286
287counting_impl! {
288    EventJournalWriter, "events",
289    fn append(batch: EventBatch) -> Result<Vec<EventId>, StoreError>;
290    fn redact_payload(
291        account: &AccountId,
292        event_id: &EventId,
293        authority: &RedactionAuthority,
294    ) -> Result<EventRedaction, StoreError>;
295}
296
297counting_impl! {
298    EventJournalReader, "events",
299    fn list_since(
300        account: &AccountId,
301        case_key: &CaseKey,
302        since: CaseRevision,
303        limit: usize,
304    ) -> Result<Vec<StoredEvent>, StoreError>;
305    fn read_from(
306        account: &AccountId,
307        after: EventCursor,
308        limit: usize,
309    ) -> Result<EventPage, StoreError>;
310    fn get_by_ids(
311        account: &AccountId,
312        ids: &[EventId],
313    ) -> Result<Vec<StoredEvent>, StoreError>;
314    fn count(account: &AccountId, case_key: &CaseKey) -> Result<u64, StoreError>;
315}
316
317counting_impl! {
318    OutboxReader, "outbox",
319    fn get(outbox_id: &OutboxId) -> Result<OutboxRecord, StoreError>;
320    fn list_for_command(command_id: &CommandId) -> Result<Vec<OutboxRecord>, StoreError>;
321}
322
323counting_impl! {
324    OutboxWriter, "outbox",
325    fn enqueue(entry: OutboxEntry) -> Result<(), StoreError>;
326    fn claim_due(
327        now: DateTime<Utc>,
328        limit: usize,
329        worker_id: &str,
330    ) -> Result<Vec<OutboxEntry>, StoreError>;
331    fn mark_completed(outbox_id: &OutboxId) -> Result<(), StoreError>;
332    fn mark_failed(
333        outbox_id: &OutboxId,
334        reason: String,
335        retry_at: Option<DateTime<Utc>>,
336    ) -> Result<(), StoreError>;
337    fn mark_outcome_unknown(
338        outbox_id: &OutboxId,
339        remote_ref: Option<String>,
340    ) -> Result<(), StoreError>;
341    fn reschedule(
342        outbox_id: &OutboxId,
343        next_attempt_at: DateTime<Utc>,
344    ) -> Result<(), StoreError>;
345    fn release_expired_claims(
346        claimed_before: DateTime<Utc>,
347    ) -> Result<Vec<OutboxId>, StoreError>;
348}
349
350counting_impl! {
351    ReplayReader, "replay",
352    fn get(account: &AccountId, turn_id: &TurnId) -> Result<ReplayRecord, StoreError>;
353    fn list_for_conversation(
354        account: &AccountId,
355        conversation: &ConversationId,
356        limit: usize,
357    ) -> Result<Vec<ReplayRecord>, StoreError>;
358}
359
360counting_impl! {
361    ReplayWriter, "replay",
362    fn put(record: ReplayRecord) -> Result<(), StoreError>;
363}
364
365counting_impl! {
366    CommitStore, "commit",
367    fn commit(
368        account: &AccountId,
369        bundle: CommitBundle,
370    ) -> Result<CommitReceipt, StoreError>;
371}