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    settled: AtomicBool,
315}
316
317impl LeaseHandoff {
318    /// The attempt this handoff leases.
319    #[must_use]
320    pub fn key(&self) -> &LeaseKey {
321        &self.key
322    }
323
324    /// The completion token the lease gate is armed under.
325    #[must_use]
326    pub fn token(&self) -> &CompletionToken {
327        &self.token
328    }
329}
330
331impl std::fmt::Debug for LeaseHandoff {
332    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        formatter
334            .debug_struct("LeaseHandoff")
335            .field("key", &self.key)
336            .field("settled", &self.settled.load(Ordering::SeqCst))
337            .finish_non_exhaustive()
338    }
339}
340
341impl LeaseHandoff {
342    /// Arm the fences' lease gate for `token` and hold the lease to record.
343    ///
344    /// # Errors
345    ///
346    /// Returns lock poison from the fences.
347    pub fn arm(
348        seam: LeaseRecorderSeam,
349        key: LeaseKey,
350        worker: WorkerAttribution,
351        fences: CompletionFences,
352        token: CompletionToken,
353    ) -> Result<Self, ServerError> {
354        fences.arm_lease(&token)?;
355        Ok(Self {
356            seam,
357            key,
358            worker,
359            fences,
360            token,
361            settled: AtomicBool::new(false),
362        })
363    }
364
365    /// Record the lease from a thread that cannot await, then settle the gate.
366    pub fn accepted_blocking(&self, handle: Option<&tokio::runtime::Handle>) {
367        if self.settled.load(Ordering::SeqCst) {
368            tracing::warn!(key = %self.key, "lease handoff accepted twice; the second is ignored");
369            return;
370        }
371        self.seam
372            .record_blocking(handle, &self.key, self.worker.clone());
373        self.settle();
374    }
375
376    fn settle(&self) {
377        if self.settled.swap(true, Ordering::SeqCst) {
378            return;
379        }
380        if let Err(error) = self.fences.settle_lease(&self.token) {
381            tracing::error!(
382                key = %self.key,
383                %error,
384                "lease gate could not be settled; completions for this attempt may wait on a \
385                 poisoned fence"
386            );
387        }
388    }
389}
390
391impl Drop for LeaseHandoff {
392    fn drop(&mut self) {
393        self.settle();
394    }
395}
396
397#[async_trait]
398impl DeliveryAccepted for LeaseHandoff {
399    async fn accepted(&self) {
400        if self.settled.load(Ordering::SeqCst) {
401            tracing::warn!(key = %self.key, "lease handoff accepted twice; the second is ignored");
402            return;
403        }
404        self.seam.record(&self.key, self.worker.clone()).await;
405        self.settle();
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use aion_core::WorkerTransport;
413    use std::sync::Mutex;
414    use uuid::Uuid;
415
416    struct Recording(Mutex<Vec<(LeaseKey, WorkerAttribution)>>);
417
418    #[async_trait]
419    impl ActivityLeaseRecorder for Recording {
420        async fn record(&self, key: &LeaseKey, worker: WorkerAttribution) -> Result<(), String> {
421            self.0
422                .lock()
423                .map_err(|_| "recording lock poisoned".to_owned())?
424                .push((key.clone(), worker));
425            Ok(())
426        }
427    }
428
429    struct Faulting;
430
431    #[async_trait]
432    impl ActivityLeaseRecorder for Faulting {
433        async fn record(&self, _key: &LeaseKey, _worker: WorkerAttribution) -> Result<(), String> {
434            Err("store fault injected".to_owned())
435        }
436    }
437
438    fn key() -> LeaseKey {
439        LeaseKey {
440            workflow_id: WorkflowId::new(Uuid::new_v4()),
441            run_id: RunId::new(Uuid::new_v4()),
442            activity_id: ActivityId::from_sequence_position(3),
443            attempt: 1,
444        }
445    }
446
447    fn worker() -> WorkerAttribution {
448        WorkerAttribution {
449            identity: "w-1".to_owned(),
450            task_queue: "q".to_owned(),
451            node: None,
452            deployment: None,
453            instance_id: None,
454            transport: WorkerTransport::Grpc,
455        }
456    }
457
458    #[tokio::test]
459    async fn an_installed_recorder_receives_the_lease_and_the_ledger_stays_at_zero() {
460        let seam = LeaseRecorderSeam::default();
461        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
462        assert!(seam.install(recording.clone(), None));
463        let key = key();
464        seam.record(&key, worker()).await;
465        let seen = recording
466            .0
467            .lock()
468            .map(|seen| seen.clone())
469            .unwrap_or_default();
470        assert_eq!(seen.len(), 1);
471        assert_eq!(seen[0].0, key);
472        assert_eq!(seen[0].1.identity, "w-1");
473        assert_eq!(seam.ledger().failures(), 0);
474    }
475
476    #[tokio::test]
477    async fn a_failed_record_counts_on_the_ledger_and_on_the_metrics_surface() {
478        let seam = LeaseRecorderSeam::default();
479        let metrics = Metrics::new().ok();
480        assert!(seam.install(Arc::new(Faulting), metrics.clone()));
481        seam.record(&key(), worker()).await;
482        seam.record(&key(), worker()).await;
483        assert_eq!(seam.ledger().failures(), 2);
484        if let Some(metrics) = metrics {
485            let text = String::from_utf8(metrics.encode().unwrap_or_default()).unwrap_or_default();
486            assert!(
487                text.contains("aion_activity_lease_record_failures_total 2"),
488                "the metrics surface must carry the same count; got:\n{text}"
489            );
490        }
491    }
492
493    #[tokio::test]
494    async fn an_uninstalled_seam_counts_the_loss_instead_of_skipping_it() {
495        let seam = LeaseRecorderSeam::default();
496        assert!(!seam.is_installed());
497        seam.record(&key(), worker()).await;
498        assert_eq!(seam.ledger().failures(), 1);
499    }
500
501    #[tokio::test]
502    async fn a_second_install_keeps_the_first() {
503        let seam = LeaseRecorderSeam::default();
504        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
505        assert!(seam.install(recording.clone(), None));
506        assert!(!seam.install(Arc::new(Faulting), None));
507        seam.record(&key(), worker()).await;
508        assert_eq!(seam.ledger().failures(), 0);
509        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 1);
510    }
511
512    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
513    async fn a_handoff_arms_the_gate_and_settles_it_after_recording() -> Result<(), ServerError> {
514        let seam = LeaseRecorderSeam::default();
515        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
516        seam.install(recording.clone(), None);
517        let fences = CompletionFences::default();
518        let k = key();
519        let token = fences.issue(&k.workflow_id, &k.run_id, &k.activity_id, k.attempt)?;
520        let handoff = LeaseHandoff::arm(seam.clone(), k, worker(), fences.clone(), token.clone())?;
521        assert!(fences.lease_pending(&token)?);
522        handoff.accepted().await;
523        assert!(!fences.lease_pending(&token)?);
524        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 1);
525        Ok(())
526    }
527
528    #[tokio::test]
529    async fn dropping_an_unaccepted_handoff_settles_without_recording() -> Result<(), ServerError> {
530        let seam = LeaseRecorderSeam::default();
531        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
532        seam.install(recording.clone(), None);
533        let fences = CompletionFences::default();
534        let k = key();
535        let token = fences.issue(&k.workflow_id, &k.run_id, &k.activity_id, k.attempt)?;
536        let handoff = LeaseHandoff::arm(seam, k, worker(), fences.clone(), token.clone())?;
537        assert!(fences.lease_pending(&token)?);
538        drop(handoff);
539        assert!(!fences.lease_pending(&token)?);
540        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 0);
541        Ok(())
542    }
543
544    #[tokio::test]
545    async fn recording_from_a_current_thread_runtime_is_a_named_loss_not_a_deadlock() {
546        let seam = LeaseRecorderSeam::default();
547        seam.install(Arc::new(Recording(Mutex::new(Vec::new()))), None);
548        seam.record_blocking(None, &key(), worker());
549        assert_eq!(seam.ledger().failures(), 1);
550    }
551
552    #[test]
553    fn recording_from_a_plain_thread_runs_on_the_given_handle() {
554        let runtime = tokio::runtime::Builder::new_multi_thread()
555            .worker_threads(1)
556            .enable_all()
557            .build();
558        let Ok(runtime) = runtime else {
559            return;
560        };
561        let seam = LeaseRecorderSeam::default();
562        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
563        seam.install(recording.clone(), None);
564        seam.record_blocking(Some(runtime.handle()), &key(), worker());
565        assert_eq!(seam.ledger().failures(), 0);
566        assert_eq!(recording.0.lock().map_or(0, |s| s.len()), 1);
567    }
568}