Skip to main content

aion_server/api/
worker_grpc.rs

1//! tonic `WorkerProtocol` service — bidirectional stream handler.
2
3use aion_proto::{
4    ProtoActivityDescriptor, ProtoActivityResult, ProtoRegisterWorker, ProtoWorkerInstanceIdentity,
5    generated::{
6        self,
7        worker_protocol_server::{WorkerProtocol, WorkerProtocolServer},
8    },
9};
10use tokio::sync::mpsc;
11use tokio_stream::wrappers::ReceiverStream;
12use tonic::{Request, Response, Status, Streaming};
13
14use crate::worker::PendingActivities;
15use crate::worker::dispatch::{ActivityCompletion, ActivityCompletionSink};
16use crate::worker::registry::{WorkerId, WorkerMessage};
17use crate::{CallerIdentity, ServerState};
18
19/// Cloneable tonic implementation for the worker bidirectional stream.
20#[derive(Clone)]
21pub struct WorkerGrpcService {
22    state: ServerState,
23}
24
25impl WorkerGrpcService {
26    /// Build a tonic worker service from shared server state.
27    #[must_use]
28    pub const fn new(state: ServerState) -> Self {
29        Self { state }
30    }
31}
32
33/// Construct the generated tonic server wrapper for the worker protocol.
34#[must_use]
35pub fn worker_service(state: ServerState) -> WorkerProtocolServer<WorkerGrpcService> {
36    WorkerProtocolServer::new(WorkerGrpcService::new(state))
37}
38
39#[tonic::async_trait]
40impl WorkerProtocol for WorkerGrpcService {
41    type StreamWorkerStream = ReceiverStream<Result<generated::ServerToWorker, Status>>;
42
43    async fn stream_worker(
44        &self,
45        request: Request<Streaming<generated::WorkerToServer>>,
46    ) -> Result<Response<Self::StreamWorkerStream>, Status> {
47        let metadata = request.metadata().clone();
48        let caller = worker_caller_from_metadata(&metadata, &self.state).await?;
49        let token_expires_at = token_expiration_from_metadata(&metadata, &self.state).await?;
50        let heartbeat_grace = self.state.runtime_config().worker.heartbeat_window;
51        let mut inbound = request.into_inner();
52
53        let first = inbound
54            .message()
55            .await?
56            .and_then(|msg| msg.message)
57            .ok_or_else(|| Status::invalid_argument("first message must be RegisterWorker"))?;
58
59        let register = match first {
60            generated::worker_to_server::Message::Register(r) => decode_register(r),
61            _ => {
62                return Err(Status::invalid_argument(
63                    "first message must be RegisterWorker",
64                ));
65            }
66        };
67        validate_worker_contracts(&self.state, &register)?;
68
69        let (task_tx, task_rx) = mpsc::channel::<Result<generated::ServerToWorker, Status>>(32);
70        let (worker_tx, worker_rx) = mpsc::channel(32);
71
72        let registration = self
73            .state
74            .worker_registry()
75            .accept_registration(self.state.namespace_guard(), &caller, &register, worker_tx)
76            .await
77            .map_err(|error| status_from_server_error(&error))?;
78
79        let pending = self.state.pending_activities().clone();
80        let heartbeat = self.state.heartbeat_tracker().clone();
81        let drain = self.state.drain_state().clone();
82        let registry = self.state.worker_registry().clone();
83        let worker_id = registration
84            .worker_id()
85            .ok_or_else(|| Status::internal("worker registration missing id"))?;
86        heartbeat
87            .register_connection(worker_id, std::time::Instant::now())
88            .map_err(|error| status_from_server_error(&error))?;
89        // A worker serves a SET of namespaces; the ack echoes them joined in
90        // stable order purely for the worker's logs (the RegisterAck namespace
91        // field is informational, not a routing input).
92        let authorized_namespace = registration
93            .namespaces()
94            .filter(|namespaces| !namespaces.is_empty())
95            .ok_or_else(|| Status::internal("worker registration missing namespace"))?
96            .iter()
97            .cloned()
98            .collect::<Vec<_>>()
99            .join(",");
100
101        // RegisterAck ordering guarantee: the ack is enqueued on `task_tx`
102        // BEFORE the write forwarder that copies dispatched tasks onto the
103        // same channel is spawned, so no task frame can precede it on the
104        // wire. This is a structural ordering proof, not a timing hope.
105        task_tx
106            .try_send(Ok(register_ack_frame(
107                worker_id,
108                &authorized_namespace,
109                heartbeat_grace,
110            )))
111            .map_err(|_| Status::internal("worker response channel closed before RegisterAck"))?;
112
113        tokio::spawn(async move {
114            let write_handle = spawn_write_forwarder(worker_rx, task_tx.clone());
115
116            // Armed BEFORE the inbound loop runs: the sweep in its `Drop`
117            // fires on every exit from this task — clean stream end, stream
118            // error, token expiry, even a panic unwinding `process_inbound`.
119            // The unbounded dispatch wait depends on it.
120            let teardown = StreamTeardown {
121                worker_id,
122                heartbeat: &heartbeat,
123                registry: &registry,
124                pending: &pending,
125                drain: &drain,
126            };
127            let session = WorkerSession {
128                worker_id,
129                pending: &pending,
130                heartbeat: &heartbeat,
131                drain: &drain,
132                token_expires_at,
133                heartbeat_grace,
134                task_tx: task_tx.clone(),
135            };
136            if let Err(status) = process_inbound(inbound, session).await {
137                tracing::info!(
138                    worker_id = ?worker_id,
139                    %status,
140                    "worker stream closed with status"
141                );
142            }
143
144            write_handle.abort();
145            drop(task_tx);
146            drop(teardown);
147            // The teardown sweep already deregistered the stream; consuming
148            // the registration here is an idempotent no-op that still
149            // surfaces a poisoned-lock error loudly.
150            if let Err(error) = registration.deregister() {
151                tracing::error!(
152                    worker_id = ?worker_id,
153                    %error,
154                    "worker deregistration failed during stream teardown"
155                );
156            }
157        });
158
159        Ok(Response::new(ReceiverStream::new(task_rx)))
160    }
161}
162
163/// Spawn the write forwarder: it copies registry-delivered [`WorkerMessage`]s
164/// onto the worker's response stream, and — the #176 zombie fix — TERMINATES
165/// the RPC when the registry drops this worker's delivery sender.
166///
167/// `recv` returning `None` means every delivery sender is gone: the worker
168/// was DEREGISTERED while its stream stayed open (the heartbeat expiry sweep,
169/// or any future administrative removal). Silently deregistering would leave
170/// the worker a zombie — connected but unroutable, believing it is
171/// registered, its heartbeats rejected as "not in flight", never
172/// re-registering until its own stream happens to end. Ending the RPC with a
173/// retryable `Unavailable` status makes the worker OBSERVE the
174/// deregistration and re-register through its reconnect machinery. (On the
175/// normal teardown path this task is aborted before the registration is
176/// consumed, so the status is never sent to a worker that hung up; a send
177/// failure means the response stream's consumer is already gone, so there is
178/// no one left to signal.)
179fn spawn_write_forwarder(
180    mut worker_rx: mpsc::Receiver<WorkerMessage>,
181    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
182) -> tokio::task::JoinHandle<()> {
183    tokio::spawn(async move {
184        while let Some(message) = worker_rx.recv().await {
185            let msg = encode_server_to_worker(message);
186            if task_tx.send(Ok(msg)).await.is_err() {
187                return;
188            }
189        }
190        let _ = task_tx
191            .send(Err(Status::unavailable(
192                "worker was deregistered by the server (heartbeat window expired); \
193                 reconnect and re-register",
194            )))
195            .await;
196    })
197}
198
199/// Drop guard that sweeps a torn-down worker stream's in-flight activities:
200/// failed back to the engine mid-run, or parked for restart recovery under a
201/// graceful drain (#207) — see [`teardown_worker_stream`].
202///
203/// A guard rather than a call site so the sweep cannot be skipped by any
204/// exit from the stream task — including a panic unwinding the inbound
205/// loop, which would otherwise leave every dispatch blocked on that worker
206/// waiting forever.
207struct StreamTeardown<'a> {
208    worker_id: WorkerId,
209    heartbeat: &'a crate::worker::HeartbeatTracker,
210    registry: &'a crate::worker::ConnectedWorkerRegistry,
211    pending: &'a PendingActivities,
212    drain: &'a crate::shutdown::DrainState,
213}
214
215impl Drop for StreamTeardown<'_> {
216    fn drop(&mut self) {
217        teardown_worker_stream(
218            self.worker_id,
219            self.heartbeat,
220            self.registry,
221            self.pending,
222            self.drain,
223        );
224    }
225}
226
227/// Sweep a torn-down worker stream's in-flight activities.
228///
229/// The stream is the worker's liveness. When it ends mid-run — process death,
230/// network disconnect, expired token — every activity still assigned to this
231/// worker must be failed back through the completion sink as a retryable
232/// lost-worker error. The activity dispatch wait is unbounded by design (the
233/// engine imposes no activity timeout), so this sweep is what unblocks
234/// dispatches whose worker died mid-activity; the engine's retry policy then
235/// decides re-dispatch.
236///
237/// Under a graceful drain (#207) the stream ending is the EXPECTED worker
238/// response to the drain request, not a death: the worker's in-flight tasks
239/// are PARKED for restart recovery instead — nothing is recorded, nothing is
240/// delivered, the durable log keeps its dangling scheduled/started trail
241/// exactly as a kill -9 would, and post-restart replay re-dispatches it. The
242/// park-vs-fail branch keys on
243/// [`DrainState::is_draining`](crate::shutdown::DrainState::is_draining): a
244/// worker lost while the server is NOT draining still fails-and-retries
245/// byte-identically to before.
246fn teardown_worker_stream(
247    worker_id: WorkerId,
248    heartbeat: &crate::worker::HeartbeatTracker,
249    registry: &crate::worker::ConnectedWorkerRegistry,
250    pending: &PendingActivities,
251    drain: &crate::shutdown::DrainState,
252) {
253    if drain.is_draining() {
254        match heartbeat.park_disconnected_worker(worker_id, registry, pending) {
255            Ok(report) if report.tasks.is_empty() => {}
256            Ok(report) => {
257                tracing::info!(
258                    worker_id = ?worker_id,
259                    parked_tasks = report.tasks.len(),
260                    "worker stream ended during drain; in-flight activities \
261                     parked for restart recovery"
262                );
263            }
264            Err(error) => {
265                tracing::error!(
266                    worker_id = ?worker_id,
267                    %error,
268                    "failed to park draining worker's in-flight activities"
269                );
270            }
271        }
272    } else {
273        match heartbeat.fail_disconnected_worker(worker_id, registry, pending) {
274            Ok(report) if report.tasks.is_empty() => {}
275            Ok(report) => {
276                tracing::warn!(
277                    worker_id = ?worker_id,
278                    failed_tasks = report.tasks.len(),
279                    "worker disconnected with in-flight activities; \
280                     surfaced as transport losses, to be re-dispatched \
281                     attempt-neutrally"
282                );
283            }
284            Err(error) => {
285                tracing::error!(
286                    worker_id = ?worker_id,
287                    %error,
288                    "failed to sweep disconnected worker's in-flight activities"
289                );
290            }
291        }
292    }
293    // In-flight accounting may have just reached zero; wake any drain
294    // waiter so shutdown does not sit out its full timeout.
295    drain.notify_activity_drained();
296}
297
298struct WorkerSession<'a> {
299    worker_id: WorkerId,
300    pending: &'a PendingActivities,
301    heartbeat: &'a crate::worker::HeartbeatTracker,
302    drain: &'a crate::shutdown::DrainState,
303    token_expires_at: Option<u64>,
304    heartbeat_grace: std::time::Duration,
305    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
306}
307
308async fn process_inbound(
309    mut inbound: Streaming<generated::WorkerToServer>,
310    session: WorkerSession<'_>,
311) -> Result<(), Status> {
312    let mut expired_since: Option<std::time::Instant> = None;
313    while let Some(msg) = inbound.message().await? {
314        refresh_connection_lease(&session)?;
315        let Some(inner) = msg.message else {
316            continue;
317        };
318        match inner {
319            generated::worker_to_server::Message::Result(result) => {
320                let proto_result = decode_activity_result(result);
321                match ActivityCompletion::try_from(proto_result) {
322                    Ok(completion) => {
323                        let workflow_id = completion.workflow_id.clone();
324                        let activity_id = completion.activity_id.clone();
325                        match session.pending.complete_activity(completion) {
326                            Ok(()) => {
327                                if let Err(error) = session.heartbeat.complete_task(
328                                    session.worker_id,
329                                    &workflow_id,
330                                    &activity_id,
331                                ) {
332                                    // A poisoned liveness tracker would also break
333                                    // the lost-worker sweep the unbounded dispatch
334                                    // wait relies on — never swallow it.
335                                    tracing::error!(
336                                        worker_id = ?session.worker_id,
337                                        workflow_id = %workflow_id,
338                                        activity_id = %activity_id,
339                                        %error,
340                                        "failed to clear in-flight tracking for completed activity"
341                                    );
342                                }
343                                session.drain.notify_activity_drained();
344                            }
345                            Err(error) => {
346                                // Leave the current worker's liveness entry intact
347                                // when the generation proof is wrong. Its stopped
348                                // heartbeat pump will drive the normal loss/retry path.
349                                tracing::error!(
350                                    worker_id = ?session.worker_id,
351                                    workflow_id = %workflow_id,
352                                    activity_id = %activity_id,
353                                    %error,
354                                    "activity completion handoff failed"
355                                );
356                            }
357                        }
358                        // Ack every well-formed result frame — including
359                        // duplicates with no pending waiter; their re-report
360                        // obligation is equally discharged. `try_send`: a
361                        // worker that stopped draining its receive side must
362                        // not wedge the inbound loop; a dropped ack is
363                        // recovered by the next-session re-report.
364                        let ack = result_ack_frame(&workflow_id, &activity_id);
365                        if let Err(error) = session.task_tx.try_send(Ok(ack)) {
366                            tracing::warn!(
367                                worker_id = ?session.worker_id,
368                                workflow_id = %workflow_id,
369                                activity_id = %activity_id,
370                                %error,
371                                "result ack dropped: worker stream channel unavailable"
372                            );
373                        }
374                    }
375                    Err(error) => {
376                        // Malformed result: no ids to ack with. Loud, never
377                        // silent — the worker's entry will re-report and
378                        // re-fail visibly each session.
379                        tracing::error!(
380                            worker_id = ?session.worker_id,
381                            %error,
382                            "malformed activity result frame; no ack sent"
383                        );
384                    }
385                }
386            }
387            generated::worker_to_server::Message::Register(_) => {
388                warn_duplicate_registration(session.worker_id);
389            }
390            generated::worker_to_server::Message::Heartbeat(heartbeat_msg) => {
391                // Empty task ids are the connection-level lease beat. The frame
392                // already advanced the lease above; it intentionally has no
393                // per-task liveness entry to update.
394                if heartbeat_msg.workflow_id.is_none() && heartbeat_msg.activity_id.is_none() {
395                    continue;
396                }
397                if let Err(error) = session.heartbeat.record_heartbeat(
398                    session.worker_id,
399                    decode_heartbeat(heartbeat_msg),
400                    std::time::Instant::now(),
401                ) {
402                    // Malformed frames and heartbeats for untracked tasks
403                    // are worker-side defects worth surfacing; a poisoned
404                    // tracker lock is a server-side corruption signal that
405                    // must never vanish silently.
406                    if matches!(error, crate::ServerError::LockPoisoned { .. }) {
407                        tracing::error!(
408                            worker_id = ?session.worker_id,
409                            %error,
410                            "heartbeat tracker lock poisoned; liveness state untrustworthy"
411                        );
412                    } else {
413                        tracing::warn!(
414                            worker_id = ?session.worker_id,
415                            %error,
416                            "worker heartbeat rejected"
417                        );
418                    }
419                }
420                enforce_token_expiration(&session, &mut expired_since).await?;
421            }
422        }
423    }
424    Ok(())
425}
426
427fn refresh_connection_lease(session: &WorkerSession<'_>) -> Result<(), Status> {
428    session
429        .heartbeat
430        .record_connection_activity(session.worker_id, std::time::Instant::now())
431        .map(|_| ())
432        .map_err(|error| {
433            tracing::error!(
434                worker_id = ?session.worker_id,
435                %error,
436                "failed to advance worker connection lease"
437            );
438            status_from_server_error(&error)
439        })
440}
441
442fn warn_duplicate_registration(worker_id: WorkerId) {
443    tracing::warn!(
444        worker_id = ?worker_id,
445        "ignoring subsequent RegisterWorker message; \
446         only the first registration is accepted per stream"
447    );
448}
449
450async fn enforce_token_expiration(
451    session: &WorkerSession<'_>,
452    expired_since: &mut Option<std::time::Instant>,
453) -> Result<(), Status> {
454    if !token_expired(session.token_expires_at) {
455        return Ok(());
456    }
457    let first_expired = *expired_since.get_or_insert_with(std::time::Instant::now);
458    let _ = session
459        .task_tx
460        .send(Err(Status::unauthenticated(
461            "worker token expired; re-authentication required",
462        )))
463        .await;
464    if first_expired.elapsed() >= session.heartbeat_grace {
465        return Err(Status::unauthenticated("worker token expired"));
466    }
467    Ok(())
468}
469
470async fn worker_caller_from_metadata(
471    metadata: &tonic::metadata::MetadataMap,
472    state: &ServerState,
473) -> Result<CallerIdentity, Status> {
474    crate::api::grpc::caller_from_metadata(metadata, state).await
475}
476
477async fn token_expiration_from_metadata(
478    metadata: &tonic::metadata::MetadataMap,
479    state: &ServerState,
480) -> Result<Option<u64>, Status> {
481    if !state.runtime_config().auth.enabled {
482        return Ok(None);
483    }
484    #[cfg(feature = "auth")]
485    {
486        let bearer = metadata
487            .get("authorization")
488            .and_then(|value| value.to_str().ok())
489            .and_then(parse_bearer)
490            .ok_or_else(|| Status::unauthenticated("missing bearer token"))?;
491        let Some(cache) = state.jwks_cache() else {
492            return Err(Status::unauthenticated("invalid bearer token"));
493        };
494        return cache
495            .validate(&bearer)
496            .await
497            .map(|claims| Some(claims.expires_at()))
498            .map_err(|_error| Status::unauthenticated("invalid bearer token"));
499    }
500    #[cfg(not(feature = "auth"))]
501    {
502        let _ = metadata;
503        // Yield to preserve the async signature required by the auth-feature branch.
504        tokio::task::yield_now().await;
505        Ok(None)
506    }
507}
508
509#[cfg(feature = "auth")]
510fn parse_bearer(value: &str) -> Option<String> {
511    let token = value.strip_prefix("Bearer ")?.trim();
512    if token.is_empty() {
513        return None;
514    }
515    Some(token.to_owned())
516}
517
518fn token_expired(expires_at: Option<u64>) -> bool {
519    expires_at.is_some_and(|expires_at| {
520        #[cfg(feature = "auth")]
521        {
522            crate::auth::jwks::is_expired(expires_at)
523        }
524        #[cfg(not(feature = "auth"))]
525        {
526            let _ = expires_at;
527            false
528        }
529    })
530}
531
532fn status_from_server_error(error: &crate::ServerError) -> Status {
533    let wire = error.to_wire_error();
534    if wire.code == aion_proto::WireErrorCode::NamespaceDenied {
535        Status::permission_denied(wire.message)
536    } else {
537        Status::internal(wire.message)
538    }
539}
540
541/// Build the positive registration acknowledgement frame — the guaranteed
542/// first frame on every successful worker response stream.
543fn register_ack_frame(
544    worker_id: WorkerId,
545    namespace: &str,
546    heartbeat_window: std::time::Duration,
547) -> generated::ServerToWorker {
548    generated::ServerToWorker {
549        message: Some(generated::server_to_worker::Message::RegisterAck(
550            generated::RegisterAck {
551                worker_id: worker_id.value(),
552                namespace: namespace.to_owned(),
553                heartbeat_window_ms: u64::try_from(heartbeat_window.as_millis())
554                    .unwrap_or(u64::MAX),
555            },
556        )),
557    }
558}
559
560/// Build the per-result acknowledgement frame for a consumed `ActivityResult`.
561fn result_ack_frame(
562    workflow_id: &aion_core::WorkflowId,
563    activity_id: &aion_core::ActivityId,
564) -> generated::ServerToWorker {
565    generated::ServerToWorker {
566        message: Some(generated::server_to_worker::Message::ResultAck(
567            generated::ResultAck {
568                workflow_id: Some(generated::WorkflowId {
569                    uuid: workflow_id.to_string(),
570                }),
571                activity_id: Some(generated::ActivityId {
572                    sequence_position: activity_id.sequence_position(),
573                }),
574            },
575        )),
576    }
577}
578
579fn decode_register(r: generated::RegisterWorker) -> ProtoRegisterWorker {
580    ProtoRegisterWorker {
581        namespaces: r.namespaces,
582        activity_types: r.activity_types,
583        task_queue: r.task_queue,
584        node: r.node,
585        activities: r
586            .activities
587            .into_iter()
588            .map(|activity| ProtoActivityDescriptor {
589                name: activity.name,
590                input_schema_json: activity.input_schema_json,
591                output_schema_json: activity.output_schema_json,
592            })
593            .collect(),
594        identity: r.identity,
595        instance: r.instance.map(|instance| ProtoWorkerInstanceIdentity {
596            deployment: instance.deployment,
597            instance_id: instance.instance_id,
598        }),
599    }
600}
601
602fn validate_worker_contracts(
603    state: &ServerState,
604    register: &ProtoRegisterWorker,
605) -> Result<(), Status> {
606    let advertised = register
607        .activities
608        .iter()
609        .map(|activity| {
610            let input_schema =
611                serde_json::from_str(&activity.input_schema_json).map_err(|error| {
612                    Status::invalid_argument(format!(
613                        "worker activity `{}` input_schema_json is invalid: {error}",
614                        activity.name
615                    ))
616                })?;
617            let output_schema =
618                serde_json::from_str(&activity.output_schema_json).map_err(|error| {
619                    Status::invalid_argument(format!(
620                        "worker activity `{}` output_schema_json is invalid: {error}",
621                        activity.name
622                    ))
623                })?;
624            Ok(aion_package::ActivityDescriptor {
625                name: activity.name.clone(),
626                input_schema,
627                output_schema,
628            })
629        })
630        .collect::<Result<Vec<_>, Status>>()?;
631    // Mirrors the liminal transport's no-catalog admission: a state built
632    // from parts without an engine handle has no durable catalog, so no
633    // deployed `.v4` contract can exist to contradict this worker. Refusing
634    // here would turn every registration away forever on such a server; the
635    // strict field-level check applies on every engine-backed state.
636    let Ok(engine) = state.engine() else {
637        tracing::warn!(
638            task_queue = %register.task_queue,
639            identity = %register.identity,
640            "worker contract check skipped: server state has no engine handle, \
641             so no deployed contracts exist to check against"
642        );
643        return Ok(());
644    };
645    // Both advertised forms travel into the gate together — see
646    // [`crate::worker::contracts::WorkerAdvertisement`] for why carrying only
647    // one of them makes a refusal contradict itself.
648    let activity_types = register
649        .activity_types
650        .iter()
651        .cloned()
652        .collect::<std::collections::BTreeSet<_>>();
653    crate::worker::contracts::validate_worker_contracts(
654        &engine,
655        state.worker_registry().admission_audit(),
656        &register.task_queue,
657        crate::worker::registry::optional_node(&register.node).as_deref(),
658        &register.identity,
659        crate::worker::contracts::WorkerAdvertisement {
660            activity_types: &activity_types,
661            contracts: &advertised,
662        },
663    )
664    .map_err(|error| match error {
665        crate::worker::contracts::ContractAdmissionError::Mismatch { .. } => {
666            Status::failed_precondition(error.to_string())
667        }
668        crate::worker::contracts::ContractAdmissionError::Catalog { .. } => {
669            Status::internal(error.to_string())
670        }
671    })
672}
673
674fn encode_server_to_worker(message: WorkerMessage) -> generated::ServerToWorker {
675    let message = match message {
676        WorkerMessage::ActivityTask(task) => {
677            generated::server_to_worker::Message::Task(encode_task(*task))
678        }
679        WorkerMessage::DrainRequest => {
680            generated::server_to_worker::Message::Drain(generated::DrainRequest {})
681        }
682    };
683    generated::ServerToWorker {
684        message: Some(message),
685    }
686}
687
688fn encode_task(task: aion_proto::ProtoActivityTask) -> generated::ActivityTask {
689    generated::ActivityTask {
690        workflow_id: task
691            .workflow_id
692            .map(|id| generated::WorkflowId { uuid: id.uuid }),
693        activity_id: task.activity_id.map(|id| generated::ActivityId {
694            sequence_position: id.sequence_position,
695        }),
696        activity_type: task.activity_type,
697        input: task.input.map(|p| generated::Payload {
698            content_type: p.content_type,
699            bytes: p.bytes,
700        }),
701        attempt: task.attempt,
702        labels: task.labels,
703        run_id: task.run_id.map(|id| generated::RunId { uuid: id.uuid }),
704        completion_token: task.completion_token,
705        idempotency_key: task.idempotency_key,
706    }
707}
708
709fn decode_activity_result(r: generated::ActivityResult) -> ProtoActivityResult {
710    ProtoActivityResult {
711        workflow_id: r
712            .workflow_id
713            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
714        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
715            sequence_position: id.sequence_position,
716        }),
717        outcome: r.outcome.map(decode_outcome),
718        run_id: r.run_id.map(|id| aion_proto::ProtoRunId { uuid: id.uuid }),
719        completion_token: r.completion_token,
720    }
721}
722
723fn decode_heartbeat(r: generated::Heartbeat) -> aion_proto::ProtoHeartbeat {
724    aion_proto::ProtoHeartbeat {
725        workflow_id: r
726            .workflow_id
727            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
728        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
729            sequence_position: id.sequence_position,
730        }),
731        progress: r.progress.map(|p| aion_proto::ProtoPayload {
732            content_type: p.content_type,
733            bytes: p.bytes,
734        }),
735    }
736}
737
738fn decode_outcome(
739    outcome: generated::activity_result::Outcome,
740) -> aion_proto::proto_activity_result::Outcome {
741    match outcome {
742        generated::activity_result::Outcome::Result(p) => {
743            aion_proto::proto_activity_result::Outcome::Result(aion_proto::ProtoPayload {
744                content_type: p.content_type,
745                bytes: p.bytes,
746            })
747        }
748        generated::activity_result::Outcome::Error(e) => {
749            aion_proto::proto_activity_result::Outcome::Error(aion_proto::ProtoActivityError {
750                kind: e.kind,
751                message: e.message,
752                details: e.details.map(|p| aion_proto::ProtoPayload {
753                    content_type: p.content_type,
754                    bytes: p.bytes,
755                }),
756            })
757        }
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use std::time::{Duration, Instant};
764
765    use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
766
767    use crate::shutdown::DrainState;
768    use crate::worker::dispatch::{
769        ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
770    };
771    use crate::worker::heartbeat::InFlightActivity;
772    use crate::worker::registry::ConnectedWorkerRegistry;
773    use crate::worker::{HeartbeatTracker, PendingActivities};
774
775    use super::{decode_register, teardown_worker_stream};
776
777    type TestError = Box<dyn std::error::Error>;
778
779    #[test]
780    fn decode_register_maps_tag_seven_instance_without_changing_absent_registration() {
781        let generated = super::generated::RegisterWorker {
782            namespaces: vec!["orders".to_owned()],
783            activity_types: vec!["shell".to_owned()],
784            task_queue: "shell".to_owned(),
785            node: "node-a".to_owned(),
786            activities: Vec::new(),
787            identity: "build-a".to_owned(),
788            instance: Some(super::generated::WorkerInstanceIdentity {
789                deployment: "shells".to_owned(),
790                instance_id: "instance-1".to_owned(),
791            }),
792        };
793        let mapped = decode_register(generated.clone());
794        let instance = mapped.instance.as_ref();
795        assert_eq!(
796            instance.map(|value| value.deployment.as_str()),
797            Some("shells")
798        );
799        assert_eq!(
800            instance.map(|value| value.instance_id.as_str()),
801            Some("instance-1")
802        );
803
804        let mut absent = generated;
805        absent.instance = None;
806        let mapped_absent = decode_register(absent);
807        assert!(mapped_absent.instance.is_none());
808        assert_eq!(mapped_absent.identity, "build-a");
809    }
810
811    /// One tracked in-flight dispatch with a live pending waiter, ready for a
812    /// stream teardown: the registered worker, the shared tracker/pending/drain
813    /// state, and the waiter's receiver.
814    struct TeardownFixture {
815        registry: ConnectedWorkerRegistry,
816        tracker: HeartbeatTracker,
817        pending: PendingActivities,
818        drain: DrainState,
819        worker_id: crate::worker::registry::WorkerId,
820        workflow_id: WorkflowId,
821        activity_id: ActivityId,
822        completion_token: crate::worker::CompletionToken,
823        rx: std::sync::mpsc::Receiver<Result<String, String>>,
824        /// Held so the registered worker stays routable until the teardown
825        /// under test deregisters it (dropping the guard would race that).
826        _registration: crate::worker::registry::WorkerRegistration,
827    }
828
829    fn fixture() -> Result<TeardownFixture, TestError> {
830        let registry = ConnectedWorkerRegistry::default();
831        let (tx, _rx) = tokio::sync::mpsc::channel(1);
832        let activity_types = [String::from("greet")];
833        let registration = registry.register("default", activity_types.iter(), tx)?;
834        let worker_id = registration
835            .worker_id()
836            .ok_or("test worker registration missing id")?;
837        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
838        let pending = PendingActivities::default();
839        let workflow_id = WorkflowId::new_v4();
840        let activity_id = ActivityId::from_sequence_position(0);
841        let (completion_token, rx) =
842            pending.insert_for_test(workflow_id.clone(), activity_id.clone())?;
843        tracker.track_task(
844            worker_id,
845            InFlightActivity {
846                workflow_id: workflow_id.clone(),
847                activity_id: activity_id.clone(),
848                attempt: 1,
849                completion_token: completion_token.clone(),
850            },
851            Instant::now(),
852        )?;
853        Ok(TeardownFixture {
854            registry,
855            tracker,
856            pending,
857            drain: DrainState::default(),
858            worker_id,
859            workflow_id,
860            activity_id,
861            completion_token,
862            rx,
863            _registration: registration,
864        })
865    }
866
867    /// #207: with drain begun, a stream teardown PARKS the in-flight dispatch —
868    /// the waiter resolves with the ephemeral parked sentinel, no lost-worker
869    /// failure is synthesized, and the tracker empties for drain accounting.
870    #[test]
871    fn teardown_under_drain_parks_instead_of_failing() -> Result<(), TestError> {
872        let fixture = fixture()?;
873        assert!(fixture.drain.begin());
874
875        teardown_worker_stream(
876            fixture.worker_id,
877            &fixture.tracker,
878            &fixture.registry,
879            &fixture.pending,
880            &fixture.drain,
881        );
882
883        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
884        assert_eq!(
885            resolved,
886            Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
887            "a drain teardown must resolve the waiter with the parked sentinel"
888        );
889        assert_eq!(fixture.tracker.in_flight_count()?, 0);
890        assert!(
891            !fixture.tracker.is_tracked(
892                fixture.worker_id,
893                &fixture.workflow_id,
894                &fixture.activity_id
895            )?,
896            "parking must retire the tracked entry"
897        );
898        Ok(())
899    }
900
901    /// Regression pin: WITHOUT drain, the teardown path still resolves the
902    /// waiter with a lost-worker failure — but in the TRANSPORT domain, not the
903    /// action's.
904    ///
905    /// The old contract here was `retryable:`, and that framing was the Hit D
906    /// defect: an authored retry policy governs how often the ACTION may fail,
907    /// and an activity with no policy (the SDK default) turned a worker death
908    /// into a TERMINAL failure. Worker loss now carries the `lost:` class, which
909    /// the engine re-dispatches attempt-neutrally.
910    #[test]
911    fn teardown_without_drain_fails_with_the_transport_domain_lost_worker_class()
912    -> Result<(), TestError> {
913        let fixture = fixture()?;
914
915        teardown_worker_stream(
916            fixture.worker_id,
917            &fixture.tracker,
918            &fixture.registry,
919            &fixture.pending,
920            &fixture.drain,
921        );
922
923        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
924        let reason = resolved.err().ok_or("expected a lost-worker failure")?;
925        assert!(
926            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
927            "a mid-run teardown must surface the TRANSPORT-domain loss class, never the \
928             action's retry vocabulary: {reason}"
929        );
930        assert!(
931            reason.contains("lost before reporting activity result"),
932            "the failure must name worker loss: {reason}"
933        );
934        assert_eq!(fixture.tracker.in_flight_count()?, 0);
935        Ok(())
936    }
937
938    /// R2 fencing red: after heartbeat loss resolves worker A's attempt and a
939    /// retry installs worker B's waiter for the same activity, A's late result
940    /// must not resolve B's generation.
941    #[test]
942    fn stale_worker_completion_after_heartbeat_loss_does_not_resolve_retry() -> Result<(), TestError>
943    {
944        let fixture = fixture()?;
945        teardown_worker_stream(
946            fixture.worker_id,
947            &fixture.tracker,
948            &fixture.registry,
949            &fixture.pending,
950            &fixture.drain,
951        );
952        let first = fixture.rx.recv_timeout(Duration::from_millis(200))?;
953        assert!(
954            first
955                .err()
956                .is_some_and(|reason| reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX)),
957            "worker A loss must release attempt 1 in the transport-loss class"
958        );
959
960        let (retry_token, retry_rx) = fixture
961            .pending
962            .insert_for_test(fixture.workflow_id.clone(), fixture.activity_id.clone())?;
963        let rejected = fixture.pending.complete_activity(ActivityCompletion {
964            workflow_id: fixture.workflow_id,
965            activity_id: fixture.activity_id,
966            run_id: None,
967            completion_token: fixture.completion_token,
968            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
969                ContentType::Json,
970                br#"{"worker":"A","stale":true}"#.to_vec(),
971            )),
972        });
973
974        assert!(matches!(
975            rejected,
976            Err(crate::ServerError::ActivityCompletionRejected { .. })
977        ));
978        drop(retry_token);
979        assert!(
980            retry_rx.recv_timeout(Duration::from_millis(50)).is_err(),
981            "worker A's late completion must be rejected instead of resolving worker B's retry"
982        );
983        Ok(())
984    }
985}