1use 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#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct LeaseKey {
51 pub workflow_id: WorkflowId,
53 pub run_id: RunId,
55 pub activity_id: ActivityId,
57 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#[async_trait]
77pub trait ActivityLeaseRecorder: Send + Sync + 'static {
78 async fn record(&self, key: &LeaseKey, worker: WorkerAttribution) -> Result<(), String>;
85}
86
87pub struct EngineLeaseRecorder {
89 engine: Arc<aion::Engine>,
90}
91
92impl EngineLeaseRecorder {
93 #[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#[derive(Clone, Debug, Default)]
128pub struct LeaseRecordLedger {
129 failures: Arc<AtomicU64>,
130}
131
132impl LeaseRecordLedger {
133 #[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#[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 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 #[must_use]
189 pub fn is_installed(&self) -> bool {
190 self.installed.get().is_some()
191 }
192
193 #[must_use]
195 pub const fn ledger(&self) -> &LeaseRecordLedger {
196 &self.ledger
197 }
198
199 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 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#[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
302pub struct LeaseHandoff {
309 seam: LeaseRecorderSeam,
310 key: LeaseKey,
311 worker: WorkerAttribution,
312 fences: CompletionFences,
313 token: CompletionToken,
314 lease: aion::LeaseSignal,
320 settled: AtomicBool,
321}
322
323impl LeaseHandoff {
324 #[must_use]
326 pub fn key(&self) -> &LeaseKey {
327 &self.key
328 }
329
330 #[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 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 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 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}