Skip to main content

aion_worker/runtime/
loop_.rs

1//! receive->dispatch->report worker loop + bounded concurrency
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::sync::Arc;
5
6use aion_core::{ActivityError, ActivityId, Payload, WorkflowId};
7use async_trait::async_trait;
8use futures::StreamExt;
9use futures::future;
10use tokio::sync::{Semaphore, mpsc};
11use tracing::{debug, info};
12
13use crate::config::WorkerConfig;
14use crate::context::{ActivityContext, HeartbeatRequest};
15use crate::error::WorkerError;
16use crate::protocol::reconnect::UnackedResultTracker;
17use crate::protocol::{
18    ActivityExecutionKey, ActivityTask, HeartbeatBookkeeper, WorkerSession, WorkerSessionEvent,
19};
20use crate::runtime::report::{
21    DispatchFinished, InFlightActivity, RuntimeChannels, drain_remaining, record_first_error,
22    report_finished,
23};
24
25/// Dispatch seam used by the receive loop to execute decoded activity tasks.
26#[async_trait]
27pub trait ActivityDispatcher: Send + Sync + 'static {
28    /// Executes one decoded activity task with the provided handler context.
29    async fn dispatch(
30        &self,
31        task: ActivityTask,
32        context: ActivityContext,
33    ) -> Result<DispatchOutcome, WorkerError>;
34
35    /// Activity type names this dispatcher can serve.
36    fn activity_types(&self) -> BTreeSet<String>;
37}
38
39/// Activity execution outcome returned by the dispatch seam.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum DispatchOutcome {
42    /// Activity completed with an output payload.
43    Completed {
44        /// Opaque output payload.
45        output: Payload,
46    },
47    /// Activity failed with explicit classification.
48    Failed {
49        /// Classified activity failure.
50        failure: ActivityError,
51    },
52}
53
54/// Future that never resolves, used by the default serve entrypoint.
55pub type NoShutdown = future::Pending<()>;
56
57/// Why the serve loop ended without an error.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum ServeEnd {
60    /// The caller's shutdown future fired; in-flight work was drained.
61    Shutdown,
62    /// The server ended the task stream cleanly without announcing a drain.
63    /// The reconnect-aware run loop treats this unannounced close as a
64    /// budgeted retryable session drop — never as a run end.
65    StreamClosed,
66    /// The server announced a drain: in-flight work was finished and
67    /// reported, and the run loop reconnects after the schedule's initial
68    /// backoff without consuming any drop budget.
69    Drained,
70}
71
72/// Per-session health accounting written by the serve loop for the
73/// reconnect-aware caller's drop-budget reset decision.
74#[derive(Debug, Default)]
75pub struct SessionHealth {
76    /// Activity tasks whose outcome report was sent on this session.
77    pub tasks_reported: usize,
78    /// When the receive stream ended or dropped, captured before in-flight
79    /// handlers are drained — so post-drop draining never extends the
80    /// session's measured connected lifetime.
81    pub stream_ended_at: Option<tokio::time::Instant>,
82    /// Latched when a drain frame is observed on this session: the eventual
83    /// stream end — clean OR abrupt — is then drain-class (the server
84    /// announced it was going away), so the drop consumes no budget even if
85    /// the post-drain reporting fails. Survives an error return because this
86    /// is an out-parameter.
87    pub drain_received: bool,
88}
89
90/// Runs the worker receive loop until the session's task stream completes.
91///
92/// The RUNTIME owns liveness: for a session that carries a server-assigned
93/// heartbeat window ([`WorkerSession::heartbeat_window`]), the loop
94/// automatically heartbeats every in-flight activity at a quarter-window
95/// cadence, so a healthy worker running a legitimately long activity is never
96/// expired by the server's heartbeat sweeper. Explicit handler heartbeats
97/// remain the way to attach PROGRESS payloads; they are forwarded as they
98/// arrive. The loop never enforces heartbeat timeouts locally and never
99/// aborts running handler tasks on cancellation.
100///
101/// Every computed dispatch outcome is recorded in `tracker` before its report
102/// is sent, so a caller that reconnects after a transport drop can re-report
103/// the backlog; the server acks each consumed report (`ResultAck`), and only
104/// that explicit acknowledgement clears a tracker entry.
105///
106/// # Errors
107///
108/// Returns [`WorkerError`] when task decode, dispatch, heartbeat send, or result
109/// reporting fails.
110pub async fn serve_activity_tasks<S, D>(
111    config: &WorkerConfig,
112    session: &mut S,
113    dispatcher: Arc<D>,
114    tracker: &mut UnackedResultTracker,
115) -> Result<ServeEnd, WorkerError>
116where
117    S: WorkerSession,
118    D: ActivityDispatcher,
119{
120    let mut health = SessionHealth::default();
121    serve_activity_tasks_until(
122        config,
123        session,
124        dispatcher,
125        tracker,
126        &mut health,
127        future::pending(),
128    )
129    .await
130}
131
132/// Runs the worker receive loop until the session's task stream completes.
133///
134/// The RUNTIME owns liveness (#176): when the session carries a
135/// server-assigned heartbeat window ([`WorkerSession::heartbeat_window`],
136/// from the `RegisterAck`), the loop automatically sends a liveness heartbeat
137/// for EVERY in-flight activity at a quarter-window cadence
138/// ([`liveness_pump_interval`]). The server's heartbeat sweeper expires any
139/// worker whose in-flight task exceeds the window without a heartbeat — that
140/// is dead/wedged-PROCESS detection, and a healthy process running a
141/// multi-minute handler must never trip it, so keeping tasks beating is the
142/// runtime's job, not each handler's. A wedged process (deadlocked loop,
143/// stopped host) stops pumping and is correctly expired. Explicit handler
144/// heartbeats remain the way to attach PROGRESS payloads and are forwarded as
145/// they arrive; the loop never enforces heartbeat timeouts locally and never
146/// aborts running handler tasks on cancellation.
147///
148/// Every computed dispatch outcome is recorded in `tracker` before its report
149/// is sent, so a caller that reconnects after a transport drop can re-report
150/// the backlog; the server ingests reports idempotently and acks each one
151/// with a `ResultAck` frame. Only that explicit acknowledgement clears a
152/// tracker entry — a successful send proves nothing on its own.
153///
154/// `health` accumulates session-health accounting: the activity tasks whose
155/// outcome report was sent on this session, and the instant the receive
156/// stream ended (captured before in-flight handlers are drained). It is an
157/// out-parameter (rather than part of the return value) so the accounting
158/// survives an error return: the reconnect-aware caller uses it for the
159/// drop-budget reset decision — a session that served at least one task, or
160/// that stayed connected longer than the maximum backoff delay measured to
161/// the recorded stream end (never to the end of the post-drop drain), resets
162/// the cumulative drop budget even when it later drops.
163///
164/// On a clean end this returns [`ServeEnd`] distinguishing a caller-driven
165/// shutdown from a server-side stream close, so the caller can treat the
166/// latter as a retryable drop.
167///
168/// # Errors
169///
170/// Returns [`WorkerError`] when task decode, dispatch, heartbeat send, or result
171/// reporting fails.
172pub async fn serve_activity_tasks_until<S, D, Shutdown>(
173    config: &WorkerConfig,
174    session: &mut S,
175    dispatcher: Arc<D>,
176    tracker: &mut UnackedResultTracker,
177    health: &mut SessionHealth,
178    shutdown: Shutdown,
179) -> Result<ServeEnd, WorkerError>
180where
181    S: WorkerSession,
182    D: ActivityDispatcher,
183    Shutdown: Future<Output = ()> + Send,
184{
185    ensure_max_concurrency(config)?;
186    let semaphore = Arc::new(Semaphore::new(config.max_concurrency));
187    let (result_sender, heartbeat_sender, mut channels) = runtime_channels();
188    let heartbeat_bookkeeper = HeartbeatBookkeeper::default();
189    let mut liveness_pump = liveness_pump_for(session);
190    let mut stream = session.receive_tasks();
191    let mut in_flight = HashMap::<ActivityExecutionKey, InFlightActivity>::new();
192    let mut pending_error = None;
193    // Overridden at the shutdown break sites; every other clean exit is the
194    // server ending the stream.
195    let mut end = ServeEnd::StreamClosed;
196    tokio::pin!(shutdown);
197
198    // No batching preamble: the select arms below consume queued dispatch
199    // outcomes and heartbeats directly, so nothing waits for a stream event.
200    while pending_error.is_none() {
201        tokio::select! {
202            biased;
203            () = &mut shutdown => {
204                cancel_all_in_flight(&in_flight);
205                end = ServeEnd::Shutdown;
206                break;
207            }
208            // Dispatch outcomes are reported the moment they complete — the
209            // loop must not sit in `stream.next()` while a finished result
210            // waits, or a single dispatched task on an otherwise idle stream
211            // is only reported when the stream ends (the server-side dispatch
212            // would time out against a healthy worker).
213            finished = channels.results.recv() => {
214                consume_finished(
215                    session,
216                    &heartbeat_bookkeeper,
217                    finished,
218                    &mut in_flight,
219                    tracker,
220                    health,
221                    &mut pending_error,
222                )
223                .await;
224            }
225            // Handler heartbeats are forwarded as they arrive for the same
226            // reason: the server's liveness window must be beatable while the
227            // stream is idle.
228            request = channels.heartbeats.recv() => {
229                forward_heartbeat(session, &heartbeat_bookkeeper, request, &mut pending_error)
230                    .await;
231            }
232            // Automatic connection lease beat plus per-task liveness beats.
233            // This arm remains active while idle so an open but wedged runtime
234            // becomes detectable by the server's connection lease.
235            () = tick_liveness_pump(&mut liveness_pump) => {
236                pump_liveness(session, &heartbeat_bookkeeper, &in_flight, &mut pending_error)
237                    .await;
238            }
239            event = stream.next() => {
240                let Some(event) = event else { break; };
241                match event {
242                    Ok(WorkerSessionEvent::Cancel { workflow_id, activity_id }) => {
243                        deliver_cancellation(workflow_id, &activity_id, &in_flight);
244                    }
245                    // Acks are bookkeeping, not work: consumed without a
246                    // concurrency permit, like cancellation delivery.
247                    Ok(WorkerSessionEvent::ResultAck { workflow_id, activity_id }) => {
248                        acknowledge_result(&workflow_id, &activity_id, tracker);
249                    }
250                    Ok(WorkerSessionEvent::Drain) => {
251                        info!("server drain received; finishing in-flight work before reconnect");
252                        health.drain_received = true;
253                        end = ServeEnd::Drained;
254                        break;
255                    }
256                    Err(error) => {
257                        pending_error = Some(error);
258                        break;
259                    }
260                    Ok(WorkerSessionEvent::Task(proto_task)) => {
261                        let Some(permit) =
262                            acquire_permit_or_shutdown(shutdown.as_mut(), &semaphore).await?
263                        else {
264                            cancel_all_in_flight(&in_flight);
265                            end = ServeEnd::Shutdown;
266                            break;
267                        };
268                        if !handle_task(
269                            *proto_task,
270                            SessionEventContext {
271                                permit,
272                                dispatcher: Arc::clone(&dispatcher),
273                                result_sender: &result_sender,
274                                heartbeat_sender: &heartbeat_sender,
275                                heartbeat_bookkeeper: &heartbeat_bookkeeper,
276                                in_flight: &mut in_flight,
277                                pending_error: &mut pending_error,
278                            },
279                        )? {
280                            break;
281                        }
282                    }
283                }
284            }
285        }
286    }
287
288    // The stream just ended — cleanly, by error, or by shutdown. Capture the
289    // moment before draining in-flight handlers so the caller's drop-budget
290    // reset decision measures connected time, never drain time.
291    health.stream_ended_at = Some(tokio::time::Instant::now());
292
293    drop((result_sender, heartbeat_sender));
294    drain_remaining(
295        session,
296        &heartbeat_bookkeeper,
297        &mut channels,
298        &mut in_flight,
299        tracker,
300        &mut health.tasks_reported,
301        &mut pending_error,
302    )
303    .await;
304
305    pending_error.map_or(Ok(end), Err)
306}
307
308/// Builds the runtime's dispatch-outcome and heartbeat channels.
309fn runtime_channels() -> (
310    mpsc::UnboundedSender<DispatchFinished>,
311    mpsc::UnboundedSender<HeartbeatRequest>,
312    RuntimeChannels,
313) {
314    let (result_sender, result_receiver) = mpsc::unbounded_channel();
315    let (heartbeat_sender, heartbeat_receiver) = mpsc::unbounded_channel();
316    let channels = RuntimeChannels {
317        heartbeats: heartbeat_receiver,
318        results: result_receiver,
319    };
320    (result_sender, heartbeat_sender, channels)
321}
322
323struct SessionEventContext<'a, D> {
324    permit: tokio::sync::OwnedSemaphorePermit,
325    dispatcher: Arc<D>,
326    result_sender: &'a mpsc::UnboundedSender<DispatchFinished>,
327    heartbeat_sender: &'a mpsc::UnboundedSender<HeartbeatRequest>,
328    heartbeat_bookkeeper: &'a HeartbeatBookkeeper,
329    in_flight: &'a mut HashMap<ActivityExecutionKey, InFlightActivity>,
330    pending_error: &'a mut Option<WorkerError>,
331}
332
333fn handle_task<D>(
334    proto_task: aion_proto::ProtoActivityTask,
335    ctx: SessionEventContext<'_, D>,
336) -> Result<bool, WorkerError>
337where
338    D: ActivityDispatcher,
339{
340    let task = match ActivityTask::try_from(proto_task) {
341        Ok(task) => task,
342        Err(error) => {
343            drop(ctx.permit);
344            *ctx.pending_error = Some(error);
345            return Ok(false);
346        }
347    };
348    spawn_activity(
349        task,
350        ctx.permit,
351        ctx.dispatcher,
352        ctx.result_sender.clone(),
353        ctx.heartbeat_sender.clone(),
354        ctx.heartbeat_bookkeeper,
355        ctx.in_flight,
356    )?;
357    Ok(true)
358}
359
360/// Rejects a zero `max_concurrency` before the serve loop starts.
361fn ensure_max_concurrency(config: &WorkerConfig) -> Result<(), WorkerError> {
362    if config.max_concurrency == 0 {
363        return Err(WorkerError::registration(InvalidMaxConcurrency));
364    }
365    Ok(())
366}
367
368/// Waits for a dispatch permit, racing the caller's shutdown future; returns
369/// `None` when shutdown won.
370async fn acquire_permit_or_shutdown<F>(
371    shutdown: std::pin::Pin<&mut F>,
372    semaphore: &Arc<Semaphore>,
373) -> Result<Option<tokio::sync::OwnedSemaphorePermit>, WorkerError>
374where
375    F: Future<Output = ()> + Send,
376{
377    tokio::select! {
378        biased;
379        () = shutdown => Ok(None),
380        permit = Arc::clone(semaphore).acquire_owned() => {
381            permit.map(Some).map_err(WorkerError::registration)
382        }
383    }
384}
385
386/// Build the automatic liveness pump for a session: sessions registered
387/// against a server heartbeat window ([`WorkerSession::heartbeat_window`])
388/// beat every in-flight activity at a quarter-window cadence so the server's
389/// expiry sweeper only ever fires on a genuinely dead/wedged process.
390/// Sessions without a window (fakes, tests) never pump — byte-identical to
391/// the pre-pump loop.
392fn liveness_pump_for<S>(session: &S) -> Option<tokio::time::Interval>
393where
394    S: WorkerSession,
395{
396    session.heartbeat_window().map(|window| {
397        let mut ticks = tokio::time::interval(liveness_pump_interval(window));
398        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
399        ticks
400    })
401}
402
403/// Consume one queued dispatch outcome (a `None` channel read is a no-op)
404/// and report it through the session, mirroring the drain path's
405/// [`report_finished`].
406async fn consume_finished<S>(
407    session: &mut S,
408    heartbeat_bookkeeper: &HeartbeatBookkeeper,
409    finished: Option<DispatchFinished>,
410    in_flight: &mut HashMap<ActivityExecutionKey, InFlightActivity>,
411    tracker: &mut UnackedResultTracker,
412    health: &mut SessionHealth,
413    pending_error: &mut Option<WorkerError>,
414) where
415    S: WorkerSession,
416{
417    if let Some(finished) = finished {
418        report_finished(
419            session,
420            heartbeat_bookkeeper,
421            finished,
422            in_flight,
423            tracker,
424            &mut health.tasks_reported,
425            pending_error,
426        )
427        .await;
428    }
429}
430
431/// Automatic liveness-heartbeat cadence derived from the server-assigned
432/// heartbeat window: a quarter of the window, floored at one millisecond
433/// (`tokio::time::interval` rejects a zero period).
434///
435/// The server expires a task once it goes longer than the WHOLE window
436/// without a heartbeat, so a quarter-window pump gives roughly four beats per
437/// window — comfortably inside the contract even when an individual beat is
438/// delayed by a busy loop iteration. Deliberately derived rather than
439/// configurable: the window is the server operator's contract, and the pump
440/// cadence is an implementation detail of honouring it (mirroring the
441/// server's own derived sweep cadence).
442#[must_use]
443pub(crate) fn liveness_pump_interval(heartbeat_window: std::time::Duration) -> std::time::Duration {
444    (heartbeat_window / 4).max(std::time::Duration::from_millis(1))
445}
446
447/// Resolves on the next automatic liveness tick, or never for sessions
448/// without a server-assigned heartbeat window (fakes and unregistered
449/// sessions never pump).
450async fn tick_liveness_pump(pump: &mut Option<tokio::time::Interval>) {
451    match pump {
452        Some(ticks) => {
453            ticks.tick().await;
454        }
455        None => future::pending().await,
456    }
457}
458
459/// Sends one connection lease heartbeat followed by a task liveness heartbeat
460/// (with no progress payload) for every in-flight activity.
461async fn pump_liveness<S>(
462    session: &mut S,
463    heartbeat_bookkeeper: &HeartbeatBookkeeper,
464    in_flight: &HashMap<ActivityExecutionKey, InFlightActivity>,
465    pending_error: &mut Option<WorkerError>,
466) where
467    S: WorkerSession,
468{
469    record_first_error(pending_error, session.send_connection_heartbeat().await);
470    if pending_error.is_some() {
471        return;
472    }
473    for key in in_flight.keys() {
474        record_first_error(
475            pending_error,
476            crate::protocol::send_heartbeat(
477                session,
478                heartbeat_bookkeeper,
479                HeartbeatRequest {
480                    workflow_id: key.workflow_id.clone(),
481                    activity_id: key.activity_id.clone(),
482                    detail: None,
483                },
484            )
485            .await,
486        );
487        if pending_error.is_some() {
488            // The session send path is broken; the loop is about to exit
489            // with this error, so further beats are pointless.
490            return;
491        }
492    }
493}
494
495/// Forwards one queued handler heartbeat (a `None` channel read is a no-op)
496/// to the session, recording the first error.
497async fn forward_heartbeat<S>(
498    session: &mut S,
499    heartbeat_bookkeeper: &HeartbeatBookkeeper,
500    request: Option<HeartbeatRequest>,
501    pending_error: &mut Option<WorkerError>,
502) where
503    S: WorkerSession,
504{
505    if let Some(request) = request {
506        record_first_error(
507            pending_error,
508            crate::protocol::send_heartbeat(session, heartbeat_bookkeeper, request).await,
509        );
510    }
511}
512
513/// Clears the acknowledged tracker entry; an unknown ack (already cleared on
514/// a previous session, or replaced by a re-record) is a logged no-op.
515fn acknowledge_result(
516    workflow_id: &WorkflowId,
517    activity_id: &ActivityId,
518    tracker: &mut UnackedResultTracker,
519) {
520    if tracker.acknowledge(workflow_id, activity_id).is_some() {
521        debug!(
522            workflow_id = %workflow_id,
523            activity_id = activity_id.sequence_position(),
524            "server acknowledged activity result; tracker entry cleared"
525        );
526    } else {
527        debug!(
528            workflow_id = %workflow_id,
529            activity_id = activity_id.sequence_position(),
530            "result ack for unknown tracker entry ignored"
531        );
532    }
533}
534
535/// Render an activity's display labels as a compact, log-friendly
536/// `key=value` list in stable key order (for example `brief=IP-001
537/// repo=ablative-io/yggdrasil`). Empty when the workflow attached none.
538fn render_labels(labels: &BTreeMap<String, String>) -> String {
539    labels
540        .iter()
541        .map(|(key, value)| format!("{key}={value}"))
542        .collect::<Vec<_>>()
543        .join(" ")
544}
545
546fn spawn_activity<D>(
547    task: ActivityTask,
548    permit: tokio::sync::OwnedSemaphorePermit,
549    dispatcher: Arc<D>,
550    result_sender: mpsc::UnboundedSender<DispatchFinished>,
551    heartbeat_sender: mpsc::UnboundedSender<HeartbeatRequest>,
552    heartbeat_bookkeeper: &HeartbeatBookkeeper,
553    in_flight: &mut HashMap<ActivityExecutionKey, InFlightActivity>,
554) -> Result<(), WorkerError>
555where
556    D: ActivityDispatcher,
557{
558    info!(
559        activity_type = %task.activity_type,
560        activity_id = task.activity_id.sequence_position(),
561        workflow_id = %task.workflow_id,
562        attempt = task.attempt,
563        labels = %render_labels(&task.labels),
564        "received activity task"
565    );
566    let key = ActivityExecutionKey::new(task.workflow_id.clone(), task.activity_id.clone());
567    heartbeat_bookkeeper.register(key.clone())?;
568    let (context, cancellation_handle) = ActivityContext::for_task(
569        task.workflow_id.clone(),
570        task.run_id.clone(),
571        task.activity_id.clone(),
572        task.attempt,
573        task.idempotency_key.clone(),
574        Some(heartbeat_sender),
575    );
576    let finished_key = key.clone();
577    let finished_run_id = task.run_id.clone();
578    let finished_completion_token = task.completion_token.clone();
579    let join_handle = tokio::spawn(async move {
580        let outcome = dispatcher.dispatch(task, context).await;
581        if result_sender
582            .send(DispatchFinished {
583                key: finished_key,
584                run_id: finished_run_id,
585                completion_token: finished_completion_token,
586                outcome,
587            })
588            .is_err()
589        {
590            debug!("worker loop stopped before dispatch outcome could be delivered");
591        }
592        drop(permit);
593    });
594    in_flight.insert(
595        key,
596        InFlightActivity {
597            cancellation_handle,
598            join_handle,
599        },
600    );
601    Ok(())
602}
603
604fn deliver_cancellation(
605    workflow_id: WorkflowId,
606    activity_id: &ActivityId,
607    in_flight: &HashMap<ActivityExecutionKey, InFlightActivity>,
608) {
609    let key = ActivityExecutionKey::new(workflow_id, activity_id.clone());
610    if let Some(in_flight_activity) = in_flight.get(&key) {
611        in_flight_activity.cancellation_handle.cancel();
612        info!(
613            activity_id = activity_id.sequence_position(),
614            "delivered cooperative activity cancellation"
615        );
616    }
617}
618
619fn cancel_all_in_flight(in_flight: &HashMap<ActivityExecutionKey, InFlightActivity>) {
620    for (key, in_flight_activity) in in_flight {
621        in_flight_activity.cancellation_handle.cancel();
622        info!(
623            activity_id = key.activity_id.sequence_position(),
624            workflow_id = %key.workflow_id,
625            "delivered cooperative activity cancellation during worker shutdown"
626        );
627    }
628}
629
630#[derive(Debug, thiserror::Error)]
631#[error("worker max_concurrency must be greater than zero")]
632struct InvalidMaxConcurrency;
633
634#[cfg(test)]
635#[path = "loop_tests.rs"]
636mod tests;