Skip to main content

aion_server/worker/
envelope.rs

1//! Activity task-envelope generation and completion fencing.
2
3use 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/// Opaque proof that a worker owns one dispatched execution generation.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct CompletionToken(String);
18
19impl CompletionToken {
20    /// Parse a worker-echoed token without assigning meaning to its contents.
21    ///
22    /// # Errors
23    ///
24    /// Returns a typed compatibility refusal when a pre-fencing worker omits it.
25    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    /// Return the opaque wire representation.
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45
46    /// Build a non-wire token for crate-local unit fixtures.
47    #[cfg(test)]
48    #[must_use]
49    pub(crate) fn for_test() -> Self {
50        Self("test-generation".to_owned())
51    }
52}
53
54/// Every completion token authorized for ONE attempt of ONE execution
55/// generation of one execution site.
56///
57/// The vector holds more than one token in exactly one situation: the SAME
58/// attempt was delivered more than once, so two live workers each genuinely
59/// hold a token minted for the same work. Delivery is at-least-once, so that
60/// situation is ordinary rather than exceptional. Both tokens are acceptable
61/// candidates; the FIRST accepted completion consumes the whole generation, so
62/// the second worker's completion is the duplicate and is refused.
63#[derive(Clone, Debug, Eq, PartialEq)]
64struct SiteGeneration {
65    /// Run-scoped external-effect key of the generation these tokens belong to,
66    /// and the execution-generation discriminator. Not a second discriminator
67    /// invented here: it is the same [`idempotency_key`] the wire already
68    /// carries to the worker, so a reset or continue-as-new — which mints a new
69    /// [`RunId`] — is a different generation by construction.
70    idempotency_key: String,
71    /// One-based delivery attempt these tokens were issued for. A higher
72    /// attempt supersedes; an equal attempt is a redelivery of the same work.
73    attempt: u32,
74    /// Tokens issued for `attempt` that have been neither consumed by an
75    /// accepted completion nor revoked by the dispatch that issued them.
76    outstanding: Vec<CompletionToken>,
77}
78
79/// The generation one accepted completion consumed, handed back so a settlement
80/// that fails afterwards can restore exactly what was taken.
81///
82/// Restoring needs the `idempotency_key` and `attempt` that were consumed;
83/// re-deriving either at the restore site would be inventing a second
84/// execution-generation discriminator, so the consumed generation travels out
85/// with the acceptance instead.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct AcceptedGeneration(SiteGeneration);
88
89impl AcceptedGeneration {
90    /// One-based delivery attempt whose completion was accepted.
91    #[must_use]
92    pub const fn attempt(&self) -> u32 {
93        self.0.attempt
94    }
95}
96
97/// Process-incarnation registry of the only generations allowed to complete.
98///
99/// Recovery deliberately starts with an empty registry. Re-dispatch issues a
100/// fresh token, so a result carrying any pre-recovery token is refused whether
101/// it arrives before or after the new dispatch.
102#[derive(Clone, Debug, Default)]
103pub struct CompletionFences {
104    current: Arc<Mutex<HashMap<ExecutionKey, SiteGeneration>>>,
105    /// The lease gate (WA-010 R3): tokens whose `ActivityLeased` append is in
106    /// flight. A completion for a token that is still here waits at
107    /// [`Self::lease_settled`] before the fence accepts it, so a worker that
108    /// answers faster than the lease lands cannot put its terminal ahead of
109    /// its lease in the history. Armed per delivery attempt and settled by
110    /// the handoff either way, so nothing waits on a lease that will never be
111    /// recorded.
112    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    /// Authorize one more delivery of one attempt and return its token.
123    ///
124    /// One generation is held per execution site, and what a call is decides
125    /// what happens to it:
126    ///
127    /// - a different `idempotency_key` is a different EXECUTION GENERATION
128    ///   (reset / continue-as-new): the prior generation is superseded whole;
129    /// - a higher `attempt` within the same generation is a genuine RETRY: the
130    ///   prior attempt's tokens are superseded;
131    /// - the SAME attempt within the same generation is a REDELIVERY of work a
132    ///   worker may still be executing, so the new token JOINS the outstanding
133    ///   set rather than replacing it. Both workers hold a token that can be
134    ///   accepted, and the first accepted completion consumes both.
135    ///
136    /// A re-dispatch of an attempt that has already been superseded registers
137    /// nothing: it is logged with both attempt numbers, and the token it returns
138    /// is refused as a stale generation when presented, which is the truthful
139    /// answer for a worker whose attempt no longer exists.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`ServerError::LockPoisoned`] when fence state cannot be trusted.
144    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    /// Consume the current generation when `submitted` is one of the tokens it
188    /// still has outstanding.
189    ///
190    /// Comparison and consumption share one mutex critical section, so two
191    /// concurrent submissions cannot both become truth: the whole generation is
192    /// removed by the first, and every later presentation of any of its tokens
193    /// finds no generation at all.
194    ///
195    /// # Errors
196    ///
197    /// Returns a typed rejection for a missing generation or a token that is
198    /// not outstanding in it, or [`ServerError::LockPoisoned`] when fence state
199    /// cannot be trusted.
200    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    /// Restore a consumed generation after accepted-path settlement fails.
226    ///
227    /// A concurrently issued newer generation always wins; the accepted
228    /// generation is restored only while the execution site has no current one.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`ServerError::LockPoisoned`] when fence state cannot be trusted.
233    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    /// Revoke exactly the token this caller issued, and nothing else.
246    ///
247    /// A dispatch that could not place its task withdraws its OWN authorization.
248    /// It never withdraws a sibling token still held by a live worker executing
249    /// the same attempt, and it never disturbs a newer retry — a newer attempt
250    /// already replaced the outstanding set, so the old token is simply absent
251    /// and the removal is a no-op. The generation is dropped once its last
252    /// outstanding token is gone.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`ServerError::LockPoisoned`] when fence state cannot be trusted.
257    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    /// Revoke whichever generation is current while parking for recovery.
276    ///
277    /// A park retires the execution site deliberately, so it takes every token
278    /// of the current generation with it — including a redelivery's sibling.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`ServerError::LockPoisoned`] when fence state cannot be trusted.
283    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    /// Mark `token`'s lease append as in flight: a completion for it waits at
294    /// [`Self::lease_settled`] until [`Self::settle_lease`] runs.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`ServerError::LockPoisoned`] if the gate lock is poisoned.
299    pub fn arm_lease(&self, token: &CompletionToken) -> Result<(), ServerError> {
300        self.gate()?.insert(token.as_str().to_owned());
301        Ok(())
302    }
303
304    /// The lease append for `token` has landed, failed, or been abandoned —
305    /// release every completion waiting on it.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`ServerError::LockPoisoned`] if the gate lock is poisoned.
310    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    /// Whether `token`'s lease append is still in flight.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ServerError::LockPoisoned`] if the gate lock is poisoned.
321    pub fn lease_pending(&self, token: &CompletionToken) -> Result<bool, ServerError> {
322        Ok(self.gate()?.contains(token.as_str()))
323    }
324
325    /// Wait until `token`'s lease append is no longer in flight. Returns at
326    /// once for a token that was never armed or is already settled.
327    ///
328    /// The completion entry points await this before handing a result to the
329    /// fence; [`Self::accept`] also waits when it can host the wait itself.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`ServerError::LockPoisoned`] if the gate lock is poisoned.
334    pub async fn lease_settled(&self, token: &CompletionToken) -> Result<(), ServerError> {
335        loop {
336            // Register interest BEFORE the check, so a settle that lands
337            // between the check and the wait is not missed.
338            let notified = self.lease_gate.settled.notified();
339            if !self.lease_pending(token)? {
340                return Ok(());
341            }
342            notified.await;
343        }
344    }
345
346    /// [`Self::lease_settled`] from the synchronous fence: hosted with
347    /// `block_in_place` inside a multi-thread runtime; on a plain thread the
348    /// wait runs on a private current-thread runtime. Inside a current-thread
349    /// runtime nothing can host a blocking wait without starving the task that
350    /// settles the gate, so the fence relies on the async entry point having
351    /// awaited already and proceeds — and says so at debug level.
352    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                    // The one place the fence's promise thins. Said at WARN,
363                    // not DEBUG: current-thread is what `#[tokio::test]`
364                    // defaults to, so a test driving the sync entry could
365                    // otherwise pass green-by-construction with no visible
366                    // trace that the gate was never waited on. Not counted on
367                    // the lease-loss ledger: no lease record is lost here —
368                    // the ordering guarantee is what is being relied on.
369                    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/// Derive the stable external-effect key for one action site in one workflow run.
407///
408/// Attempts and execution generations are intentionally absent. The domain tag,
409/// workflow id, run id, and activity ordinal are length-unambiguous fixed-width
410/// inputs to SHA-256.
411#[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    /// WA-010 R3 ordering pin: a completion that arrives while its lease is
451    /// still being appended is accepted only AFTER the lease settles — the
452    /// fence hosts the wait itself on a multi-thread runtime.
453    #[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        // The completion is in flight; the lease "lands" now.
480        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    /// The FIRST attempt's token is refused once a genuine RETRY attempt has
547    /// been issued: c4e1412d7's invariant, by the attempt discriminator it put
548    /// on the envelope.
549    #[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, &current)?;
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    /// Revoking a superseded ATTEMPT's token never disturbs the retry that
591    /// replaced it. The same-attempt sibling case this name never claimed is
592    /// owned by
593    /// [`the_all_streams_closed_revoke_cannot_orphan_a_redelivered_sibling`].
594    #[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, &current)?;
641        Ok(())
642    }
643
644    /// FENCE-1 T1: a redelivery between delivery and completion does not orphan
645    /// the finished result.
646    ///
647    /// Delivery is at-least-once (`transport_loss.rs`: worker loss is
648    /// attempt-neutral and re-dispatches the SAME attempt), so a worker that is
649    /// alive and finishing can have its work delivered a second time. The first
650    /// worker still holds the first token and its result is real: it is
651    /// accepted. The redelivery's token is then the duplicate, and it is refused
652    /// because the first acceptance consumed the whole generation.
653    ///
654    /// On the base this reads `StaleGeneration` for the FIRST worker — the
655    /// finished result is thrown away, and nothing retries because the outbox
656    /// row settled `Done` at dispatch.
657    #[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        // The redelivery: the SAME attempt of the SAME run, dispatched again.
666        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    /// FENCE-1 T1b: the all-streams-closed revoke
691    /// (`dispatch.rs` `send_to_candidates`, immediately before `Ok(None)`)
692    /// cannot orphan a sibling still held by a live worker.
693    ///
694    /// This is the exact rocketfish 09:57:54Z interleaving: attempt A is
695    /// delivered and held, the redelivery B is issued for the same attempt,
696    /// every candidate stream is then found closed so B revokes its OWN token —
697    /// and on the base that removed the site's only key, so A's completion read
698    /// `NoCurrentGeneration` rather than `StaleGeneration`.
699    #[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        // Every candidate stream was closed, so the redelivery withdraws the
710        // authorization IT minted — and only that one.
711        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    /// FENCE-1 T2, half one: a superseded ATTEMPT is still refused.
735    ///
736    /// Regression pin, not a red-first test: it is green on the base too, for
737    /// the wrong reason (the base replaced the single slot on every issue). Its
738    /// value is that it stays green through the change, which is what says
739    /// c4e1412d7's invariant kept its name.
740    #[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, &current)?;
763        Ok(())
764    }
765
766    /// FENCE-1 T2, half two: the EXECUTION-GENERATION boundary is still
767    /// refused across, with identical attempt numbers on both sides.
768    ///
769    /// A reset or continue-as-new mints a new [`RunId`], so the run-scoped
770    /// idempotency key c4e1412d7 already derives is the only discriminator that
771    /// separates these two attempt-1 tokens. This is the case
772    /// acceptance-by-held-generation would be blind to if the key were not used.
773    #[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    /// A settlement that fails after acceptance restores the WHOLE generation,
801    /// siblings included, so the true resolver — whichever token it holds — is
802    /// not refused with `NoCurrentGeneration`.
803    #[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        // The true resolver presents the sibling token and is accepted.
818        fences.accept(&workflow, &activity, &redelivery)?;
819        Ok(())
820    }
821
822    /// A restore never overwrites a generation issued while the settlement was
823    /// in flight: a concurrently issued newer generation always wins.
824    #[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    /// A re-dispatch of an ALREADY superseded attempt registers nothing: it
853    /// cannot resurrect the attempt the engine has moved past, and the token it
854    /// hands back is refused when presented.
855    #[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, &current)?;
878        Ok(())
879    }
880
881    /// Two workers holding sibling tokens for one redelivered attempt: exactly
882    /// one becomes truth. The set of tokens that MAY be first is wider; how
883    /// many may be first is not.
884    #[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}