Skip to main content

aion_server/worker/bridge/
completion_outcome.rs

1//! Turning one settled dispatch into the engine-facing reason string.
2//!
3//! Two of those reasons are TRANSPORT-domain classifications — a worker lost
4//! before it reported, and a worker that refused the dispatch it had no slot
5//! for. Both say the activity never ran, both ride the shared transport-loss
6//! ledger, and both are worded apart so an operator is not sent looking for a
7//! fault that is not there. The completion funnel that consumes them, and the
8//! payload decode its success arm needs, live here with them.
9//!
10//! Split out of `bridge.rs`: this is one closed cluster with a single entry
11//! point, and keeping it beside the dispatcher pushed that file past the
12//! per-file length budget.
13
14use aion_core::{ActivityErrorKind, ActivityId, ContentType, Payload, WorkflowId};
15
16use crate::error::ServerError;
17use crate::worker::dispatch::{ActivityCompletion, ActivityCompletionOutcome};
18
19use super::PendingActivities;
20
21impl PendingActivities {
22    /// The transport-loss ledger this sink classifies worker deaths through.
23    #[must_use]
24    pub const fn transport_losses(&self) -> &crate::worker::transport_loss::TransportLossLedger {
25        &self.transport_losses
26    }
27
28    /// Classify one worker loss for `(workflow_id, activity_id)` into the
29    /// transport-domain reason the engine seam consumes.
30    ///
31    /// A ledger failure (poisoned lock) is reported as transport exhaustion
32    /// rather than as a re-dispatchable loss: with no trustworthy budget the
33    /// only safe answer is the one that terminates, because an unbounded
34    /// re-dispatch is the failure mode the budget exists to prevent.
35    pub(super) fn classify_worker_loss(
36        &self,
37        workflow_id: &WorkflowId,
38        activity_id: &ActivityId,
39        worker_id: crate::worker::registry::WorkerId,
40    ) -> String {
41        let detail = crate::worker::transport_loss::worker_lost_detail(worker_id);
42        match self
43            .transport_losses
44            .record_loss(workflow_id, activity_id, &detail)
45        {
46            Ok(verdict) => {
47                if verdict.exhausted {
48                    tracing::error!(
49                        operation = "activity_complete",
50                        workflow_id = %workflow_id,
51                        activity_id = %activity_id,
52                        worker_id = ?worker_id,
53                        error_type = "TransportExhausted",
54                        losses = verdict.losses,
55                        budget_ms = self.transport_losses.budget().as_millis(),
56                        "activity abandoned: the transport kept losing its worker past the \
57                         transport-loss budget"
58                    );
59                } else {
60                    tracing::warn!(
61                        operation = "activity_complete",
62                        workflow_id = %workflow_id,
63                        activity_id = %activity_id,
64                        worker_id = ?worker_id,
65                        error_type = "WorkerLost",
66                        losses = verdict.losses,
67                        budget_ms = self.transport_losses.budget().as_millis(),
68                        "worker lost before reporting an activity result; the activity never ran \
69                         and will be re-dispatched attempt-neutrally"
70                    );
71                }
72                verdict.reason
73            }
74            Err(error) => {
75                tracing::error!(
76                    workflow_id = %workflow_id,
77                    activity_id = %activity_id,
78                    %error,
79                    "transport-loss ledger is unreadable; abandoning the activity rather than \
80                     re-dispatching it without a budget"
81                );
82                format!(
83                    "{}{detail} (transport-loss budget unreadable: {error})",
84                    crate::worker::transport_loss::TRANSPORT_EXHAUSTED_REASON_PREFIX
85                )
86            }
87        }
88    }
89
90    /// Classify a worker's admission refusal into the TRANSPORT domain.
91    ///
92    /// Same domain as a worker loss and the same engine-facing consequence —
93    /// the activity never ran, so it is re-dispatched attempt-neutrally and the
94    /// action's authored retry budget is untouched — but a different fact, said
95    /// differently: nothing died, and an operator told "worker lost" about a
96    /// healthy worker would go looking for a fault that is not there.
97    ///
98    /// It rides the SAME transport-loss ledger, and that is deliberate rather
99    /// than incidental. Attempt-neutral must not mean unbounded: with capacity
100    /// on the wire the server does not select a full worker, so a refusal means
101    /// the two counts have drifted, and a drift that keeps recurring is a real
102    /// pathology that has to terminate rather than re-dispatch for ever. The
103    /// ledger is the ceiling that already exists for exactly that shape, and
104    /// giving this its own would be a second bound for one job.
105    ///
106    /// Logged at INFO, not WARN: a single refusal is a correction the system
107    /// made for itself, and the ledger's own escalation is what says when it
108    /// has stopped being one.
109    fn classify_admission_refusal(
110        &self,
111        workflow_id: &WorkflowId,
112        activity_id: &ActivityId,
113        worker_id: crate::worker::registry::WorkerId,
114        reason: &str,
115    ) -> String {
116        let detail = format!("worker {worker_id:?} refused the dispatch: {reason}");
117        match self
118            .transport_losses
119            .record_loss(workflow_id, activity_id, &detail)
120        {
121            Ok(verdict) if verdict.exhausted => {
122                tracing::error!(
123                    operation = "activity_complete",
124                    workflow_id = %workflow_id,
125                    activity_id = %activity_id,
126                    worker_id = ?worker_id,
127                    error_type = "TransportExhausted",
128                    refusals = verdict.losses,
129                    budget_ms = self.transport_losses.budget().as_millis(),
130                    reason,
131                    "activity abandoned: workers kept refusing it past the transport-loss budget.                      The server tracks every worker's advertised concurrency and does not select                      a full one, so a refusal this persistent means the server's count and the                      worker's admission disagree"
132                );
133                verdict.reason
134            }
135            Ok(verdict) => {
136                tracing::info!(
137                    operation = "activity_complete",
138                    workflow_id = %workflow_id,
139                    activity_id = %activity_id,
140                    worker_id = ?worker_id,
141                    refusals = verdict.losses,
142                    reason,
143                    "worker refused a dispatch it had no slot for; re-parking it clock-free and                      re-selecting, with no attempt consumed and nothing recorded"
144                );
145                verdict.reason
146            }
147            Err(error) => {
148                tracing::error!(
149                    workflow_id = %workflow_id,
150                    activity_id = %activity_id,
151                    %error,
152                    "transport-loss ledger is unreadable; abandoning the refused activity rather                      than re-dispatching it without a budget"
153                );
154                format!(
155                    "{}{detail} (transport-loss budget unreadable: {error})",
156                    crate::worker::transport_loss::TRANSPORT_EXHAUSTED_REASON_PREFIX
157                )
158            }
159        }
160    }
161
162    pub(crate) fn complete_activity_after_accept(
163        &self,
164        completion: ActivityCompletion,
165        after_accept: impl FnOnce() -> Result<(), ServerError>,
166    ) -> Result<(), ServerError> {
167        let result = match completion.outcome {
168            ActivityCompletionOutcome::Succeeded(payload) => {
169                payload_to_string(&payload).map_err(|reason| {
170                    tracing::error!(
171                        operation = "activity_complete",
172                        workflow_id = %completion.workflow_id,
173                        activity_id = %completion.activity_id,
174                        error_type = "ActivityResultDecode",
175                        %reason,
176                        "activity completion failed"
177                    );
178                    ServerError::worker_dispatch("", "", format!("payload decode: {reason}"))
179                })?
180            }
181            ActivityCompletionOutcome::Failed(error) => {
182                let prefix = match error.kind {
183                    ActivityErrorKind::Retryable => "retryable",
184                    ActivityErrorKind::PolicyRefused => "policy_refused",
185                    ActivityErrorKind::Terminal => "terminal",
186                };
187                tracing::error!(
188                    operation = "activity_complete",
189                    workflow_id = %completion.workflow_id,
190                    activity_id = %completion.activity_id,
191                    error_type = "ActivityFailed",
192                    error_kind = prefix,
193                    reason = %error.message,
194                    "activity completion failed"
195                );
196                Err(format!("{prefix}:{}", error.message))
197            }
198            ActivityCompletionOutcome::WorkerLost { worker_id } => Err(self.classify_worker_loss(
199                &completion.workflow_id,
200                &completion.activity_id,
201                worker_id,
202            )),
203            ActivityCompletionOutcome::Refused { worker_id, reason } => Err(self
204                .classify_admission_refusal(
205                    &completion.workflow_id,
206                    &completion.activity_id,
207                    worker_id,
208                    &reason,
209                )),
210        };
211        let accepted_settlement = || after_accept().map(|()| true);
212        self.complete_fenced_after_accept(
213            &completion.workflow_id,
214            &completion.activity_id,
215            completion.run_id.as_ref(),
216            &completion.completion_token,
217            result,
218            accepted_settlement,
219        )?;
220        Ok(())
221    }
222}
223
224fn payload_to_string(payload: &Payload) -> Result<Result<String, String>, String> {
225    match payload.content_type() {
226        ContentType::Json => String::from_utf8(payload.bytes().to_vec())
227            .map(Ok)
228            .map_err(|_| "activity result payload is not valid UTF-8".to_owned()),
229    }
230}