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, 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 libSQL 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
449    async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError> {
450        self.inner.query(filter).await
451    }
452
453    async fn schedule_timer(
454        &self,
455        workflow_id: &WorkflowId,
456        timer_id: &TimerId,
457        fire_at: DateTime<Utc>,
458    ) -> Result<(), StoreError> {
459        self.inner
460            .schedule_timer(workflow_id, timer_id, fire_at)
461            .await
462    }
463
464    async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError> {
465        self.inner.expired_timers(as_of).await
466    }
467}
468
469#[async_trait]
470impl PackageStore for InstrumentedEventStore {
471    async fn put_package(&self, record: PackageRecord) -> Result<(), StoreError> {
472        let started = Instant::now();
473        let result = self.inner.put_package(record).await;
474        self.observe_since("put_package", started);
475        result
476    }
477
478    async fn put_package_with_routes(
479        &self,
480        record: PackageRecord,
481        route_workflow_types: &[String],
482    ) -> Result<(), StoreError> {
483        let started = Instant::now();
484        let result = self
485            .inner
486            .put_package_with_routes(record, route_workflow_types)
487            .await;
488        self.observe_since("put_package_with_routes", started);
489        result
490    }
491
492    async fn list_packages(&self) -> Result<Vec<PackageRecord>, StoreError> {
493        let started = Instant::now();
494        let result = self.inner.list_packages().await;
495        self.observe_since("list_packages", started);
496        result
497    }
498
499    async fn delete_package(
500        &self,
501        workflow_type: &str,
502        content_hash: &str,
503    ) -> Result<(), StoreError> {
504        let started = Instant::now();
505        let result = self.inner.delete_package(workflow_type, content_hash).await;
506        self.observe_since("delete_package", started);
507        result
508    }
509
510    async fn put_package_route(
511        &self,
512        workflow_type: &str,
513        content_hash: &str,
514    ) -> Result<(), StoreError> {
515        let started = Instant::now();
516        let result = self
517            .inner
518            .put_package_route(workflow_type, content_hash)
519            .await;
520        self.observe_since("put_package_route", started);
521        result
522    }
523
524    async fn list_package_routes(&self) -> Result<Vec<PackageRouteRecord>, StoreError> {
525        let started = Instant::now();
526        let result = self.inner.list_package_routes().await;
527        self.observe_since("list_package_routes", started);
528        result
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use std::path::PathBuf;
535    use std::sync::Arc;
536    use std::time::{Duration, SystemTime, UNIX_EPOCH};
537
538    use aion_core::{
539        ActivityError, ActivityErrorKind, ActivityId, ContentType, Event, EventEnvelope,
540        PackageVersion, Payload, RunId, WorkflowId,
541    };
542    use aion_store::{OutboxRow, WritableEventStore, WriteToken};
543    use aion_store_libsql::LibSqlStore;
544    use chrono::Utc;
545
546    use super::InstrumentedEventStore;
547    use crate::observability::Metrics;
548
549    /// Envelope for a synthetic activity event owned by `workflow_id`.
550    fn envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
551        EventEnvelope {
552            seq,
553            recorded_at: Utc::now(),
554            workflow_id: workflow_id.clone(),
555        }
556    }
557
558    fn activity_scheduled(
559        workflow_id: &WorkflowId,
560        activity_id: &ActivityId,
561        activity_type: &str,
562    ) -> Event {
563        Event::ActivityScheduled {
564            envelope: envelope(workflow_id, 2),
565            activity_id: activity_id.clone(),
566            activity_type: activity_type.to_owned(),
567            input: Payload::new(ContentType::Json, b"{}".to_vec()),
568            task_queue: String::from("default"),
569            node: None,
570        }
571    }
572
573    fn activity_completed(workflow_id: &WorkflowId, activity_id: &ActivityId) -> Event {
574        Event::ActivityCompleted {
575            envelope: envelope(workflow_id, 3),
576            activity_id: activity_id.clone(),
577            result: Payload::new(ContentType::Json, b"{}".to_vec()),
578            attempt: 1,
579        }
580    }
581
582    fn activity_failed(workflow_id: &WorkflowId, activity_id: &ActivityId) -> Event {
583        Event::ActivityFailed {
584            envelope: envelope(workflow_id, 3),
585            activity_id: activity_id.clone(),
586            error: ActivityError {
587                kind: ActivityErrorKind::Terminal,
588                message: String::from("boom"),
589                details: None,
590            },
591            attempt: 1,
592        }
593    }
594
595    fn activity_cancelled(workflow_id: &WorkflowId, activity_id: &ActivityId) -> Event {
596        Event::ActivityCancelled {
597            envelope: envelope(workflow_id, 3),
598            activity_id: activity_id.clone(),
599            attempt: 1,
600        }
601    }
602
603    /// Build an instrumented store over a libSQL backend in the given namespace,
604    /// returning the store and a clone of its metrics handle for assertions. Only
605    /// the metrics-recording seam is exercised, so the inner store is never
606    /// appended to in these unit tests.
607    async fn instrumented(
608        name: &str,
609        namespace: &str,
610    ) -> Result<(InstrumentedEventStore, Metrics), Box<dyn std::error::Error>> {
611        let store = Arc::new(LibSqlStore::open(unique_temp_path(name)).await?);
612        let metrics = Metrics::new()?;
613        let instrumented = InstrumentedEventStore::new(store, metrics.clone(), namespace);
614        Ok((instrumented, metrics))
615    }
616
617    fn unique_temp_path(name: &str) -> PathBuf {
618        let nanos = SystemTime::now()
619            .duration_since(UNIX_EPOCH)
620            .map_or(0, |duration| duration.as_nanos());
621        std::env::temp_dir().join(format!(
622            "aion-server-instrumented-store-{name}-{}-{nanos}.db",
623            std::process::id()
624        ))
625    }
626
627    fn workflow_started(workflow_id: &WorkflowId) -> Event {
628        Event::WorkflowStarted {
629            envelope: EventEnvelope {
630                seq: 1,
631                recorded_at: Utc::now(),
632                workflow_id: workflow_id.clone(),
633            },
634            workflow_type: String::from("checkout"),
635            input: Payload::new(ContentType::Json, b"{}".to_vec()),
636            run_id: RunId::new_v4(),
637            parent_run_id: None,
638            package_version: PackageVersion::new("a".repeat(64)),
639        }
640    }
641
642    /// `notified()` resolves only if the wake has a stored permit (or one arrives);
643    /// returns whether it fired inside a short deadline.
644    async fn wake_fired(wake: &tokio::sync::Notify) -> bool {
645        tokio::time::timeout(Duration::from_millis(200), wake.notified())
646            .await
647            .is_ok()
648    }
649
650    /// Regression guard (#157): the decorator must FORWARD the singular per-shard
651    /// failover-seam methods to its inner store rather than silently inheriting
652    /// the `ReadableEventStore` no-op defaults. The spy returns sentinels distinct
653    /// from those defaults (`Err`/`false`) and records each call, so an unforwarded
654    /// method is caught by both the returned value and the missing recorded call.
655    #[tokio::test]
656    async fn forwards_per_shard_failover_seam_to_inner() -> Result<(), Box<dyn std::error::Error>> {
657        use aion_store::ReadableEventStore;
658        use aion_store::testing::ShardSeamSpy;
659
660        let spy = Arc::new(ShardSeamSpy::new());
661        let store = InstrumentedEventStore::new(
662            Arc::clone(&spy) as Arc<dyn aion_store::EventStore>,
663            Metrics::new()?,
664            "default",
665        );
666
667        assert!(
668            store.acquire_owned_shard(0).is_err(),
669            "acquire_owned_shard must forward to the spy's NotOwner sentinel, not the Ok(()) default"
670        );
671        assert!(
672            !store.is_current_owner(1),
673            "is_current_owner must forward to the spy's false, not the `true` default"
674        );
675        assert!(
676            store.publish_shard_owner(2).is_err(),
677            "publish_shard_owner must forward to the spy's NotOwner sentinel, not the Ok(()) default"
678        );
679
680        let calls = spy.calls();
681        assert!(
682            calls.contains(&"acquire_owned_shard:0".to_owned()),
683            "spy did not record acquire_owned_shard:0 — call was not forwarded; saw {calls:?}"
684        );
685        assert!(
686            calls.contains(&"is_current_owner:1".to_owned()),
687            "spy did not record is_current_owner:1 — call was not forwarded; saw {calls:?}"
688        );
689        assert!(
690            calls.contains(&"publish_shard_owner:2".to_owned()),
691            "spy did not record publish_shard_owner:2 — call was not forwarded; saw {calls:?}"
692        );
693
694        // The three PLURAL owned-shard seams have no value sentinel, so forwarding
695        // is proved by the recorded call alone — unguarded before this.
696        store.set_owned_shards(Some(&[3]));
697        assert!(
698            store.acquire_owned_shards(&[4]).is_ok(),
699            "acquire_owned_shards must forward to the spy's inner Ok(()), not error"
700        );
701        store.extend_owned_shards(&[5]);
702
703        let calls = spy.calls();
704        for expected in [
705            "set_owned_shards:Some([3])",
706            "acquire_owned_shards:[4]",
707            "extend_owned_shards:[5]",
708        ] {
709            assert!(
710                calls.contains(&expected.to_owned()),
711                "spy did not record {expected} — call was not forwarded; saw {calls:?}"
712            );
713        }
714        Ok(())
715    }
716
717    /// Regression guard (#157 family): the instrumented decorator must FORWARD
718    /// `settle_outbox_row_cancelled`; the trait default is a silent `Ok(())`
719    /// no-op, so a dropped forward strands a cancelled fan-out ordinal's outbox
720    /// row (stays claimable → the dispatcher re-dispatches the cancelled activity).
721    #[tokio::test]
722    async fn forwards_outbox_cancel_settle_to_inner() -> Result<(), Box<dyn std::error::Error>> {
723        use aion_store::testing::ShardSeamSpy;
724
725        let spy = Arc::new(ShardSeamSpy::new());
726        let store = InstrumentedEventStore::new(
727            Arc::clone(&spy) as Arc<dyn aion_store::EventStore>,
728            Metrics::new()?,
729            "default",
730        );
731
732        assert!(
733            store.settle_outbox_row_cancelled("wf-7").await.is_err(),
734            "settle must forward to the spy's Err sentinel, not the silent Ok(()) no-op default"
735        );
736        let calls = spy.calls();
737        assert!(
738            calls.contains(&"settle_outbox_row_cancelled:wf-7".to_owned()),
739            "spy did not record settle_outbox_row_cancelled — the decorator swallowed it; saw {calls:?}"
740        );
741
742        // Same hazard for the workflow-terminal settle (#253): the Recorder
743        // settles a terminal workflow's rows through this decorator, and the
744        // trait default is a silent empty-Ok no-op.
745        let workflow_id = WorkflowId::new_v4();
746        assert!(
747            store
748                .settle_workflow_outbox_rows_cancelled(&workflow_id)
749                .await
750                .is_err(),
751            "workflow settle must forward to the spy's Err sentinel, not the empty-Ok default"
752        );
753        let calls = spy.calls();
754        assert!(
755            calls.contains(&format!(
756                "settle_workflow_outbox_rows_cancelled:{workflow_id}"
757            )),
758            "spy did not record settle_workflow_outbox_rows_cancelled — the decorator swallowed \
759             it; saw {calls:?}"
760        );
761        Ok(())
762    }
763
764    /// LSUB-2 seam: a successful `append_with_outbox` carrying a non-empty outbox
765    /// slice pulses the shared advisory wake exactly once.
766    #[tokio::test]
767    async fn append_with_outbox_fires_wake_on_successful_non_empty_stage()
768    -> Result<(), Box<dyn std::error::Error>> {
769        let store = Arc::new(LibSqlStore::open(unique_temp_path("fires")).await?);
770        let metrics = Metrics::new()?;
771        let wake = Arc::new(tokio::sync::Notify::new());
772        let instrumented = InstrumentedEventStore::new(store, metrics, "default")
773            .with_outbox_wake(Arc::clone(&wake));
774
775        let workflow_id = WorkflowId::new_v4();
776        let event = workflow_started(&workflow_id);
777        let row = OutboxRow::pending(
778            workflow_id.clone(),
779            0,
780            String::from("charge"),
781            Payload::new(ContentType::Json, b"{}".to_vec()),
782            Utc::now(),
783        );
784        instrumented
785            .append_with_outbox(
786                WriteToken::recorder(),
787                &workflow_id,
788                std::slice::from_ref(&event),
789                0,
790                std::slice::from_ref(&row),
791            )
792            .await?;
793
794        assert!(
795            wake_fired(&wake).await,
796            "a successful non-empty outbox stage must pulse the advisory wake"
797        );
798        Ok(())
799    }
800
801    /// LSUB-2 seam: a successful append with an EMPTY outbox slice does NOT pulse
802    /// the wake — there is nothing for the dispatcher to sweep.
803    #[tokio::test]
804    async fn append_with_outbox_does_not_fire_wake_on_empty_slice()
805    -> Result<(), Box<dyn std::error::Error>> {
806        let store = Arc::new(LibSqlStore::open(unique_temp_path("empty")).await?);
807        let metrics = Metrics::new()?;
808        let wake = Arc::new(tokio::sync::Notify::new());
809        let instrumented = InstrumentedEventStore::new(store, metrics, "default")
810            .with_outbox_wake(Arc::clone(&wake));
811
812        let workflow_id = WorkflowId::new_v4();
813        let event = workflow_started(&workflow_id);
814        // Empty outbox slice: the override delegates to a plain append; no wake.
815        instrumented
816            .append_with_outbox(
817                WriteToken::recorder(),
818                &workflow_id,
819                std::slice::from_ref(&event),
820                0,
821                &[],
822            )
823            .await?;
824
825        assert!(
826            !wake_fired(&wake).await,
827            "an empty outbox slice must not pulse the wake (nothing to dispatch)"
828        );
829        Ok(())
830    }
831
832    /// LSUB-2 seam: a FAILED append (here a sequence conflict — wrong expected
833    /// head, so nothing commits) does NOT pulse the wake. Without a committed row
834    /// there is nothing to dispatch, so a wake would be a spurious sweep at best
835    /// and misleading at worst.
836    #[tokio::test]
837    async fn append_with_outbox_does_not_fire_wake_on_failed_append()
838    -> Result<(), Box<dyn std::error::Error>> {
839        let store = Arc::new(LibSqlStore::open(unique_temp_path("failed")).await?);
840        let metrics = Metrics::new()?;
841        let wake = Arc::new(tokio::sync::Notify::new());
842        let instrumented = InstrumentedEventStore::new(store, metrics, "default")
843            .with_outbox_wake(Arc::clone(&wake));
844
845        let workflow_id = WorkflowId::new_v4();
846        let event = workflow_started(&workflow_id);
847        let row = OutboxRow::pending(
848            workflow_id.clone(),
849            0,
850            String::from("charge"),
851            Payload::new(ContentType::Json, b"{}".to_vec()),
852            Utc::now(),
853        );
854        // expected_seq = 9 against an empty history is a sequence conflict: the
855        // append fails and nothing commits, so the wake must stay silent.
856        let result = instrumented
857            .append_with_outbox(
858                WriteToken::recorder(),
859                &workflow_id,
860                std::slice::from_ref(&event),
861                9,
862                std::slice::from_ref(&row),
863            )
864            .await;
865        assert!(result.is_err(), "the seq-conflict append must fail");
866
867        assert!(
868            !wake_fired(&wake).await,
869            "a failed append commits nothing, so it must not pulse the wake"
870        );
871        Ok(())
872    }
873
874    /// AO-004 C13/C14: dispatch (an `ActivityScheduled` event) increments the
875    /// dispatched counter and the in-flight gauge, both with the correct labels.
876    #[tokio::test]
877    async fn dispatch_increments_counter_and_gauge() -> Result<(), Box<dyn std::error::Error>> {
878        let (store, metrics) = instrumented("dispatch-inc", "tenant-a").await?;
879        let workflow_id = WorkflowId::new_v4();
880        let activity_id = ActivityId::from_sequence_position(0);
881
882        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
883
884        assert_eq!(
885            metrics.inflight_activities_value("tenant-a"),
886            1,
887            "dispatch must raise the in-flight gauge to 1"
888        );
889        assert_eq!(
890            metrics.activities_dispatched_value("tenant-a", "charge"),
891            1,
892            "dispatch must increment the dispatched counter for the activity type"
893        );
894        Ok(())
895    }
896
897    /// AO-004 C13/C14: a completed activity nets the in-flight gauge back to zero
898    /// and records the completion counter under the `succeeded` outcome, proving
899    /// the increment/decrement pairing balances.
900    #[tokio::test]
901    async fn completion_nets_gauge_to_zero_and_records_outcome()
902    -> Result<(), Box<dyn std::error::Error>> {
903        let (store, metrics) = instrumented("complete-net", "tenant-a").await?;
904        let workflow_id = WorkflowId::new_v4();
905        let activity_id = ActivityId::from_sequence_position(0);
906
907        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
908        assert_eq!(metrics.inflight_activities_value("tenant-a"), 1);
909
910        store.record_events(&[activity_completed(&workflow_id, &activity_id)]);
911
912        assert_eq!(
913            metrics.inflight_activities_value("tenant-a"),
914            0,
915            "a completed activity must net the in-flight gauge back to zero"
916        );
917        assert_eq!(
918            metrics.activities_completed_value("tenant-a", "succeeded"),
919            1,
920            "completion must record the succeeded outcome counter"
921        );
922        Ok(())
923    }
924
925    /// A terminal `ActivityFailed` is a completion for gauge purposes: it decrements
926    /// the in-flight gauge (no leak) and records the `failed` outcome.
927    #[tokio::test]
928    async fn failure_decrements_gauge_and_records_failed_outcome()
929    -> Result<(), Box<dyn std::error::Error>> {
930        let (store, metrics) = instrumented("fail-dec", "tenant-a").await?;
931        let workflow_id = WorkflowId::new_v4();
932        let activity_id = ActivityId::from_sequence_position(0);
933
934        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
935        store.record_events(&[activity_failed(&workflow_id, &activity_id)]);
936
937        assert_eq!(
938            metrics.inflight_activities_value("tenant-a"),
939            0,
940            "a terminal failure must decrement the in-flight gauge (no leak)"
941        );
942        assert_eq!(
943            metrics.activities_completed_value("tenant-a", "failed"),
944            1,
945            "a terminal failure must record the failed outcome counter"
946        );
947        Ok(())
948    }
949
950    /// A cancelled activity (the abandon/settle case) decrements the in-flight
951    /// gauge so a dispatched-but-cancelled activity does not leak a gauge slot.
952    #[tokio::test]
953    async fn cancellation_decrements_gauge_and_records_cancelled_outcome()
954    -> Result<(), Box<dyn std::error::Error>> {
955        let (store, metrics) = instrumented("cancel-dec", "tenant-a").await?;
956        let workflow_id = WorkflowId::new_v4();
957        let activity_id = ActivityId::from_sequence_position(0);
958
959        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
960        store.record_events(&[activity_cancelled(&workflow_id, &activity_id)]);
961
962        assert_eq!(
963            metrics.inflight_activities_value("tenant-a"),
964            0,
965            "a cancelled activity must decrement the in-flight gauge (no leak)"
966        );
967        assert_eq!(
968            metrics.activities_completed_value("tenant-a", "cancelled"),
969            1,
970            "a cancelled activity must record the cancelled outcome counter"
971        );
972        Ok(())
973    }
974
975    /// Pairing guard: a terminal with NO live in-flight entry (a duplicate
976    /// terminal, or an interim retry failure whose schedule was already consumed)
977    /// is a structural no-op — the gauge is NEVER driven below the true in-flight
978    /// count, and no phantom completion is counted.
979    #[tokio::test]
980    async fn unmatched_terminal_never_underflows_gauge() -> Result<(), Box<dyn std::error::Error>> {
981        let (store, metrics) = instrumented("no-underflow", "tenant-a").await?;
982        let workflow_id = WorkflowId::new_v4();
983        let activity_id = ActivityId::from_sequence_position(0);
984
985        // Dispatch two activities, complete one, then replay the SAME completion.
986        let other = ActivityId::from_sequence_position(1);
987        store.record_events(&[
988            activity_scheduled(&workflow_id, &activity_id, "charge"),
989            activity_scheduled(&workflow_id, &other, "charge"),
990        ]);
991        assert_eq!(metrics.inflight_activities_value("tenant-a"), 2);
992
993        store.record_events(&[activity_completed(&workflow_id, &activity_id)]);
994        assert_eq!(metrics.inflight_activities_value("tenant-a"), 1);
995
996        // A duplicate terminal for an already-consumed activity must NOT decrement
997        // again, so the one still-in-flight activity keeps the gauge at exactly 1.
998        store.record_events(&[activity_completed(&workflow_id, &activity_id)]);
999        assert_eq!(
1000            metrics.inflight_activities_value("tenant-a"),
1001            1,
1002            "a duplicate/unmatched terminal must not underflow the gauge"
1003        );
1004        assert_eq!(
1005            metrics.activities_completed_value("tenant-a", "succeeded"),
1006            1,
1007            "the duplicate terminal must not count a second completion"
1008        );
1009        Ok(())
1010    }
1011
1012    /// Isolation: the per-namespace gauge is keyed by namespace, so activity
1013    /// traffic in one tenant never moves another tenant's gauge — the same handle
1014    /// reports zero for a namespace with no dispatches.
1015    #[tokio::test]
1016    async fn gauge_is_isolated_per_namespace() -> Result<(), Box<dyn std::error::Error>> {
1017        let (store, metrics) = instrumented("iso", "tenant-a").await?;
1018        let workflow_id = WorkflowId::new_v4();
1019        let activity_id = ActivityId::from_sequence_position(0);
1020
1021        store.record_events(&[activity_scheduled(&workflow_id, &activity_id, "charge")]);
1022
1023        assert_eq!(metrics.inflight_activities_value("tenant-a"), 1);
1024        assert_eq!(
1025            metrics.inflight_activities_value("tenant-b"),
1026            0,
1027            "a namespace with no dispatches must read zero"
1028        );
1029        Ok(())
1030    }
1031}