Skip to main content

aion_server/observability/
instrumented_store.rs

1//! [`InstrumentedEventStore`]: event-store decorator recording server metrics.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5use std::time::Instant;
6
7use aion_core::{ActivityId, Event, TimerId, WorkflowFilter, WorkflowId, WorkflowSummary};
8use aion_store::{
9    EventStore, OutboxRow, PackageRecord, PackageRouteRecord, PackageStore, ReadableEventStore,
10    RunSummary, StoreError, TimerEntry, TimerRetirement, WritableEventStore, WriteToken,
11};
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14
15use super::metrics::Metrics;
16
17/// A dispatched activity awaiting its worker result, tracked so the pairing
18/// terminal (completed / failed / cancelled) can be attributed to the right
19/// `activity_type` and dispatch→result duration.
20///
21/// The two labels the terminal metrics need (`activity_type` and the wall-clock
22/// duration) live ONLY on the `ActivityScheduled` event, never on the terminal
23/// events, so they are captured here at dispatch and consumed at the terminal.
24#[derive(Clone, Debug)]
25struct InflightActivity {
26    activity_type: String,
27    scheduled_at: DateTime<Utc>,
28}
29
30/// Event-store wrapper that observes operation latency and lifecycle events without changing engine crates.
31pub struct InstrumentedEventStore {
32    inner: Arc<dyn EventStore>,
33    metrics: Metrics,
34    namespace: String,
35    /// In-flight activity correlation for the observability-only
36    /// `aion_inflight_activities` gauge, the `aion_activities_*_total` counters,
37    /// and the `aion_activity_duration_seconds` histogram (AO-004 R2/R3, C13/C14).
38    ///
39    /// # Observability, NOT enforcement
40    ///
41    /// This map — and the gauge it feeds — is a per-process observability signal
42    /// with standard Prometheus gauge semantics: it resets on restart and is NOT
43    /// durable. It is deliberately SEPARATE from quota enforcement, which reads the
44    /// DURABLE Claimed outbox count (`count_claimed_outbox_rows*`) so a failover
45    /// survivor sees the correct in-flight count regardless of which process
46    /// dispatched the work (Control-Plane Phase 2, P2-Q2). The two must never be
47    /// conflated: this gauge is for dashboards/alerts, the durable count is the
48    /// enforcement source-of-truth (CONTROL-PLANE-PHASE-2 §8 divergence warning).
49    ///
50    /// # Pairing (no leak, no double-count)
51    ///
52    /// A row is inserted on `ActivityScheduled` (dispatch, the gauge increment) and
53    /// consumed on the FIRST terminal for that `(workflow_id, activity_id)` —
54    /// `ActivityCompleted`, terminal `ActivityFailed`, or `ActivityCancelled` — which
55    /// decrements the gauge exactly once. Because the decrement fires ONLY when a
56    /// matching in-flight entry is removed, a duplicate or unmatched terminal (e.g. a
57    /// re-driven append, or an interim retry failure with no live entry) is a
58    /// structural no-op: it can never drive the gauge below the true in-flight count.
59    /// Correlation state is keyed by the same `(workflow_id, activity_id)` history
60    /// uses, so it holds across the separate append batches that carry the schedule
61    /// and its terminal.
62    inflight: Mutex<HashMap<(WorkflowId, ActivityId), InflightActivity>>,
63    /// Advisory outbox wake (LSUB-2): pulsed when an `append_with_outbox` commits
64    /// a non-empty outbox-row batch, so the in-process [`OutboxDispatcher`] sweeps
65    /// promptly instead of waiting for its next poll tick. Body-less and
66    /// best-effort: the dispatcher's interval poll remains the correctness
67    /// backstop, so a lost wake only costs poll latency.
68    ///
69    /// [`OutboxDispatcher`]: crate::worker::OutboxDispatcher
70    outbox_wake: Arc<tokio::sync::Notify>,
71}
72
73impl InstrumentedEventStore {
74    /// Wrap an event store with server-side metrics.
75    ///
76    /// The store is given a private, never-pulsed outbox wake; callers that share
77    /// the engine's stage seam with the dispatcher install the shared handle with
78    /// [`Self::with_outbox_wake`].
79    #[must_use]
80    pub fn new(inner: Arc<dyn EventStore>, metrics: Metrics, namespace: impl Into<String>) -> Self {
81        Self {
82            inner,
83            metrics,
84            namespace: namespace.into(),
85            inflight: Mutex::new(HashMap::new()),
86            outbox_wake: Arc::new(tokio::sync::Notify::new()),
87        }
88    }
89
90    /// Install the shared advisory outbox wake (LSUB-2).
91    ///
92    /// The supplied `Notify` is the same handle the [`OutboxDispatcher`] awaits,
93    /// so a committed outbox-row batch wakes the dispatcher's run loop directly.
94    ///
95    /// [`OutboxDispatcher`]: crate::worker::OutboxDispatcher
96    #[must_use]
97    pub fn with_outbox_wake(mut self, outbox_wake: Arc<tokio::sync::Notify>) -> Self {
98        self.outbox_wake = outbox_wake;
99        self
100    }
101
102    fn record_events(&self, events: &[Event]) {
103        for event in events {
104            match event {
105                Event::WorkflowStarted { workflow_type, .. } => {
106                    self.metrics
107                        .workflow_started(&self.namespace, workflow_type.as_str());
108                }
109                Event::WorkflowCompleted { .. } => {
110                    self.metrics
111                        .workflow_completed(&self.namespace, "completed");
112                }
113                Event::WorkflowFailed { .. } => {
114                    self.metrics.workflow_completed(&self.namespace, "failed");
115                }
116                Event::WorkflowCancelled { .. } => {
117                    self.metrics
118                        .workflow_completed(&self.namespace, "cancelled");
119                }
120                Event::WorkflowTimedOut { .. } => {
121                    self.metrics
122                        .workflow_completed(&self.namespace, "timed_out");
123                }
124                Event::WorkflowContinuedAsNew { .. } => {
125                    self.metrics
126                        .workflow_completed(&self.namespace, "continued_as_new");
127                }
128                Event::WorkflowReopened { .. } => {
129                    self.metrics.workflow_reopened(&self.namespace);
130                }
131                Event::SignalReceived { .. } => {
132                    self.metrics.signal_delivered(&self.namespace, "resident");
133                }
134                Event::ScheduleTriggered { .. } => {
135                    self.metrics.schedule_fired(&self.namespace);
136                }
137                Event::ActivityScheduled {
138                    envelope,
139                    activity_id,
140                    activity_type,
141                    ..
142                } => {
143                    self.record_activity_dispatched(envelope, activity_id, activity_type);
144                }
145                Event::ActivityCompleted {
146                    envelope,
147                    activity_id,
148                    ..
149                } => {
150                    self.record_activity_terminal(envelope, activity_id, "succeeded");
151                }
152                Event::ActivityFailed {
153                    envelope,
154                    activity_id,
155                    ..
156                } => {
157                    self.record_activity_terminal(envelope, activity_id, "failed");
158                }
159                Event::ActivityCancelled {
160                    envelope,
161                    activity_id,
162                    ..
163                } => {
164                    self.record_activity_terminal(envelope, activity_id, "cancelled");
165                }
166                _ => {}
167            }
168        }
169    }
170
171    /// Record an activity dispatch: increment the dispatched counter and the
172    /// observability-only in-flight gauge, and remember the `activity_type` and
173    /// dispatch time so the pairing terminal can attribute the completion counter,
174    /// outcome, and duration (whose labels live only on this schedule event).
175    fn record_activity_dispatched(
176        &self,
177        envelope: &aion_core::EventEnvelope,
178        activity_id: &ActivityId,
179        activity_type: &str,
180    ) {
181        self.metrics
182            .activity_dispatched(&self.namespace, activity_type);
183        let key = (envelope.workflow_id.clone(), activity_id.clone());
184        let entry = InflightActivity {
185            activity_type: activity_type.to_owned(),
186            scheduled_at: envelope.recorded_at,
187        };
188        // A poisoned lock loses this correlation entry (worst case: one dropped
189        // gauge decrement); it must never panic the append path, so recover the
190        // guard rather than propagate the poison.
191        let mut inflight = self
192            .inflight
193            .lock()
194            .unwrap_or_else(std::sync::PoisonError::into_inner);
195        // A duplicate schedule for the same key (re-driven append) leaves the
196        // original dispatch time in place: the gauge was already incremented for
197        // the live entry, so we do NOT double-count the in-flight slot.
198        inflight.entry(key).or_insert(entry);
199    }
200
201    /// Record an activity terminal (completed / failed / cancelled). Consumes the
202    /// paired in-flight entry so the gauge decrement fires EXACTLY once per
203    /// dispatch: an unmatched or duplicate terminal finds no entry and is a no-op,
204    /// which structurally prevents the gauge from leaking or underflowing.
205    fn record_activity_terminal(
206        &self,
207        envelope: &aion_core::EventEnvelope,
208        activity_id: &ActivityId,
209        outcome: &str,
210    ) {
211        let key = (envelope.workflow_id.clone(), activity_id.clone());
212        let entry = {
213            let mut inflight = self
214                .inflight
215                .lock()
216                .unwrap_or_else(std::sync::PoisonError::into_inner);
217            inflight.remove(&key)
218        };
219        let Some(entry) = entry else {
220            // No live in-flight entry: an interim retry failure, a duplicate
221            // terminal, or a terminal whose schedule this process never observed.
222            // Skip entirely so the gauge is never decremented without a paired
223            // increment.
224            return;
225        };
226        let duration = (envelope.recorded_at - entry.scheduled_at)
227            .to_std()
228            .unwrap_or_default();
229        self.metrics
230            .activity_completed(&self.namespace, &entry.activity_type, outcome, duration);
231    }
232
233    fn observe_since(&self, operation: &str, started: Instant) {
234        self.metrics.store_operation(operation, started.elapsed());
235    }
236}
237
238impl std::fmt::Debug for InstrumentedEventStore {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.debug_struct("InstrumentedEventStore")
241            .field("namespace", &self.namespace)
242            .finish_non_exhaustive()
243    }
244}
245
246#[async_trait]
247impl WritableEventStore for InstrumentedEventStore {
248    async fn append(
249        &self,
250        token: WriteToken,
251        workflow_id: &WorkflowId,
252        events: &[Event],
253        expected_seq: u64,
254    ) -> Result<(), StoreError> {
255        let started = Instant::now();
256        let result = self
257            .inner
258            .append(token, workflow_id, events, expected_seq)
259            .await;
260        self.observe_since("append", started);
261        if result.is_ok() {
262            self.record_events(events);
263        }
264        result
265    }
266
267    /// Forward the atomic durable-outbox append to the inner store.
268    ///
269    /// The default trait method REFUSES a non-empty `outbox_rows` slice (to stop
270    /// an outbox-unaware backend silently dropping fan-out rows). Without this
271    /// override the engine — which writes through this decorator — would never
272    /// reach the inner haematite store's outbox-capable append, so a commissioned
273    /// (`outbox.enabled`) server could not stage a single fan-out member. We
274    /// delegate to the inner store so its atomicity guarantee (events + rows
275    /// commit together) holds, and observe the same `append` latency bucket and
276    /// lifecycle metrics as a plain append.
277    async fn append_with_outbox(
278        &self,
279        token: WriteToken,
280        workflow_id: &WorkflowId,
281        events: &[Event],
282        expected_seq: u64,
283        outbox_rows: &[OutboxRow],
284    ) -> Result<(), StoreError> {
285        let started = Instant::now();
286        let result = self
287            .inner
288            .append_with_outbox(token, workflow_id, events, expected_seq, outbox_rows)
289            .await;
290        self.observe_since("append", started);
291        if result.is_ok() {
292            self.record_events(events);
293            // LSUB-2 advisory wake: a successful commit that staged at least one
294            // outbox row pulses the dispatcher so it sweeps in ~RTT rather than on
295            // its next poll tick. Body-less and best-effort — `notify_one`
296            // coalesces, which is the desired advisory semantics: the dispatcher's
297            // poll is the correctness backstop, so a lost or merged wake only costs
298            // poll latency, never a dropped dispatch. Skip the wake when nothing was
299            // staged (no fan-out to dispatch) or the append failed (nothing
300            // committed).
301            if !outbox_rows.is_empty() {
302                self.outbox_wake.notify_one();
303            }
304        }
305        result
306    }
307
308    /// Forward the crash-recovery outbox re-arm to the inner store.
309    ///
310    /// As with [`Self::append_with_outbox`], the refusing default would strand a
311    /// recovered fan-out member because the engine re-arms through this decorator.
312    async fn rearm_outbox_pending(&self, rows: &[OutboxRow]) -> Result<(), StoreError> {
313        let started = Instant::now();
314        let result = self.inner.rearm_outbox_pending(rows).await;
315        self.observe_since("append", started);
316        result
317    }
318
319    /// Forward the fan-out cancellation settle to the inner store.
320    ///
321    /// MUST be forwarded: the trait default is a SILENT `Ok(())` no-op, so
322    /// without this override a cancelled fan-out ordinal's outbox row is never
323    /// settled on an `outbox.enabled` server — it stays claimable and the
324    /// dispatcher re-dispatches the cancelled activity (the same silent-default
325    /// forwarding hazard as the per-shard failover seam, #157). Timed under the
326    /// shared write bucket like the sibling outbox re-arm.
327    async fn settle_outbox_row_cancelled(&self, dispatch_key: &str) -> Result<(), StoreError> {
328        let started = Instant::now();
329        let result = self.inner.settle_outbox_row_cancelled(dispatch_key).await;
330        self.observe_since("append", started);
331        result
332    }
333
334    /// Forward the workflow-terminal outbox settle (#253) to the inner store.
335    ///
336    /// MUST be forwarded for the same reason as
337    /// [`Self::settle_outbox_row_cancelled`]: the trait default is a silent
338    /// empty-`Ok` no-op, and the Recorder settles a terminal workflow's rows
339    /// through this decorator — inheriting the default would leave a dead
340    /// workflow's rows claimable and redeliverable. Timed under the shared
341    /// write bucket like the sibling settle.
342    async fn settle_workflow_outbox_rows_cancelled(
343        &self,
344        workflow_id: &WorkflowId,
345    ) -> Result<Vec<String>, StoreError> {
346        let started = Instant::now();
347        let result = self
348            .inner
349            .settle_workflow_outbox_rows_cancelled(workflow_id)
350            .await;
351        self.observe_since("append", started);
352        result
353    }
354}
355
356#[async_trait]
357impl ReadableEventStore for InstrumentedEventStore {
358    /// Forward owned-shard scoping to the inner store; this decorator adds only
359    /// metrics, never shard policy, so the inner backend remains the sole
360    /// authority on enumeration scope.
361    fn set_owned_shards(&self, shards: Option<&[usize]>) {
362        self.inner.set_owned_shards(shards);
363    }
364
365    /// Forward the SS-2 shard election to the inner store; this decorator adds
366    /// only metrics, never ownership policy, so the inner backend runs the
367    /// election (or no-ops in single-node mode).
368    fn acquire_owned_shards(&self, shards: &[usize]) -> Result<(), StoreError> {
369        self.inner.acquire_owned_shards(shards)
370    }
371
372    /// Forward the per-shard (ADR-021 clean-partial) election to the inner store.
373    /// MUST be forwarded: the adoption fence (`Engine::adopt_shards`) drives the
374    /// SINGULAR per-shard seam, and the trait default is a silent no-op that would
375    /// let a survivor "adopt" a shard WITHOUT winning the election — its in-memory
376    /// live epoch is then never seeded, so every recovery write is fenced by the
377    /// surviving quorum and cross-node failover stalls (#157).
378    fn acquire_owned_shard(&self, shard: usize) -> Result<(), StoreError> {
379        self.inner.acquire_owned_shard(shard)
380    }
381
382    /// Forward the SS-5 failover scope-widening to the inner store; this
383    /// decorator adds only metrics, never ownership policy.
384    fn extend_owned_shards(&self, shards: &[usize]) {
385        self.inner.extend_owned_shards(shards);
386    }
387
388    /// Forward the residual-window ownership re-assertion (ADR-021). MUST be
389    /// forwarded: the trait default returns `true`, which would make the adoption
390    /// planner treat a shard it never actually won as a survivor (#157).
391    fn is_current_owner(&self, shard: usize) -> bool {
392        self.inner.is_current_owner(shard)
393    }
394
395    /// Forward the SS-3 shard-owner directory publish (fenced by the election just
396    /// won). MUST be forwarded: the trait default is a silent no-op, so a request
397    /// reaching a different survivor would mis-resolve to the dead declared owner
398    /// instead of this adopter (#157).
399    fn publish_shard_owner(&self, shard: usize) -> Result<(), StoreError> {
400        self.inner.publish_shard_owner(shard)
401    }
402
403    async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError> {
404        let started = Instant::now();
405        let result = self.inner.read_history(workflow_id).await;
406        self.observe_since("read_history", started);
407        result
408    }
409
410    async fn read_history_from(
411        &self,
412        workflow_id: &WorkflowId,
413        from_seq: u64,
414    ) -> Result<Vec<Event>, StoreError> {
415        let started = Instant::now();
416        let result = self.inner.read_history_from(workflow_id, from_seq).await;
417        self.observe_since("read_history_from", started);
418        result
419    }
420
421    async fn read_run_chain(
422        &self,
423        workflow_id: &WorkflowId,
424    ) -> Result<Vec<RunSummary>, StoreError> {
425        self.inner.read_run_chain(workflow_id).await
426    }
427
428    async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
429        let started = Instant::now();
430        let result = self.inner.list_workflow_ids().await;
431        self.observe_since("list_workflow_ids", started);
432        result
433    }
434
435    async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError> {
436        let started = Instant::now();
437        let result = self.inner.list_active().await;
438        self.observe_since("list_active", started);
439        result
440    }
441
442    async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError> {
443        let started = Instant::now();
444        let result = self.inner.list_paused().await;
445        self.observe_since("list_paused", started);
446        result
447    }
448    async fn stream_heads(&self) -> Result<Vec<aion_store::visibility::StreamHead>, StoreError> {
449        let started = Instant::now();
450        let result = self.inner.stream_heads().await;
451        self.observe_since("stream_heads", started);
452        result
453    }
454
455    async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError> {
456        self.inner.query(filter).await
457    }
458
459    async fn schedule_timer(
460        &self,
461        workflow_id: &WorkflowId,
462        timer_id: &TimerId,
463        fire_at: DateTime<Utc>,
464        armed_seq: u64,
465    ) -> Result<(), StoreError> {
466        self.inner
467            .schedule_timer(workflow_id, timer_id, fire_at, armed_seq)
468            .await
469    }
470
471    async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError> {
472        self.inner.expired_timers(as_of).await
473    }
474
475    async fn retire_timer(
476        &self,
477        workflow_id: &WorkflowId,
478        timer_id: &TimerId,
479        fire_at: DateTime<Utc>,
480        armed_seq: u64,
481    ) -> Result<TimerRetirement, StoreError> {
482        self.inner
483            .retire_timer(workflow_id, timer_id, fire_at, armed_seq)
484            .await
485    }
486}
487
488#[async_trait]
489impl PackageStore for InstrumentedEventStore {
490    async fn put_package(&self, record: PackageRecord) -> Result<(), StoreError> {
491        let started = Instant::now();
492        let result = self.inner.put_package(record).await;
493        self.observe_since("put_package", started);
494        result
495    }
496
497    async fn put_package_with_routes(
498        &self,
499        record: PackageRecord,
500        route_workflow_types: &[String],
501    ) -> Result<(), StoreError> {
502        let started = Instant::now();
503        let result = self
504            .inner
505            .put_package_with_routes(record, route_workflow_types)
506            .await;
507        self.observe_since("put_package_with_routes", started);
508        result
509    }
510
511    async fn list_packages(&self) -> Result<Vec<PackageRecord>, StoreError> {
512        let started = Instant::now();
513        let result = self.inner.list_packages().await;
514        self.observe_since("list_packages", started);
515        result
516    }
517
518    async fn delete_package(
519        &self,
520        workflow_type: &str,
521        content_hash: &str,
522    ) -> Result<(), StoreError> {
523        let started = Instant::now();
524        let result = self.inner.delete_package(workflow_type, content_hash).await;
525        self.observe_since("delete_package", started);
526        result
527    }
528
529    async fn put_package_route(
530        &self,
531        workflow_type: &str,
532        content_hash: &str,
533    ) -> Result<(), StoreError> {
534        let started = Instant::now();
535        let result = self
536            .inner
537            .put_package_route(workflow_type, content_hash)
538            .await;
539        self.observe_since("put_package_route", started);
540        result
541    }
542
543    async fn list_package_routes(&self) -> Result<Vec<PackageRouteRecord>, StoreError> {
544        let started = Instant::now();
545        let result = self.inner.list_package_routes().await;
546        self.observe_since("list_package_routes", started);
547        result
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use std::path::PathBuf;
554    use std::sync::Arc;
555    use std::time::{Duration, SystemTime, UNIX_EPOCH};
556
557    use aion_core::{
558        ActivityError, ActivityErrorKind, ActivityId, ContentType, Event, EventEnvelope,
559        PackageVersion, Payload, RunId, WorkflowId,
560    };
561    use aion_store::{OutboxRow, WritableEventStore, WriteToken};
562    use aion_store_haematite::HaematiteStore;
563    use chrono::Utc;
564
565    use super::InstrumentedEventStore;
566    use crate::observability::Metrics;
567
568    /// Envelope for a synthetic activity event owned by `workflow_id`.
569    fn envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
570        EventEnvelope {
571            seq,
572            recorded_at: Utc::now(),
573            workflow_id: workflow_id.clone(),
574        }
575    }
576
577    fn activity_scheduled(
578        workflow_id: &WorkflowId,
579        activity_id: &ActivityId,
580        activity_type: &str,
581    ) -> Event {
582        Event::ActivityScheduled {
583            envelope: envelope(workflow_id, 2),
584            activity_id: activity_id.clone(),
585            activity_type: activity_type.to_owned(),
586            input: Payload::new(ContentType::Json, b"{}".to_vec()),
587            task_queue: String::from("default"),
588            node: None,
589        }
590    }
591
592    fn activity_completed(workflow_id: &WorkflowId, activity_id: &ActivityId) -> Event {
593        Event::ActivityCompleted {
594            envelope: envelope(workflow_id, 3),
595            activity_id: activity_id.clone(),
596            result: Payload::new(ContentType::Json, b"{}".to_vec()),
597            attempt: 1,
598        }
599    }
600
601    fn activity_failed(workflow_id: &WorkflowId, activity_id: &ActivityId) -> Event {
602        Event::ActivityFailed {
603            envelope: envelope(workflow_id, 3),
604            activity_id: activity_id.clone(),
605            error: ActivityError {
606                kind: ActivityErrorKind::Terminal,
607                message: String::from("boom"),
608                details: None,
609            },
610            attempt: 1,
611        }
612    }
613
614    fn activity_cancelled(workflow_id: &WorkflowId, activity_id: &ActivityId) -> Event {
615        Event::ActivityCancelled {
616            envelope: envelope(workflow_id, 3),
617            activity_id: activity_id.clone(),
618            attempt: 1,
619        }
620    }
621
622    /// Build an instrumented store over a haematite backend in the given namespace,
623    /// returning the store and a clone of its metrics handle for assertions. Only
624    /// the metrics-recording seam is exercised, so the inner store is never
625    /// appended to in these unit tests.
626    async fn instrumented(
627        name: &str,
628        namespace: &str,
629    ) -> Result<(InstrumentedEventStore, Metrics), Box<dyn std::error::Error>> {
630        let store = Arc::new(
631            HaematiteStore::open_or_create(
632                unique_temp_path(name),
633                haematite::NodeCacheBudget::Unlimited,
634                // test-ruled patience: 250ms covers the measured 93-150ms fork window; not a default.
635            )
636            .await?,
637        );
638        let metrics = Metrics::new()?;
639        let instrumented = InstrumentedEventStore::new(store, metrics.clone(), namespace);
640        Ok((instrumented, metrics))
641    }
642
643    fn unique_temp_path(name: &str) -> PathBuf {
644        let nanos = SystemTime::now()
645            .duration_since(UNIX_EPOCH)
646            .map_or(0, |duration| duration.as_nanos());
647        std::env::temp_dir().join(format!(
648            "aion-server-instrumented-store-{name}-{}-{nanos}.db",
649            std::process::id()
650        ))
651    }
652
653    fn workflow_started(workflow_id: &WorkflowId) -> Event {
654        Event::WorkflowStarted {
655            envelope: EventEnvelope {
656                seq: 1,
657                recorded_at: Utc::now(),
658                workflow_id: workflow_id.clone(),
659            },
660            workflow_type: String::from("checkout"),
661            input: Payload::new(ContentType::Json, b"{}".to_vec()),
662            run_id: RunId::new_v4(),
663            parent_run_id: None,
664            parent_workflow_id: None,
665            package_version: PackageVersion::new("a".repeat(64)),
666        }
667    }
668
669    /// `notified()` resolves only if the wake has a stored permit (or one arrives);
670    /// returns whether it fired inside a short deadline.
671    async fn wake_fired(wake: &tokio::sync::Notify) -> bool {
672        tokio::time::timeout(Duration::from_millis(200), wake.notified())
673            .await
674            .is_ok()
675    }
676
677    /// Regression guard (#157): the decorator must FORWARD the singular per-shard
678    /// failover-seam methods to its inner store rather than silently inheriting
679    /// the `ReadableEventStore` no-op defaults. The spy returns sentinels distinct
680    /// from those defaults (`Err`/`false`) and records each call, so an unforwarded
681    /// method is caught by both the returned value and the missing recorded call.
682    #[tokio::test]
683    async fn forwards_per_shard_failover_seam_to_inner() -> Result<(), Box<dyn std::error::Error>> {
684        use aion_store::ReadableEventStore;
685        use aion_store::testing::ShardSeamSpy;
686
687        let spy = Arc::new(ShardSeamSpy::new());
688        let store = InstrumentedEventStore::new(
689            Arc::clone(&spy) as Arc<dyn aion_store::EventStore>,
690            Metrics::new()?,
691            "default",
692        );
693
694        assert!(
695            store.acquire_owned_shard(0).is_err(),
696            "acquire_owned_shard must forward to the spy's NotOwner sentinel, not the Ok(()) default"
697        );
698        assert!(
699            !store.is_current_owner(1),
700            "is_current_owner must forward to the spy's false, not the `true` default"
701        );
702        assert!(
703            store.publish_shard_owner(2).is_err(),
704            "publish_shard_owner must forward to the spy's NotOwner sentinel, not the Ok(()) default"
705        );
706
707        let calls = spy.calls();
708        assert!(
709            calls.contains(&"acquire_owned_shard:0".to_owned()),
710            "spy did not record acquire_owned_shard:0 — call was not forwarded; saw {calls:?}"
711        );
712        assert!(
713            calls.contains(&"is_current_owner:1".to_owned()),
714            "spy did not record is_current_owner:1 — call was not forwarded; saw {calls:?}"
715        );
716        assert!(
717            calls.contains(&"publish_shard_owner:2".to_owned()),
718            "spy did not record publish_shard_owner:2 — call was not forwarded; saw {calls:?}"
719        );
720
721        // The three PLURAL owned-shard seams have no value sentinel, so forwarding
722        // is proved by the recorded call alone — unguarded before this.
723        store.set_owned_shards(Some(&[3]));
724        assert!(
725            store.acquire_owned_shards(&[4]).is_ok(),
726            "acquire_owned_shards must forward to the spy's inner Ok(()), not error"
727        );
728        store.extend_owned_shards(&[5]);
729
730        let calls = spy.calls();
731        for expected in [
732            "set_owned_shards:Some([3])",
733            "acquire_owned_shards:[4]",
734            "extend_owned_shards:[5]",
735        ] {
736            assert!(
737                calls.contains(&expected.to_owned()),
738                "spy did not record {expected} — call was not forwarded; saw {calls:?}"
739            );
740        }
741        Ok(())
742    }
743
744    /// Regression guard (#157 family): the instrumented decorator must FORWARD
745    /// `settle_outbox_row_cancelled`; the trait default is a silent `Ok(())`
746    /// no-op, so a dropped forward strands a cancelled fan-out ordinal's outbox
747    /// row (stays claimable → the dispatcher re-dispatches the cancelled activity).
748    #[tokio::test]
749    async fn forwards_outbox_cancel_settle_to_inner() -> Result<(), Box<dyn std::error::Error>> {
750        use aion_store::testing::ShardSeamSpy;
751
752        let spy = Arc::new(ShardSeamSpy::new());
753        let store = InstrumentedEventStore::new(
754            Arc::clone(&spy) as Arc<dyn aion_store::EventStore>,
755            Metrics::new()?,
756            "default",
757        );
758
759        assert!(
760            store.settle_outbox_row_cancelled("wf-7").await.is_err(),
761            "settle must forward to the spy's Err sentinel, not the silent Ok(()) no-op default"
762        );
763        let calls = spy.calls();
764        assert!(
765            calls.contains(&"settle_outbox_row_cancelled:wf-7".to_owned()),
766            "spy did not record settle_outbox_row_cancelled — the decorator swallowed it; saw {calls:?}"
767        );
768
769        // Same hazard for the workflow-terminal settle (#253): the Recorder
770        // settles a terminal workflow's rows through this decorator, and the
771        // trait default is a silent empty-Ok no-op.
772        let workflow_id = WorkflowId::new_v4();
773        assert!(
774            store
775                .settle_workflow_outbox_rows_cancelled(&workflow_id)
776                .await
777                .is_err(),
778            "workflow settle must forward to the spy's Err sentinel, not the empty-Ok default"
779        );
780        let calls = spy.calls();
781        assert!(
782            calls.contains(&format!(
783                "settle_workflow_outbox_rows_cancelled:{workflow_id}"
784            )),
785            "spy did not record settle_workflow_outbox_rows_cancelled — the decorator swallowed \
786             it; saw {calls:?}"
787        );
788        Ok(())
789    }
790
791    /// LSUB-2 seam: a successful `append_with_outbox` carrying a non-empty outbox
792    /// slice pulses the shared advisory wake exactly once.
793    #[tokio::test]
794    async fn append_with_outbox_fires_wake_on_successful_non_empty_stage()
795    -> Result<(), Box<dyn std::error::Error>> {
796        let store = Arc::new(
797            HaematiteStore::open_or_create(
798                unique_temp_path("fires"),
799                haematite::NodeCacheBudget::Unlimited,
800                // test-ruled patience: 250ms covers the measured 93-150ms fork window; not a default.
801            )
802            .await?,
803        );
804        let metrics = Metrics::new()?;
805        let wake = Arc::new(tokio::sync::Notify::new());
806        let instrumented = InstrumentedEventStore::new(store, metrics, "default")
807            .with_outbox_wake(Arc::clone(&wake));
808
809        let workflow_id = WorkflowId::new_v4();
810        let event = workflow_started(&workflow_id);
811        let row = OutboxRow::pending(
812            workflow_id.clone(),
813            0,
814            String::from("charge"),
815            Payload::new(ContentType::Json, b"{}".to_vec()),
816            Utc::now(),
817        );
818        instrumented
819            .append_with_outbox(
820                WriteToken::recorder(),
821                &workflow_id,
822                std::slice::from_ref(&event),
823                0,
824                std::slice::from_ref(&row),
825            )
826            .await?;
827
828        assert!(
829            wake_fired(&wake).await,
830            "a successful non-empty outbox stage must pulse the advisory wake"
831        );
832        Ok(())
833    }
834
835    /// LSUB-2 seam: a successful append with an EMPTY outbox slice does NOT pulse
836    /// the wake — there is nothing for the dispatcher to sweep.
837    #[tokio::test]
838    async fn append_with_outbox_does_not_fire_wake_on_empty_slice()
839    -> Result<(), Box<dyn std::error::Error>> {
840        let store = Arc::new(
841            HaematiteStore::open_or_create(
842                unique_temp_path("empty"),
843                haematite::NodeCacheBudget::Unlimited,
844                // test-ruled patience: 250ms covers the measured 93-150ms fork window; not a default.
845            )
846            .await?,
847        );
848        let metrics = Metrics::new()?;
849        let wake = Arc::new(tokio::sync::Notify::new());
850        let instrumented = InstrumentedEventStore::new(store, metrics, "default")
851            .with_outbox_wake(Arc::clone(&wake));
852
853        let workflow_id = WorkflowId::new_v4();
854        let event = workflow_started(&workflow_id);
855        // Empty outbox slice: the override delegates to a plain append; no wake.
856        instrumented
857            .append_with_outbox(
858                WriteToken::recorder(),
859                &workflow_id,
860                std::slice::from_ref(&event),
861                0,
862                &[],
863            )
864            .await?;
865
866        assert!(
867            !wake_fired(&wake).await,
868            "an empty outbox slice must not pulse the wake (nothing to dispatch)"
869        );
870        Ok(())
871    }
872
873    /// LSUB-2 seam: a FAILED append (here a sequence conflict — wrong expected
874    /// head, so nothing commits) does NOT pulse the wake. Without a committed row
875    /// there is nothing to dispatch, so a wake would be a spurious sweep at best
876    /// and misleading at worst.
877    #[tokio::test]
878    async fn append_with_outbox_does_not_fire_wake_on_failed_append()
879    -> Result<(), Box<dyn std::error::Error>> {
880        let store = Arc::new(
881            HaematiteStore::open_or_create(
882                unique_temp_path("failed"),
883                haematite::NodeCacheBudget::Unlimited,
884                // test-ruled patience: 250ms covers the measured 93-150ms fork window; not a default.
885            )
886            .await?,
887        );
888        let metrics = Metrics::new()?;
889        let wake = Arc::new(tokio::sync::Notify::new());
890        let instrumented = InstrumentedEventStore::new(store, metrics, "default")
891            .with_outbox_wake(Arc::clone(&wake));
892
893        let workflow_id = WorkflowId::new_v4();
894        let event = workflow_started(&workflow_id);
895        let row = OutboxRow::pending(
896            workflow_id.clone(),
897            0,
898            String::from("charge"),
899            Payload::new(ContentType::Json, b"{}".to_vec()),
900            Utc::now(),
901        );
902        // expected_seq = 9 against an empty history is a sequence conflict: the
903        // append fails and nothing commits, so the wake must stay silent.
904        let result = instrumented
905            .append_with_outbox(
906                WriteToken::recorder(),
907                &workflow_id,
908                std::slice::from_ref(&event),
909                9,
910                std::slice::from_ref(&row),
911            )
912            .await;
913        assert!(result.is_err(), "the seq-conflict append must fail");
914
915        assert!(
916            !wake_fired(&wake).await,
917            "a failed append commits nothing, so it must not pulse the wake"
918        );
919        Ok(())
920    }
921
922    /// AO-004 C13/C14: dispatch (an `ActivityScheduled` event) increments the
923    /// dispatched counter and the in-flight gauge, both with the correct labels.
924    #[tokio::test]
925    async fn dispatch_increments_counter_and_gauge() -> Result<(), Box<dyn std::error::Error>> {
926        let (store, metrics) = instrumented("dispatch-inc", "tenant-a").await?;
927        let workflow_id = WorkflowId::new_v4();
928        let activity_id = ActivityId::from_sequence_position(0);
929
930        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
931
932        assert_eq!(
933            metrics.inflight_activities_value("tenant-a"),
934            1,
935            "dispatch must raise the in-flight gauge to 1"
936        );
937        assert_eq!(
938            metrics.activities_dispatched_value("tenant-a", "charge"),
939            1,
940            "dispatch must increment the dispatched counter for the activity type"
941        );
942        Ok(())
943    }
944
945    /// AO-004 C13/C14: a completed activity nets the in-flight gauge back to zero
946    /// and records the completion counter under the `succeeded` outcome, proving
947    /// the increment/decrement pairing balances.
948    #[tokio::test]
949    async fn completion_nets_gauge_to_zero_and_records_outcome()
950    -> Result<(), Box<dyn std::error::Error>> {
951        let (store, metrics) = instrumented("complete-net", "tenant-a").await?;
952        let workflow_id = WorkflowId::new_v4();
953        let activity_id = ActivityId::from_sequence_position(0);
954
955        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
956        assert_eq!(metrics.inflight_activities_value("tenant-a"), 1);
957
958        store.record_events(&[activity_completed(&workflow_id, &activity_id)]);
959
960        assert_eq!(
961            metrics.inflight_activities_value("tenant-a"),
962            0,
963            "a completed activity must net the in-flight gauge back to zero"
964        );
965        assert_eq!(
966            metrics.activities_completed_value("tenant-a", "succeeded"),
967            1,
968            "completion must record the succeeded outcome counter"
969        );
970        Ok(())
971    }
972
973    /// A terminal `ActivityFailed` is a completion for gauge purposes: it decrements
974    /// the in-flight gauge (no leak) and records the `failed` outcome.
975    #[tokio::test]
976    async fn failure_decrements_gauge_and_records_failed_outcome()
977    -> Result<(), Box<dyn std::error::Error>> {
978        let (store, metrics) = instrumented("fail-dec", "tenant-a").await?;
979        let workflow_id = WorkflowId::new_v4();
980        let activity_id = ActivityId::from_sequence_position(0);
981
982        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
983        store.record_events(&[activity_failed(&workflow_id, &activity_id)]);
984
985        assert_eq!(
986            metrics.inflight_activities_value("tenant-a"),
987            0,
988            "a terminal failure must decrement the in-flight gauge (no leak)"
989        );
990        assert_eq!(
991            metrics.activities_completed_value("tenant-a", "failed"),
992            1,
993            "a terminal failure must record the failed outcome counter"
994        );
995        Ok(())
996    }
997
998    /// A cancelled activity (the abandon/settle case) decrements the in-flight
999    /// gauge so a dispatched-but-cancelled activity does not leak a gauge slot.
1000    #[tokio::test]
1001    async fn cancellation_decrements_gauge_and_records_cancelled_outcome()
1002    -> Result<(), Box<dyn std::error::Error>> {
1003        let (store, metrics) = instrumented("cancel-dec", "tenant-a").await?;
1004        let workflow_id = WorkflowId::new_v4();
1005        let activity_id = ActivityId::from_sequence_position(0);
1006
1007        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
1008        store.record_events(&[activity_cancelled(&workflow_id, &activity_id)]);
1009
1010        assert_eq!(
1011            metrics.inflight_activities_value("tenant-a"),
1012            0,
1013            "a cancelled activity must decrement the in-flight gauge (no leak)"
1014        );
1015        assert_eq!(
1016            metrics.activities_completed_value("tenant-a", "cancelled"),
1017            1,
1018            "a cancelled activity must record the cancelled outcome counter"
1019        );
1020        Ok(())
1021    }
1022
1023    /// Pairing guard: a terminal with NO live in-flight entry (a duplicate
1024    /// terminal, or an interim retry failure whose schedule was already consumed)
1025    /// is a structural no-op — the gauge is NEVER driven below the true in-flight
1026    /// count, and no phantom completion is counted.
1027    #[tokio::test]
1028    async fn unmatched_terminal_never_underflows_gauge() -> Result<(), Box<dyn std::error::Error>> {
1029        let (store, metrics) = instrumented("no-underflow", "tenant-a").await?;
1030        let workflow_id = WorkflowId::new_v4();
1031        let activity_id = ActivityId::from_sequence_position(0);
1032
1033        // Dispatch two activities, complete one, then replay the SAME completion.
1034        let other = ActivityId::from_sequence_position(1);
1035        store.record_events(&[
1036            activity_scheduled(&workflow_id, &activity_id, "charge"),
1037            activity_scheduled(&workflow_id, &other, "charge"),
1038        ]);
1039        assert_eq!(metrics.inflight_activities_value("tenant-a"), 2);
1040
1041        store.record_events(&[activity_completed(&workflow_id, &activity_id)]);
1042        assert_eq!(metrics.inflight_activities_value("tenant-a"), 1);
1043
1044        // A duplicate terminal for an already-consumed activity must NOT decrement
1045        // again, so the one still-in-flight activity keeps the gauge at exactly 1.
1046        store.record_events(&[activity_completed(&workflow_id, &activity_id)]);
1047        assert_eq!(
1048            metrics.inflight_activities_value("tenant-a"),
1049            1,
1050            "a duplicate/unmatched terminal must not underflow the gauge"
1051        );
1052        assert_eq!(
1053            metrics.activities_completed_value("tenant-a", "succeeded"),
1054            1,
1055            "the duplicate terminal must not count a second completion"
1056        );
1057        Ok(())
1058    }
1059
1060    /// Isolation: the per-namespace gauge is keyed by namespace, so activity
1061    /// traffic in one tenant never moves another tenant's gauge — the same handle
1062    /// reports zero for a namespace with no dispatches.
1063    #[tokio::test]
1064    async fn gauge_is_isolated_per_namespace() -> Result<(), Box<dyn std::error::Error>> {
1065        let (store, metrics) = instrumented("iso", "tenant-a").await?;
1066        let workflow_id = WorkflowId::new_v4();
1067        let activity_id = ActivityId::from_sequence_position(0);
1068
1069        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
1070
1071        assert_eq!(metrics.inflight_activities_value("tenant-a"), 1);
1072        assert_eq!(
1073            metrics.inflight_activities_value("tenant-b"),
1074            0,
1075            "a namespace with no dispatches must read zero"
1076        );
1077        Ok(())
1078    }
1079}