1use 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#[derive(Clone, Debug)]
25struct InflightActivity {
26 activity_type: String,
27 scheduled_at: DateTime<Utc>,
28}
29
30pub struct InstrumentedEventStore {
32 inner: Arc<dyn EventStore>,
33 metrics: Metrics,
34 namespace: String,
35 inflight: Mutex<HashMap<(WorkflowId, ActivityId), InflightActivity>>,
63 outbox_wake: Arc<tokio::sync::Notify>,
71}
72
73impl InstrumentedEventStore {
74 #[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 #[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 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 let mut inflight = self
192 .inflight
193 .lock()
194 .unwrap_or_else(std::sync::PoisonError::into_inner);
195 inflight.entry(key).or_insert(entry);
199 }
200
201 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 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 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 if !outbox_rows.is_empty() {
302 self.outbox_wake.notify_one();
303 }
304 }
305 result
306 }
307
308 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 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 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 fn set_owned_shards(&self, shards: Option<&[usize]>) {
362 self.inner.set_owned_shards(shards);
363 }
364
365 fn acquire_owned_shards(&self, shards: &[usize]) -> Result<(), StoreError> {
369 self.inner.acquire_owned_shards(shards)
370 }
371
372 fn acquire_owned_shard(&self, shard: usize) -> Result<(), StoreError> {
379 self.inner.acquire_owned_shard(shard)
380 }
381
382 fn extend_owned_shards(&self, shards: &[usize]) {
385 self.inner.extend_owned_shards(shards);
386 }
387
388 fn is_current_owner(&self, shard: usize) -> bool {
392 self.inner.is_current_owner(shard)
393 }
394
395 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 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 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 )
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 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 #[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 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 #[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 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 #[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 )
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 #[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 )
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 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 #[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 )
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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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}