1use std::collections::hash_map::Entry;
4use std::collections::{HashMap, HashSet};
5use std::sync::{Arc, Mutex, MutexGuard};
6
7use aion_core::{ActivityId, RunId, WorkflowId};
8use sha2::{Digest, Sha256};
9use uuid::Uuid;
10
11use crate::error::{CompletionRejectionReason, ServerError};
12
13type ExecutionKey = (WorkflowId, ActivityId);
14
15#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct CompletionToken(String);
18
19impl CompletionToken {
20 pub fn from_wire(
26 workflow_id: &WorkflowId,
27 activity_id: &ActivityId,
28 value: String,
29 ) -> Result<Self, ServerError> {
30 if value.is_empty() {
31 return Err(rejection(
32 workflow_id,
33 activity_id,
34 CompletionRejectionReason::MissingCompletionToken,
35 ));
36 }
37 Ok(Self(value))
38 }
39
40 #[must_use]
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45
46 #[cfg(test)]
48 #[must_use]
49 pub(crate) fn for_test() -> Self {
50 Self("test-generation".to_owned())
51 }
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
64struct SiteGeneration {
65 idempotency_key: String,
71 attempt: u32,
74 outstanding: Vec<CompletionToken>,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct AcceptedGeneration(SiteGeneration);
88
89impl AcceptedGeneration {
90 #[must_use]
92 pub const fn attempt(&self) -> u32 {
93 self.0.attempt
94 }
95}
96
97#[derive(Clone, Debug, Default)]
103pub struct CompletionFences {
104 current: Arc<Mutex<HashMap<ExecutionKey, SiteGeneration>>>,
105 lease_gate: Arc<LeaseGate>,
113}
114
115#[derive(Debug, Default)]
116struct LeaseGate {
117 pending: Mutex<HashSet<String>>,
118 settled: tokio::sync::Notify,
119}
120
121impl CompletionFences {
122 pub fn issue(
145 &self,
146 workflow_id: &WorkflowId,
147 run_id: &RunId,
148 activity_id: &ActivityId,
149 attempt: u32,
150 ) -> Result<CompletionToken, ServerError> {
151 let token = CompletionToken(Uuid::new_v4().to_string());
152 let issued_key = idempotency_key(workflow_id, run_id, activity_id);
153 let mut state = self.state()?;
154 match state.entry((workflow_id.clone(), activity_id.clone())) {
155 Entry::Vacant(slot) => {
156 slot.insert(SiteGeneration {
157 idempotency_key: issued_key,
158 attempt,
159 outstanding: vec![token.clone()],
160 });
161 }
162 Entry::Occupied(mut slot) => {
163 let current = slot.get_mut();
164 if current.idempotency_key != issued_key || attempt > current.attempt {
165 *current = SiteGeneration {
166 idempotency_key: issued_key,
167 attempt,
168 outstanding: vec![token.clone()],
169 };
170 } else if attempt == current.attempt {
171 current.outstanding.push(token.clone());
172 } else {
173 tracing::warn!(
174 workflow_id = %workflow_id,
175 activity_id = %activity_id,
176 superseded_attempt = attempt,
177 current_attempt = current.attempt,
178 "re-dispatch of an already superseded attempt registers no generation; \
179 its completion will be refused as a stale generation"
180 );
181 }
182 }
183 }
184 Ok(token)
185 }
186
187 pub fn accept(
201 &self,
202 workflow_id: &WorkflowId,
203 activity_id: &ActivityId,
204 submitted: &CompletionToken,
205 ) -> Result<AcceptedGeneration, ServerError> {
206 self.wait_lease_settled_blocking(submitted)?;
207 let mut state = self.state()?;
208 let Entry::Occupied(slot) = state.entry((workflow_id.clone(), activity_id.clone())) else {
209 return Err(rejection(
210 workflow_id,
211 activity_id,
212 CompletionRejectionReason::NoCurrentGeneration,
213 ));
214 };
215 if !slot.get().outstanding.contains(submitted) {
216 return Err(rejection(
217 workflow_id,
218 activity_id,
219 CompletionRejectionReason::StaleGeneration,
220 ));
221 }
222 Ok(AcceptedGeneration(slot.remove()))
223 }
224
225 pub fn restore_if_absent(
234 &self,
235 workflow_id: &WorkflowId,
236 activity_id: &ActivityId,
237 accepted: &AcceptedGeneration,
238 ) -> Result<(), ServerError> {
239 self.state()?
240 .entry((workflow_id.clone(), activity_id.clone()))
241 .or_insert_with(|| accepted.0.clone());
242 Ok(())
243 }
244
245 pub fn revoke(
258 &self,
259 workflow_id: &WorkflowId,
260 activity_id: &ActivityId,
261 token: &CompletionToken,
262 ) -> Result<(), ServerError> {
263 let mut state = self.state()?;
264 let Entry::Occupied(mut slot) = state.entry((workflow_id.clone(), activity_id.clone()))
265 else {
266 return Ok(());
267 };
268 slot.get_mut().outstanding.retain(|issued| issued != token);
269 if slot.get().outstanding.is_empty() {
270 slot.remove();
271 }
272 Ok(())
273 }
274
275 pub fn revoke_current(
284 &self,
285 workflow_id: &WorkflowId,
286 activity_id: &ActivityId,
287 ) -> Result<(), ServerError> {
288 self.state()?
289 .remove(&(workflow_id.clone(), activity_id.clone()));
290 Ok(())
291 }
292
293 pub fn arm_lease(&self, token: &CompletionToken) -> Result<(), ServerError> {
300 self.gate()?.insert(token.as_str().to_owned());
301 Ok(())
302 }
303
304 pub fn settle_lease(&self, token: &CompletionToken) -> Result<(), ServerError> {
311 self.gate()?.remove(token.as_str());
312 self.lease_gate.settled.notify_waiters();
313 Ok(())
314 }
315
316 pub fn lease_pending(&self, token: &CompletionToken) -> Result<bool, ServerError> {
322 Ok(self.gate()?.contains(token.as_str()))
323 }
324
325 pub async fn lease_settled(&self, token: &CompletionToken) -> Result<(), ServerError> {
335 loop {
336 let notified = self.lease_gate.settled.notified();
339 if !self.lease_pending(token)? {
340 return Ok(());
341 }
342 notified.await;
343 }
344 }
345
346 fn wait_lease_settled_blocking(&self, token: &CompletionToken) -> Result<(), ServerError> {
353 if !self.lease_pending(token)? {
354 return Ok(());
355 }
356 if let Ok(handle) = tokio::runtime::Handle::try_current() {
357 return match handle.runtime_flavor() {
358 tokio::runtime::RuntimeFlavor::MultiThread => {
359 tokio::task::block_in_place(|| handle.block_on(self.lease_settled(token)))
360 }
361 flavor => {
362 tracing::warn!(
370 token = token.as_str(),
371 ?flavor,
372 "lease gate cannot be waited on synchronously inside this runtime; the \
373 async completion entry is relied on to have awaited it"
374 );
375 Ok(())
376 }
377 };
378 }
379 let runtime = tokio::runtime::Builder::new_current_thread()
380 .enable_all()
381 .build()
382 .map_err(|error| {
383 ServerError::worker_dispatch(
384 "",
385 "",
386 format!("lease gate wait could not build a runtime: {error}"),
387 )
388 })?;
389 runtime.block_on(self.lease_settled(token))
390 }
391
392 fn gate(&self) -> Result<MutexGuard<'_, HashSet<String>>, ServerError> {
393 self.lease_gate
394 .pending
395 .lock()
396 .map_err(|_| ServerError::lock_poisoned("activity lease gate"))
397 }
398
399 fn state(&self) -> Result<MutexGuard<'_, HashMap<ExecutionKey, SiteGeneration>>, ServerError> {
400 self.current
401 .lock()
402 .map_err(|_| ServerError::lock_poisoned("activity completion fences"))
403 }
404}
405
406#[must_use]
412pub fn idempotency_key(
413 workflow_id: &WorkflowId,
414 run_id: &RunId,
415 activity_id: &ActivityId,
416) -> String {
417 let mut hasher = Sha256::new();
418 hasher.update(b"aion.activity.idempotency.v1\0");
419 hasher.update(workflow_id.as_uuid().as_bytes());
420 hasher.update(run_id.as_uuid().as_bytes());
421 hasher.update(activity_id.sequence_position().to_be_bytes());
422 encode_hex(&hasher.finalize())
423}
424
425fn encode_hex(bytes: &[u8]) -> String {
426 const DIGITS: &[u8; 16] = b"0123456789abcdef";
427 let mut encoded = String::with_capacity(bytes.len() * 2);
428 for byte in bytes {
429 encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
430 encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
431 }
432 encoded
433}
434
435fn rejection(
436 workflow_id: &WorkflowId,
437 activity_id: &ActivityId,
438 reason: CompletionRejectionReason,
439) -> ServerError {
440 ServerError::ActivityCompletionRejected {
441 workflow_id: workflow_id.clone(),
442 activity_id: activity_id.clone(),
443 reason,
444 }
445}
446
447#[cfg(test)]
448mod tests {
449
450 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
454 async fn a_completion_racing_its_lease_is_accepted_after_the_lease_settles()
455 -> Result<(), ServerError> {
456 use std::sync::atomic::{AtomicU64, Ordering};
457 let fences = CompletionFences::default();
458 let workflow = WorkflowId::new(Uuid::new_v4());
459 let run = RunId::new(Uuid::new_v4());
460 let activity = ActivityId::from_sequence_position(1);
461 let token = fences.issue(&workflow, &run, &activity, 1)?;
462 fences.arm_lease(&token)?;
463 let clock = Arc::new(AtomicU64::new(0));
464 let lease_landed = Arc::new(AtomicU64::new(0));
465 let accepted_at = Arc::new(AtomicU64::new(0));
466 let racing = {
467 let fences = fences.clone();
468 let workflow = workflow.clone();
469 let activity = activity.clone();
470 let token = token.clone();
471 let clock = Arc::clone(&clock);
472 let accepted_at = Arc::clone(&accepted_at);
473 tokio::spawn(async move {
474 let accepted = fences.accept(&workflow, &activity, &token);
475 accepted_at.store(clock.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
476 accepted.map(|_| ())
477 })
478 };
479 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
481 assert_eq!(
482 accepted_at.load(Ordering::SeqCst),
483 0,
484 "the fence must not have accepted the completion while the lease was pending"
485 );
486 lease_landed.store(clock.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
487 fences.settle_lease(&token)?;
488 racing
489 .await
490 .map_err(|error| ServerError::worker_dispatch("", "", error.to_string()))??;
491 assert!(
492 accepted_at.load(Ordering::SeqCst) > lease_landed.load(Ordering::SeqCst),
493 "the completion was accepted before its lease landed: accepted at {} vs lease at {}",
494 accepted_at.load(Ordering::SeqCst),
495 lease_landed.load(Ordering::SeqCst)
496 );
497 Ok(())
498 }
499
500 #[tokio::test]
501 async fn an_unarmed_token_never_waits_and_a_settled_one_is_released() -> Result<(), ServerError>
502 {
503 let fences = CompletionFences::default();
504 let workflow = WorkflowId::new(Uuid::new_v4());
505 let run = RunId::new(Uuid::new_v4());
506 let activity = ActivityId::from_sequence_position(1);
507 let token = fences.issue(&workflow, &run, &activity, 1)?;
508 fences.lease_settled(&token).await?;
509 fences.arm_lease(&token)?;
510 assert!(fences.lease_pending(&token)?);
511 let waiter = {
512 let fences = fences.clone();
513 let token = token.clone();
514 tokio::spawn(async move { fences.lease_settled(&token).await })
515 };
516 fences.settle_lease(&token)?;
517 waiter
518 .await
519 .map_err(|error| ServerError::worker_dispatch("", "", error.to_string()))??;
520 assert!(!fences.lease_pending(&token)?);
521 Ok(())
522 }
523 use super::{CompletionFences, CompletionToken, idempotency_key};
524 use crate::error::{CompletionRejectionReason, ServerError};
525 use aion_core::{ActivityId, RunId, WorkflowId};
526 use std::sync::Arc;
527 use uuid::Uuid;
528
529 type TestResult = Result<(), Box<dyn std::error::Error>>;
530
531 #[test]
532 fn idempotency_key_is_attempt_independent_and_site_run_scoped() {
533 let workflow = WorkflowId::new_v4();
534 let run_a = RunId::new_v4();
535 let run_b = RunId::new_v4();
536 let site_a = ActivityId::from_sequence_position(7);
537 let site_b = ActivityId::from_sequence_position(8);
538
539 let first_attempt = idempotency_key(&workflow, &run_a, &site_a);
540 let fifth_attempt = idempotency_key(&workflow, &run_a, &site_a);
541 assert_eq!(first_attempt, fifth_attempt);
542 assert_ne!(first_attempt, idempotency_key(&workflow, &run_a, &site_b));
543 assert_ne!(first_attempt, idempotency_key(&workflow, &run_b, &site_a));
544 }
545
546 #[test]
550 fn issuing_a_retry_rejects_the_stale_generation() -> TestResult {
551 let fences = CompletionFences::default();
552 let workflow = WorkflowId::new_v4();
553 let run = RunId::new_v4();
554 let activity = ActivityId::from_sequence_position(3);
555 let stale = fences.issue(&workflow, &run, &activity, 1)?;
556 let current = fences.issue(&workflow, &run, &activity, 2)?;
557
558 let rejected = fences.accept(&workflow, &activity, &stale);
559 assert!(matches!(
560 rejected,
561 Err(ServerError::ActivityCompletionRejected {
562 reason: CompletionRejectionReason::StaleGeneration,
563 ..
564 })
565 ));
566 fences.accept(&workflow, &activity, ¤t)?;
567 Ok(())
568 }
569
570 #[test]
571 fn accepted_generation_is_consumed_exactly_once() -> TestResult {
572 let fences = CompletionFences::default();
573 let workflow = WorkflowId::new_v4();
574 let run = RunId::new_v4();
575 let activity = ActivityId::from_sequence_position(4);
576 let token = fences.issue(&workflow, &run, &activity, 1)?;
577
578 fences.accept(&workflow, &activity, &token)?;
579 let duplicate = fences.accept(&workflow, &activity, &token);
580 assert!(matches!(
581 duplicate,
582 Err(ServerError::ActivityCompletionRejected {
583 reason: CompletionRejectionReason::NoCurrentGeneration,
584 ..
585 })
586 ));
587 Ok(())
588 }
589
590 #[test]
595 fn revoking_an_old_generation_does_not_remove_its_replacement() -> TestResult {
596 let fences = CompletionFences::default();
597 let workflow = WorkflowId::new_v4();
598 let run = RunId::new_v4();
599 let activity = ActivityId::from_sequence_position(6);
600 let old = fences.issue(&workflow, &run, &activity, 1)?;
601 let replacement = fences.issue(&workflow, &run, &activity, 2)?;
602
603 fences.revoke(&workflow, &activity, &old)?;
604 fences.accept(&workflow, &activity, &replacement)?;
605 Ok(())
606 }
607
608 #[test]
609 fn an_empty_wire_token_is_a_typed_compatibility_refusal() {
610 let workflow = WorkflowId::new_v4();
611 let activity = ActivityId::from_sequence_position(9);
612 let rejected = CompletionToken::from_wire(&workflow, &activity, String::new());
613 assert!(matches!(
614 rejected,
615 Err(ServerError::ActivityCompletionRejected {
616 reason: CompletionRejectionReason::MissingCompletionToken,
617 ..
618 })
619 ));
620 }
621
622 #[test]
623 fn a_pre_recovery_generation_is_rejected_after_recovery() -> TestResult {
624 let before_recovery = CompletionFences::default();
625 let workflow = WorkflowId::new_v4();
626 let run = RunId::new_v4();
627 let activity = ActivityId::from_sequence_position(5);
628 let stale = before_recovery.issue(&workflow, &run, &activity, 1)?;
629
630 let after_recovery = CompletionFences::default();
631 let current = after_recovery.issue(&workflow, &run, &activity, 1)?;
632 let rejected = after_recovery.accept(&workflow, &activity, &stale);
633 assert!(matches!(
634 rejected,
635 Err(ServerError::ActivityCompletionRejected {
636 reason: CompletionRejectionReason::StaleGeneration,
637 ..
638 })
639 ));
640 after_recovery.accept(&workflow, &activity, ¤t)?;
641 Ok(())
642 }
643
644 #[test]
658 fn a_redelivery_of_the_same_attempt_does_not_orphan_the_first_worker() -> TestResult {
659 let fences = CompletionFences::default();
660 let workflow = WorkflowId::new_v4();
661 let run = RunId::new_v4();
662 let activity = ActivityId::from_sequence_position(11);
663
664 let first = fences.issue(&workflow, &run, &activity, 1)?;
665 let second = fences.issue(&workflow, &run, &activity, 1)?;
667
668 let accepted = fences.accept(&workflow, &activity, &first);
669 assert!(
670 accepted.is_ok(),
671 "the worker that genuinely held the FIRST delivery finished the work; its result \
672 must be accepted, not thrown away: {accepted:?}"
673 );
674
675 let duplicate = fences.accept(&workflow, &activity, &second);
676 assert!(
677 matches!(
678 duplicate,
679 Err(ServerError::ActivityCompletionRejected {
680 reason: CompletionRejectionReason::NoCurrentGeneration,
681 ..
682 })
683 ),
684 "the first accepted completion must consume EVERY outstanding token for the site, \
685 so the redelivered worker's completion is the duplicate: {duplicate:?}"
686 );
687 Ok(())
688 }
689
690 #[test]
700 fn the_all_streams_closed_revoke_cannot_orphan_a_redelivered_sibling() -> TestResult {
701 let fences = CompletionFences::default();
702 let workflow = WorkflowId::new_v4();
703 let run = RunId::new_v4();
704 let activity = ActivityId::from_sequence_position(12);
705
706 let delivered = fences.issue(&workflow, &run, &activity, 1)?;
707 let redelivery = fences.issue(&workflow, &run, &activity, 1)?;
708
709 fences.revoke(&workflow, &activity, &redelivery)?;
712
713 let accepted = fences.accept(&workflow, &activity, &delivered);
714 assert!(
715 accepted.is_ok(),
716 "the sibling token the first worker still holds must survive the redelivery's own \
717 revoke: {accepted:?}"
718 );
719
720 let withdrawn = fences.accept(&workflow, &activity, &redelivery);
721 assert!(
722 matches!(
723 withdrawn,
724 Err(ServerError::ActivityCompletionRejected {
725 reason: CompletionRejectionReason::NoCurrentGeneration,
726 ..
727 })
728 ),
729 "a revoked token must never become truth: {withdrawn:?}"
730 );
731 Ok(())
732 }
733
734 #[test]
741 fn a_superseded_attempt_is_still_refused_after_a_retry_is_issued() -> TestResult {
742 let fences = CompletionFences::default();
743 let workflow = WorkflowId::new_v4();
744 let run = RunId::new_v4();
745 let activity = ActivityId::from_sequence_position(13);
746
747 let earlier = fences.issue(&workflow, &run, &activity, 1)?;
748 let current = fences.issue(&workflow, &run, &activity, 2)?;
749
750 let refused = fences.accept(&workflow, &activity, &earlier);
751 assert!(
752 matches!(
753 refused,
754 Err(ServerError::ActivityCompletionRejected {
755 reason: CompletionRejectionReason::StaleGeneration,
756 ..
757 })
758 ),
759 "a stale worker can never be recorded as truth: attempt 1's worker was superseded by \
760 attempt 2 and its completion must stay refused: {refused:?}"
761 );
762 fences.accept(&workflow, &activity, ¤t)?;
763 Ok(())
764 }
765
766 #[test]
774 fn a_superseded_execution_generation_is_still_refused_at_the_same_attempt() -> TestResult {
775 let fences = CompletionFences::default();
776 let workflow = WorkflowId::new_v4();
777 let run_a = RunId::new_v4();
778 let run_b = RunId::new_v4();
779 let activity = ActivityId::from_sequence_position(14);
780
781 let old_run = fences.issue(&workflow, &run_a, &activity, 1)?;
782 let new_run = fences.issue(&workflow, &run_b, &activity, 1)?;
783
784 let refused = fences.accept(&workflow, &activity, &old_run);
785 assert!(
786 matches!(
787 refused,
788 Err(ServerError::ActivityCompletionRejected {
789 reason: CompletionRejectionReason::StaleGeneration,
790 ..
791 })
792 ),
793 "a stale worker can never be recorded as truth: the superseded RUN's worker holds \
794 attempt 1 of a generation that no longer exists: {refused:?}"
795 );
796 fences.accept(&workflow, &activity, &new_run)?;
797 Ok(())
798 }
799
800 #[test]
804 fn a_restored_generation_carries_its_redelivered_sibling_back() -> TestResult {
805 let fences = CompletionFences::default();
806 let workflow = WorkflowId::new_v4();
807 let run = RunId::new_v4();
808 let activity = ActivityId::from_sequence_position(15);
809
810 let delivered = fences.issue(&workflow, &run, &activity, 1)?;
811 let redelivery = fences.issue(&workflow, &run, &activity, 1)?;
812
813 let accepted = fences.accept(&workflow, &activity, &delivered)?;
814 assert_eq!(accepted.attempt(), 1);
815 fences.restore_if_absent(&workflow, &activity, &accepted)?;
816
817 fences.accept(&workflow, &activity, &redelivery)?;
819 Ok(())
820 }
821
822 #[test]
825 fn a_restore_never_displaces_a_newer_generation() -> TestResult {
826 let fences = CompletionFences::default();
827 let workflow = WorkflowId::new_v4();
828 let run = RunId::new_v4();
829 let activity = ActivityId::from_sequence_position(16);
830
831 let first = fences.issue(&workflow, &run, &activity, 1)?;
832 let accepted = fences.accept(&workflow, &activity, &first)?;
833 let retry = fences.issue(&workflow, &run, &activity, 2)?;
834
835 fences.restore_if_absent(&workflow, &activity, &accepted)?;
836
837 let refused = fences.accept(&workflow, &activity, &first);
838 assert!(
839 matches!(
840 refused,
841 Err(ServerError::ActivityCompletionRejected {
842 reason: CompletionRejectionReason::StaleGeneration,
843 ..
844 })
845 ),
846 "the restore must not displace the retry that was issued meanwhile: {refused:?}"
847 );
848 fences.accept(&workflow, &activity, &retry)?;
849 Ok(())
850 }
851
852 #[test]
856 fn re_issuing_a_superseded_attempt_registers_no_generation() -> TestResult {
857 let fences = CompletionFences::default();
858 let workflow = WorkflowId::new_v4();
859 let run = RunId::new_v4();
860 let activity = ActivityId::from_sequence_position(17);
861
862 let current = fences.issue(&workflow, &run, &activity, 2)?;
863 let out_of_order = fences.issue(&workflow, &run, &activity, 1)?;
864
865 let refused = fences.accept(&workflow, &activity, &out_of_order);
866 assert!(
867 matches!(
868 refused,
869 Err(ServerError::ActivityCompletionRejected {
870 reason: CompletionRejectionReason::StaleGeneration,
871 ..
872 })
873 ),
874 "an out-of-order re-dispatch of a superseded attempt must not become acceptable: \
875 {refused:?}"
876 );
877 fences.accept(&workflow, &activity, ¤t)?;
878 Ok(())
879 }
880
881 #[test]
885 fn only_one_of_two_sibling_tokens_can_ever_become_truth() -> TestResult {
886 let fences = CompletionFences::default();
887 let workflow = WorkflowId::new_v4();
888 let run = RunId::new_v4();
889 let activity = ActivityId::from_sequence_position(18);
890
891 let first = fences.issue(&workflow, &run, &activity, 1)?;
892 let second = fences.issue(&workflow, &run, &activity, 1)?;
893
894 let accepted = [
895 fences.accept(&workflow, &activity, &second).is_ok(),
896 fences.accept(&workflow, &activity, &first).is_ok(),
897 ];
898 assert_eq!(
899 accepted.iter().filter(|ok| **ok).count(),
900 1,
901 "exactly one completion for a redelivered attempt may become truth"
902 );
903 Ok(())
904 }
905}