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 settled: AtomicBool,
315}
316
317impl LeaseHandoff {
318 #[must_use]
320 pub fn key(&self) -> &LeaseKey {
321 &self.key
322 }
323
324 #[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 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 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}