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, 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
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 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 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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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}