Skip to main content

aion_server/worker/
lease_record.rs

1//! The lease-record seam (WA-010 R3): the one call that turns a transport's
2//! "the worker took it" into the durable [`aion_core::Event::ActivityLeased`]
3//! fact, on every transport and on both dispatch paths.
4//!
5//! # Where the call sits
6//!
7//! A transport arm calls [`DeliveryAccepted::accepted`] at the instant it knows
8//! the worker HOLDS the attempt — the gRPC stream accepted the frame; the
9//! liminal push was acknowledged — and BEFORE it waits for any reply. That
10//! placement is the whole of the ordering guarantee on the liminal outbox arm,
11//! where the reply IS the completion: recorded after the wait, the lease would
12//! land after the terminal it is supposed to precede.
13//!
14//! On the gRPC arm the completion arrives on another task, so the placement
15//! alone cannot order the two appends. [`LeaseHandoff`] therefore arms the
16//! completion fences' lease gate before the push and settles it after the
17//! record lands (or fails, or the delivery is abandoned — the guard settles on
18//! drop), and the completion entry waits on that gate before the fence accepts
19//! it. See [`CompletionFences::lease_settled`].
20//!
21//! # What a failed record means
22//!
23//! An append that fails cannot write its own failure into the history. The
24//! ERROR line and [`LeaseRecordLedger`] (exported as
25//! `aion_activity_lease_record_failures_total` and read back on the API
26//! provenance, R4) are the ONLY record that this install knew the worker and
27//! lost the fact — as opposed to a history written before the event existed,
28//! which never knew. A failed record never fails or repeats the dispatch: the
29//! worker holds the work, and the history simply reads that attempt as
30//! unattributed.
31//!
32//! An UNINSTALLED seam is the same loss by a different route — a boot wired a
33//! dispatcher without handing it a recorder — so it is counted on the ledger
34//! and said out loud, never silently skipped.
35
36use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
37use std::sync::{Arc, OnceLock};
38
39use aion_core::{ActivityId, RunId, WorkerAttribution, WorkflowId};
40use async_trait::async_trait;
41
42use super::envelope::{CompletionFences, CompletionToken};
43use super::registry::WorkerHandle;
44use super::task_delivery::DeliveryAccepted;
45use crate::error::ServerError;
46use crate::observability::Metrics;
47
48/// The attempt a lease is recorded for.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct LeaseKey {
51    /// Owning workflow.
52    pub workflow_id: WorkflowId,
53    /// The run whose history carries the lease.
54    pub run_id: RunId,
55    /// The activity the attempt belongs to.
56    pub activity_id: ActivityId,
57    /// One-based delivery attempt, as stamped on the wire.
58    pub attempt: u32,
59}
60
61impl std::fmt::Display for LeaseKey {
62    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            formatter,
65            "workflow {} run {} activity {} attempt {}",
66            self.workflow_id, self.run_id, self.activity_id, self.attempt
67        )
68    }
69}
70
71/// Something that can append the lease fact for one attempt.
72///
73/// Production implements this over the engine ([`EngineLeaseRecorder`]) so the
74/// append goes through the workflow's one `Recorder`; tests implement it over a
75/// vector or a fault.
76#[async_trait]
77pub trait ActivityLeaseRecorder: Send + Sync + 'static {
78    /// Append `ActivityLeased` for `key`, attributed to `worker`.
79    ///
80    /// # Errors
81    ///
82    /// Returns the recorder's own reason when the append did not land. The
83    /// caller counts and logs it; it never retries the dispatch.
84    async fn record(&self, key: &LeaseKey, worker: WorkerAttribution) -> Result<(), String>;
85}
86
87/// The production recorder: the engine's single-writer lease API.
88pub struct EngineLeaseRecorder {
89    engine: Arc<aion::Engine>,
90}
91
92impl EngineLeaseRecorder {
93    /// Record leases through `engine`.
94    #[must_use]
95    pub fn new(engine: Arc<aion::Engine>) -> Self {
96        Self { engine }
97    }
98}
99
100impl std::fmt::Debug for EngineLeaseRecorder {
101    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        formatter.debug_struct("EngineLeaseRecorder").finish()
103    }
104}
105
106#[async_trait]
107impl ActivityLeaseRecorder for EngineLeaseRecorder {
108    async fn record(&self, key: &LeaseKey, worker: WorkerAttribution) -> Result<(), String> {
109        self.engine
110            .record_activity_lease(
111                &key.workflow_id,
112                &key.run_id,
113                key.activity_id.clone(),
114                key.attempt,
115                worker,
116            )
117            .await
118            .map_err(|error| error.to_string())
119    }
120}
121
122/// The install-lifetime count of leases this server knew and failed to record.
123///
124/// Shared by clone: every dispatcher on a boot increments the same count, and
125/// the API provenance (R4) reads it back so a reader can tell "never recorded
126/// here" from "recorded here with N losses".
127#[derive(Clone, Debug, Default)]
128pub struct LeaseRecordLedger {
129    failures: Arc<AtomicU64>,
130}
131
132impl LeaseRecordLedger {
133    /// Leases lost on this install since boot.
134    #[must_use]
135    pub fn failures(&self) -> u64 {
136        self.failures.load(Ordering::SeqCst)
137    }
138
139    fn increment(&self) {
140        self.failures.fetch_add(1, Ordering::SeqCst);
141    }
142}
143
144struct Installed {
145    recorder: Arc<dyn ActivityLeaseRecorder>,
146    metrics: Option<Metrics>,
147}
148
149/// The shared, late-installed recorder every dispatch path records through.
150///
151/// Built with the worker seams before the engine exists and installed once the
152/// engine has booted — the same shape as the outbox delivery callback. Cloning
153/// shares the installation and the ledger.
154#[derive(Clone, Default)]
155pub struct LeaseRecorderSeam {
156    installed: Arc<OnceLock<Installed>>,
157    ledger: LeaseRecordLedger,
158}
159
160impl std::fmt::Debug for LeaseRecorderSeam {
161    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        formatter
163            .debug_struct("LeaseRecorderSeam")
164            .field("installed", &self.installed.get().is_some())
165            .field("failures", &self.ledger.failures())
166            .finish()
167    }
168}
169
170impl LeaseRecorderSeam {
171    /// Install the recorder (and the metrics the failure counter exports on).
172    ///
173    /// Returns `false`, and keeps the first installation, when one is already
174    /// in place — a second install is a wiring bug and is said so.
175    pub fn install(
176        &self,
177        recorder: Arc<dyn ActivityLeaseRecorder>,
178        metrics: Option<Metrics>,
179    ) -> bool {
180        if self.installed.set(Installed { recorder, metrics }).is_err() {
181            tracing::warn!("activity lease recorder already installed; ignoring duplicate install");
182            return false;
183        }
184        true
185    }
186
187    /// Whether a recorder has been installed.
188    #[must_use]
189    pub fn is_installed(&self) -> bool {
190        self.installed.get().is_some()
191    }
192
193    /// The shared loss count.
194    #[must_use]
195    pub const fn ledger(&self) -> &LeaseRecordLedger {
196        &self.ledger
197    }
198
199    /// Record the lease for `key`, counting and logging a loss instead of
200    /// failing: the worker already holds the work.
201    pub async fn record(&self, key: &LeaseKey, worker: WorkerAttribution) {
202        let Some(installed) = self.installed.get() else {
203            self.lost(
204                key,
205                &worker,
206                None,
207                "no activity lease recorder is installed on this dispatcher",
208            );
209            return;
210        };
211        if let Err(reason) = installed.recorder.record(key, worker.clone()).await {
212            self.lost(key, &worker, installed.metrics.as_ref(), &reason);
213        }
214    }
215
216    /// [`Self::record`] from a thread that cannot await — the engine-seam
217    /// bridge, which dispatches synchronously.
218    ///
219    /// Runs the record on `handle` (or the ambient runtime). Inside a
220    /// multi-thread runtime the wait is hosted with `block_in_place`; a
221    /// current-thread runtime cannot host it without starving the recorder,
222    /// and a thread with no runtime in reach cannot run it at all — both are
223    /// counted as losses by name rather than deadlocked or skipped.
224    pub fn record_blocking(
225        &self,
226        handle: Option<&tokio::runtime::Handle>,
227        key: &LeaseKey,
228        worker: WorkerAttribution,
229    ) {
230        match tokio::runtime::Handle::try_current() {
231            Ok(ambient) => match ambient.runtime_flavor() {
232                tokio::runtime::RuntimeFlavor::MultiThread => {
233                    let runner = handle.cloned().unwrap_or(ambient);
234                    tokio::task::block_in_place(|| runner.block_on(self.record(key, worker)));
235                }
236                flavor => self.lost(
237                    key,
238                    &worker,
239                    self.installed.get().and_then(|i| i.metrics.as_ref()),
240                    &format!(
241                        "the lease cannot be recorded from inside a {flavor:?} tokio runtime: \
242                         blocking here would starve the recorder of its only thread"
243                    ),
244                ),
245            },
246            Err(_) => match handle {
247                Some(runner) => runner.block_on(self.record(key, worker)),
248                None => self.lost(
249                    key,
250                    &worker,
251                    self.installed.get().and_then(|i| i.metrics.as_ref()),
252                    "no tokio runtime is reachable from this thread to record the lease on",
253                ),
254            },
255        }
256    }
257
258    fn lost(
259        &self,
260        key: &LeaseKey,
261        worker: &WorkerAttribution,
262        metrics: Option<&Metrics>,
263        why: &str,
264    ) {
265        self.ledger.increment();
266        if let Some(metrics) = metrics {
267            metrics.activity_lease_record_failed();
268        }
269        tracing::error!(
270            workflow_id = %key.workflow_id,
271            run_id = %key.run_id,
272            activity_id = %key.activity_id,
273            attempt = key.attempt,
274            worker_identity = %worker.identity,
275            task_queue = %worker.task_queue,
276            transport = worker.transport.name(),
277            lease_record_failures_total = self.ledger.failures(),
278            reason = why,
279            "activity lease was not recorded; the worker holds the attempt and the history reads \
280             it as unattributed"
281        );
282    }
283}
284
285/// The attribution a lease carries for `worker`, read off its registration.
286#[must_use]
287pub fn attribution_for(worker: &WorkerHandle) -> WorkerAttribution {
288    WorkerAttribution {
289        identity: worker.identity().to_owned(),
290        task_queue: worker.task_queue().to_owned(),
291        node: worker.node().map(str::to_owned),
292        deployment: worker
293            .instance()
294            .map(|instance| instance.deployment.clone()),
295        instance_id: worker
296            .instance()
297            .map(|instance| instance.instance_id.clone()),
298        transport: worker.delivery().transport(),
299    }
300}
301
302/// One delivery's lease: armed on the fences before the push, recorded at the
303/// transport's accept point, settled on the gate either way.
304///
305/// Dropping an unaccepted handoff (the push failed, the candidate was skipped)
306/// settles the gate without recording, so a completion that can never come is
307/// never waited for and a later candidate arms its own.
308pub struct LeaseHandoff {
309    seam: LeaseRecorderSeam,
310    key: LeaseKey,
311    worker: WorkerAttribution,
312    fences: CompletionFences,
313    token: CompletionToken,
314    /// Fired at acceptance, starting the engine's authored per-attempt bound.
315    ///
316    /// Held HERE, beside the durable lease record, so the clock and the record
317    /// cannot disagree about when the worker took the attempt: one object
318    /// answers both, and neither can be moved without the other.
319    lease: aion::LeaseSignal,
320    settled: AtomicBool,
321}
322
323impl LeaseHandoff {
324    /// The attempt this handoff leases.
325    #[must_use]
326    pub fn key(&self) -> &LeaseKey {
327        &self.key
328    }
329
330    /// The completion token the lease gate is armed under.
331    #[must_use]
332    pub fn token(&self) -> &CompletionToken {
333        &self.token
334    }
335}
336
337impl std::fmt::Debug for LeaseHandoff {
338    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        formatter
340            .debug_struct("LeaseHandoff")
341            .field("key", &self.key)
342            .field("settled", &self.settled.load(Ordering::SeqCst))
343            .finish_non_exhaustive()
344    }
345}
346
347impl LeaseHandoff {
348    /// Arm the fences' lease gate for `token` and hold the lease to record.
349    ///
350    /// `lease` is fired at the same instant the lease is RECORDED, so the
351    /// engine's authored per-attempt bound and the durable `ActivityLeased`
352    /// event share one anchor by construction. A caller with no per-attempt
353    /// timer above it — the outbox push leg, whose dispatches are not driven by
354    /// the engine's retry loop — passes
355    /// [`LeaseSignal::none`](aion::LeaseSignal::none), which is a statement
356    /// that nothing is timing this attempt rather than a value standing in for
357    /// one.
358    ///
359    /// # Errors
360    ///
361    /// Returns lock poison from the fences.
362    pub fn arm(
363        seam: LeaseRecorderSeam,
364        key: LeaseKey,
365        worker: WorkerAttribution,
366        fences: CompletionFences,
367        token: CompletionToken,
368        lease: aion::LeaseSignal,
369    ) -> Result<Self, ServerError> {
370        fences.arm_lease(&token)?;
371        Ok(Self {
372            seam,
373            key,
374            worker,
375            fences,
376            token,
377            lease,
378            settled: AtomicBool::new(false),
379        })
380    }
381
382    /// Record the lease from a thread that cannot await, then settle the gate.
383    pub fn accepted_blocking(&self, handle: Option<&tokio::runtime::Handle>) {
384        if self.settled.load(Ordering::SeqCst) {
385            tracing::warn!(key = %self.key, "lease handoff accepted twice; the second is ignored");
386            return;
387        }
388        self.seam
389            .record_blocking(handle, &self.key, self.worker.clone());
390        // The worker holds the attempt from here, so the engine's per-attempt
391        // bound starts from here. Fired beside the durable record rather than
392        // at some site that also knows about acceptance, because "when the
393        // lease happened" must have exactly one answer.
394        self.lease.fire();
395        self.settle();
396    }
397
398    fn settle(&self) {
399        if self.settled.swap(true, Ordering::SeqCst) {
400            return;
401        }
402        if let Err(error) = self.fences.settle_lease(&self.token) {
403            tracing::error!(
404                key = %self.key,
405                %error,
406                "lease gate could not be settled; completions for this attempt may wait on a \
407                 poisoned fence"
408            );
409        }
410    }
411}
412
413impl Drop for LeaseHandoff {
414    fn drop(&mut self) {
415        self.settle();
416    }
417}
418
419#[async_trait]
420impl DeliveryAccepted for LeaseHandoff {
421    async fn accepted(&self) {
422        if self.settled.load(Ordering::SeqCst) {
423            tracing::warn!(key = %self.key, "lease handoff accepted twice; the second is ignored");
424            return;
425        }
426        self.seam.record(&self.key, self.worker.clone()).await;
427        self.lease.fire();
428        self.settle();
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use aion_core::WorkerTransport;
436    use std::sync::Mutex;
437    use uuid::Uuid;
438
439    struct Recording(Mutex<Vec<(LeaseKey, WorkerAttribution)>>);
440
441    #[async_trait]
442    impl ActivityLeaseRecorder for Recording {
443        async fn record(&self, key: &LeaseKey, worker: WorkerAttribution) -> Result<(), String> {
444            self.0
445                .lock()
446                .map_err(|_| "recording lock poisoned".to_owned())?
447                .push((key.clone(), worker));
448            Ok(())
449        }
450    }
451
452    struct Faulting;
453
454    #[async_trait]
455    impl ActivityLeaseRecorder for Faulting {
456        async fn record(&self, _key: &LeaseKey, _worker: WorkerAttribution) -> Result<(), String> {
457            Err("store fault injected".to_owned())
458        }
459    }
460
461    fn key() -> LeaseKey {
462        LeaseKey {
463            workflow_id: WorkflowId::new(Uuid::new_v4()),
464            run_id: RunId::new(Uuid::new_v4()),
465            activity_id: ActivityId::from_sequence_position(3),
466            attempt: 1,
467        }
468    }
469
470    fn worker() -> WorkerAttribution {
471        WorkerAttribution {
472            identity: "w-1".to_owned(),
473            task_queue: "q".to_owned(),
474            node: None,
475            deployment: None,
476            instance_id: None,
477            transport: WorkerTransport::Grpc,
478        }
479    }
480
481    #[tokio::test]
482    async fn an_installed_recorder_receives_the_lease_and_the_ledger_stays_at_zero() {
483        let seam = LeaseRecorderSeam::default();
484        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
485        assert!(seam.install(recording.clone(), None));
486        let key = key();
487        seam.record(&key, worker()).await;
488        let seen = recording
489            .0
490            .lock()
491            .map(|seen| seen.clone())
492            .unwrap_or_default();
493        assert_eq!(seen.len(), 1);
494        assert_eq!(seen[0].0, key);
495        assert_eq!(seen[0].1.identity, "w-1");
496        assert_eq!(seam.ledger().failures(), 0);
497    }
498
499    #[tokio::test]
500    async fn a_failed_record_counts_on_the_ledger_and_on_the_metrics_surface() {
501        let seam = LeaseRecorderSeam::default();
502        let metrics = Metrics::new().ok();
503        assert!(seam.install(Arc::new(Faulting), metrics.clone()));
504        seam.record(&key(), worker()).await;
505        seam.record(&key(), worker()).await;
506        assert_eq!(seam.ledger().failures(), 2);
507        if let Some(metrics) = metrics {
508            let text = String::from_utf8(metrics.encode().unwrap_or_default()).unwrap_or_default();
509            assert!(
510                text.contains("aion_activity_lease_record_failures_total 2"),
511                "the metrics surface must carry the same count; got:\n{text}"
512            );
513        }
514    }
515
516    #[tokio::test]
517    async fn an_uninstalled_seam_counts_the_loss_instead_of_skipping_it() {
518        let seam = LeaseRecorderSeam::default();
519        assert!(!seam.is_installed());
520        seam.record(&key(), worker()).await;
521        assert_eq!(seam.ledger().failures(), 1);
522    }
523
524    #[tokio::test]
525    async fn a_second_install_keeps_the_first() {
526        let seam = LeaseRecorderSeam::default();
527        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
528        assert!(seam.install(recording.clone(), None));
529        assert!(!seam.install(Arc::new(Faulting), None));
530        seam.record(&key(), worker()).await;
531        assert_eq!(seam.ledger().failures(), 0);
532        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 1);
533    }
534
535    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
536    async fn a_handoff_arms_the_gate_and_settles_it_after_recording() -> Result<(), ServerError> {
537        let seam = LeaseRecorderSeam::default();
538        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
539        seam.install(recording.clone(), None);
540        let fences = CompletionFences::default();
541        let k = key();
542        let token = fences.issue(&k.workflow_id, &k.run_id, &k.activity_id, k.attempt)?;
543        let handoff = LeaseHandoff::arm(
544            seam.clone(),
545            k,
546            worker(),
547            fences.clone(),
548            token.clone(),
549            aion::LeaseSignal::none(),
550        )?;
551        assert!(fences.lease_pending(&token)?);
552        handoff.accepted().await;
553        assert!(!fences.lease_pending(&token)?);
554        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 1);
555        Ok(())
556    }
557
558    #[tokio::test]
559    async fn dropping_an_unaccepted_handoff_settles_without_recording() -> Result<(), ServerError> {
560        let seam = LeaseRecorderSeam::default();
561        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
562        seam.install(recording.clone(), None);
563        let fences = CompletionFences::default();
564        let k = key();
565        let token = fences.issue(&k.workflow_id, &k.run_id, &k.activity_id, k.attempt)?;
566        let handoff = LeaseHandoff::arm(
567            seam,
568            k,
569            worker(),
570            fences.clone(),
571            token.clone(),
572            aion::LeaseSignal::none(),
573        )?;
574        assert!(fences.lease_pending(&token)?);
575        drop(handoff);
576        assert!(!fences.lease_pending(&token)?);
577        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 0);
578        Ok(())
579    }
580
581    #[tokio::test]
582    async fn recording_from_a_current_thread_runtime_is_a_named_loss_not_a_deadlock() {
583        let seam = LeaseRecorderSeam::default();
584        seam.install(Arc::new(Recording(Mutex::new(Vec::new()))), None);
585        seam.record_blocking(None, &key(), worker());
586        assert_eq!(seam.ledger().failures(), 1);
587    }
588
589    #[test]
590    fn recording_from_a_plain_thread_runs_on_the_given_handle() {
591        let runtime = tokio::runtime::Builder::new_multi_thread()
592            .worker_threads(1)
593            .enable_all()
594            .build();
595        let Ok(runtime) = runtime else {
596            return;
597        };
598        let seam = LeaseRecorderSeam::default();
599        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
600        seam.install(recording.clone(), None);
601        seam.record_blocking(Some(runtime.handle()), &key(), worker());
602        assert_eq!(seam.ledger().failures(), 0);
603        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 1);
604    }
605}