aion_server/worker/bridge/
completion_outcome.rs1use 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 #[must_use]
24 pub const fn transport_losses(&self) -> &crate::worker::transport_loss::TransportLossLedger {
25 &self.transport_losses
26 }
27
28 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 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}