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;
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 liveness_waiters = self.state.grpc_liveness_waiters().clone();
84        let worker_id = registration
85            .worker_id()
86            .ok_or_else(|| Status::internal("worker registration missing id"))?;
87        heartbeat
88            .register_connection(worker_id, std::time::Instant::now())
89            .map_err(|error| status_from_server_error(&error))?;
90        // A worker serves a SET of namespaces; the ack echoes them joined in
91        // stable order purely for the worker's logs (the RegisterAck namespace
92        // field is informational, not a routing input).
93        let authorized_namespace = registration
94            .namespaces()
95            .filter(|namespaces| !namespaces.is_empty())
96            .ok_or_else(|| Status::internal("worker registration missing namespace"))?
97            .iter()
98            .cloned()
99            .collect::<Vec<_>>()
100            .join(",");
101
102        // RegisterAck ordering guarantee: the ack is enqueued on `task_tx`
103        // BEFORE the write forwarder that copies dispatched tasks onto the
104        // same channel is spawned, so no task frame can precede it on the
105        // wire. This is a structural ordering proof, not a timing hope.
106        task_tx
107            .try_send(Ok(register_ack_frame(
108                worker_id,
109                &authorized_namespace,
110                heartbeat_grace,
111            )))
112            .map_err(|_| Status::internal("worker response channel closed before RegisterAck"))?;
113
114        tokio::spawn(async move {
115            let write_handle = spawn_write_forwarder(worker_rx, task_tx.clone());
116
117            // Armed BEFORE the inbound loop runs: the sweep in its `Drop`
118            // fires on every exit from this task — clean stream end, stream
119            // error, token expiry, even a panic unwinding `process_inbound`.
120            // The unbounded dispatch wait depends on it.
121            let teardown = StreamTeardown {
122                worker_id,
123                heartbeat: &heartbeat,
124                registry: &registry,
125                pending: &pending,
126                drain: &drain,
127                liveness_waiters: &liveness_waiters,
128            };
129            let session = WorkerSession {
130                worker_id,
131                pending: &pending,
132                heartbeat: &heartbeat,
133                drain: &drain,
134                token_expires_at,
135                heartbeat_grace,
136                task_tx: task_tx.clone(),
137                liveness_waiters: liveness_waiters.clone(),
138            };
139            if let Err(status) = process_inbound(inbound, session).await {
140                tracing::info!(
141                    worker_id = ?worker_id,
142                    %status,
143                    "worker stream closed with status"
144                );
145            }
146
147            write_handle.abort();
148            drop(task_tx);
149            drop(teardown);
150            // The teardown sweep already deregistered the stream; consuming
151            // the registration here is an idempotent no-op that still
152            // surfaces a poisoned-lock error loudly.
153            if let Err(error) = registration.deregister() {
154                tracing::error!(
155                    worker_id = ?worker_id,
156                    %error,
157                    "worker deregistration failed during stream teardown"
158                );
159            }
160        });
161
162        Ok(Response::new(ReceiverStream::new(task_rx)))
163    }
164}
165
166/// Spawn the write forwarder: it copies registry-delivered [`WorkerMessage`]s
167/// onto the worker's response stream, and — the #176 zombie fix — TERMINATES
168/// the RPC when the registry drops this worker's delivery sender.
169///
170/// `recv` returning `None` means every delivery sender is gone: the worker
171/// was DEREGISTERED while its stream stayed open (the heartbeat expiry sweep,
172/// or any future administrative removal). Silently deregistering would leave
173/// the worker a zombie — connected but unroutable, believing it is
174/// registered, its heartbeats rejected as "not in flight", never
175/// re-registering until its own stream happens to end. Ending the RPC with a
176/// retryable `Unavailable` status makes the worker OBSERVE the
177/// deregistration and re-register through its reconnect machinery. (On the
178/// normal teardown path this task is aborted before the registration is
179/// consumed, so the status is never sent to a worker that hung up; a send
180/// failure means the response stream's consumer is already gone, so there is
181/// no one left to signal.)
182fn spawn_write_forwarder(
183    mut worker_rx: mpsc::Receiver<WorkerMessage>,
184    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
185) -> tokio::task::JoinHandle<()> {
186    tokio::spawn(async move {
187        while let Some(message) = worker_rx.recv().await {
188            let msg = encode_server_to_worker(message);
189            if task_tx.send(Ok(msg)).await.is_err() {
190                return;
191            }
192        }
193        let _ = task_tx
194            .send(Err(Status::unavailable(
195                "worker was deregistered by the server (heartbeat window expired); \
196                 reconnect and re-register",
197            )))
198            .await;
199    })
200}
201
202/// Drop guard that sweeps a torn-down worker stream's in-flight activities:
203/// failed back to the engine mid-run, or parked for restart recovery under a
204/// graceful drain (#207) — see [`teardown_worker_stream`].
205///
206/// A guard rather than a call site so the sweep cannot be skipped by any
207/// exit from the stream task — including a panic unwinding the inbound
208/// loop, which would otherwise leave every dispatch blocked on that worker
209/// waiting forever.
210struct StreamTeardown<'a> {
211    worker_id: WorkerId,
212    heartbeat: &'a crate::worker::HeartbeatTracker,
213    registry: &'a crate::worker::ConnectedWorkerRegistry,
214    pending: &'a PendingActivities,
215    drain: &'a crate::shutdown::DrainState,
216    /// The probe's answer-correlation registry (#197), released here for the
217    /// same reason the sweep runs here: a guard fires on EVERY exit from the
218    /// stream task, a plain call after the loop does not.
219    liveness_waiters: &'a crate::worker::GrpcLivenessWaiters,
220}
221
222impl Drop for StreamTeardown<'_> {
223    fn drop(&mut self) {
224        teardown_worker_stream(
225            self.worker_id,
226            self.heartbeat,
227            self.registry,
228            self.pending,
229            self.drain,
230        );
231        // #197: this worker can no longer answer anything. Inside the guard
232        // rather than after the inbound loop, because a panic unwinding
233        // `process_inbound` would skip a plain call and leak the armed waiter
234        // for the life of the process — one entry per panicking stream on a
235        // server built never to die. It changes no verdict: the probe's own
236        // cadence judges an unanswered ping either way. It only stops the leak,
237        // and stops a re-registered worker id inheriting a sequence from a
238        // stream that is gone.
239        if let Err(error) = self.liveness_waiters.disarm(self.worker_id) {
240            tracing::error!(
241                worker_id = ?self.worker_id,
242                %error,
243                "gRPC liveness waiter map is poisoned; armed pings for departed workers can no \
244                 longer be released"
245            );
246        }
247    }
248}
249
250/// Sweep a torn-down worker stream's in-flight activities.
251///
252/// The stream is the worker's liveness. When it ends mid-run — process death,
253/// network disconnect, expired token — every activity still assigned to this
254/// worker must be failed back through the completion sink as a retryable
255/// lost-worker error. The activity dispatch wait is unbounded by design (the
256/// engine imposes no activity timeout), so this sweep is what unblocks
257/// dispatches whose worker died mid-activity; the engine's retry policy then
258/// decides re-dispatch.
259///
260/// Under a graceful drain (#207) the stream ending is the EXPECTED worker
261/// response to the drain request, not a death: the worker's in-flight tasks
262/// are PARKED for restart recovery instead — nothing is recorded, nothing is
263/// delivered, the durable log keeps its dangling scheduled/started trail
264/// exactly as a kill -9 would, and post-restart replay re-dispatches it. The
265/// park-vs-fail branch keys on
266/// [`DrainState::is_draining`](crate::shutdown::DrainState::is_draining): a
267/// worker lost while the server is NOT draining still fails-and-retries
268/// byte-identically to before.
269fn teardown_worker_stream(
270    worker_id: WorkerId,
271    heartbeat: &crate::worker::HeartbeatTracker,
272    registry: &crate::worker::ConnectedWorkerRegistry,
273    pending: &PendingActivities,
274    drain: &crate::shutdown::DrainState,
275) {
276    if drain.is_draining() {
277        match heartbeat.park_disconnected_worker(worker_id, registry, pending) {
278            Ok(report) if report.tasks.is_empty() => {}
279            Ok(report) => {
280                tracing::info!(
281                    worker_id = ?worker_id,
282                    parked_tasks = report.tasks.len(),
283                    "worker stream ended during drain; in-flight activities \
284                     parked for restart recovery"
285                );
286            }
287            Err(error) => {
288                tracing::error!(
289                    worker_id = ?worker_id,
290                    %error,
291                    "failed to park draining worker's in-flight activities"
292                );
293            }
294        }
295    } else {
296        match heartbeat.fail_disconnected_worker(worker_id, registry, pending) {
297            Ok(report) if report.tasks.is_empty() => {}
298            Ok(report) => {
299                tracing::warn!(
300                    worker_id = ?worker_id,
301                    failed_tasks = report.tasks.len(),
302                    "worker disconnected with in-flight activities; \
303                     surfaced as transport losses, to be re-dispatched \
304                     attempt-neutrally"
305                );
306            }
307            Err(error) => {
308                tracing::error!(
309                    worker_id = ?worker_id,
310                    %error,
311                    "failed to sweep disconnected worker's in-flight activities"
312                );
313            }
314        }
315    }
316    // In-flight accounting may have just reached zero; wake any drain
317    // waiter so shutdown does not sit out its full timeout.
318    drain.notify_activity_drained();
319}
320
321struct WorkerSession<'a> {
322    worker_id: WorkerId,
323    pending: &'a PendingActivities,
324    heartbeat: &'a crate::worker::HeartbeatTracker,
325    drain: &'a crate::shutdown::DrainState,
326    token_expires_at: Option<u64>,
327    heartbeat_grace: std::time::Duration,
328    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
329    /// The liveness probe's answer-correlation registry (#197). The probe
330    /// pushes pings from its own timer loop and cannot see this stream; this
331    /// handle is the only thing the two halves share.
332    liveness_waiters: crate::worker::GrpcLivenessWaiters,
333}
334
335/// WA-010 R3: a worker that answers faster than its lease lands waits here,
336/// on the async side, so the completion fence never accepts a terminal ahead
337/// of the lease it must follow. A poisoned gate is said and the completion
338/// still goes through — the result is the worker's truth and must not be lost
339/// to ordering.
340async fn await_lease_before_completion(
341    session: &WorkerSession<'_>,
342    completion: &ActivityCompletion,
343) {
344    if let Err(error) = session
345        .pending
346        .completion_fences()
347        .lease_settled(&completion.completion_token)
348        .await
349    {
350        tracing::error!(
351            worker_id = ?session.worker_id,
352            workflow_id = %completion.workflow_id,
353            activity_id = %completion.activity_id,
354            %error,
355            "lease gate unreadable; accepting the completion without waiting for its lease"
356        );
357    }
358}
359
360async fn process_inbound(
361    mut inbound: Streaming<generated::WorkerToServer>,
362    session: WorkerSession<'_>,
363) -> Result<(), Status> {
364    let mut expired_since: Option<std::time::Instant> = None;
365    while let Some(msg) = inbound.message().await? {
366        refresh_connection_lease(&session)?;
367        let Some(inner) = msg.message else {
368            continue;
369        };
370        match inner {
371            generated::worker_to_server::Message::Result(result) => {
372                let proto_result = decode_activity_result(result);
373                match ActivityCompletion::try_from(proto_result) {
374                    Ok(completion) => {
375                        let workflow_id = completion.workflow_id.clone();
376                        let activity_id = completion.activity_id.clone();
377                        await_lease_before_completion(&session, &completion).await;
378                        let after_accept = || {
379                            // Fail open: the tracking clear is bookkeeping.
380                            // The ack below is sent regardless, so a withheld
381                            // result is dropped by the worker forever, and the
382                            // lost-worker sweep cannot recover it — it reads
383                            // the same poisoned lock.
384                            let _ = crate::worker::bridge::clear_completed_task_tracking(
385                                session.heartbeat,
386                                session.worker_id,
387                                &workflow_id,
388                                &activity_id,
389                            );
390                            session.drain.notify_activity_drained();
391                            Ok(())
392                        };
393                        if let Err(error) = session
394                            .pending
395                            .complete_activity_after_accept(completion, after_accept)
396                        {
397                            // The only error source here is the execution-
398                            // generation fence (the tracking clear fails open
399                            // above). Leave the current worker's liveness
400                            // entry intact when the generation proof is wrong.
401                            // Its stopped heartbeat pump will drive the normal
402                            // loss/retry path.
403                            tracing::error!(
404                                worker_id = ?session.worker_id,
405                                workflow_id = %workflow_id,
406                                activity_id = %activity_id,
407                                %error,
408                                "activity completion rejected by execution-generation proof"
409                            );
410                        }
411                        // Ack every well-formed result frame — including
412                        // duplicates with no pending waiter; their re-report
413                        // obligation is equally discharged. `try_send`: a
414                        // worker that stopped draining its receive side must
415                        // not wedge the inbound loop; a dropped ack is
416                        // recovered by the next-session re-report.
417                        let ack = result_ack_frame(&workflow_id, &activity_id);
418                        if let Err(error) = session.task_tx.try_send(Ok(ack)) {
419                            tracing::warn!(
420                                worker_id = ?session.worker_id,
421                                workflow_id = %workflow_id,
422                                activity_id = %activity_id,
423                                %error,
424                                "result ack dropped: worker stream channel unavailable"
425                            );
426                        }
427                    }
428                    Err(error) => {
429                        // Malformed result: no ids to ack with. Loud, never
430                        // silent — the worker's entry will re-report and
431                        // re-fail visibly each session.
432                        tracing::error!(
433                            worker_id = ?session.worker_id,
434                            %error,
435                            "malformed activity result frame; no ack sent"
436                        );
437                    }
438                }
439            }
440            generated::worker_to_server::Message::Register(_) => {
441                warn_duplicate_registration(session.worker_id);
442            }
443            // #197: the answer to a transport liveness ping. It proves the one
444            // fact dispatch eligibility is gated on — that the server reached
445            // this worker's dispatch path — but it proves it only through the
446            // probe, which is the party that knows which sequence it asked.
447            // Nothing is recorded here beyond handing the echo across.
448            generated::worker_to_server::Message::LivenessAnswer(answer) => {
449                deliver_liveness_answer(&session, answer.liveness_ping);
450            }
451            generated::worker_to_server::Message::Heartbeat(heartbeat_msg) => {
452                // Empty task ids are the connection-level lease beat. The frame
453                // already advanced the lease above; it intentionally has no
454                // per-task liveness entry to update.
455                if heartbeat_msg.workflow_id.is_none() && heartbeat_msg.activity_id.is_none() {
456                    continue;
457                }
458                if let Err(error) = session.heartbeat.record_heartbeat(
459                    session.worker_id,
460                    decode_heartbeat(heartbeat_msg),
461                    std::time::Instant::now(),
462                ) {
463                    // Malformed frames and heartbeats for untracked tasks
464                    // are worker-side defects worth surfacing; a poisoned
465                    // tracker lock is a server-side corruption signal that
466                    // must never vanish silently.
467                    if matches!(error, crate::ServerError::LockPoisoned { .. }) {
468                        tracing::error!(
469                            worker_id = ?session.worker_id,
470                            %error,
471                            "heartbeat tracker lock poisoned; liveness state untrustworthy"
472                        );
473                    } else {
474                        tracing::warn!(
475                            worker_id = ?session.worker_id,
476                            %error,
477                            "worker heartbeat rejected"
478                        );
479                    }
480                }
481                enforce_token_expiration(&session, &mut expired_since).await?;
482            }
483        }
484    }
485    Ok(())
486}
487
488/// Hand one `LivenessAnswer`'s echoed sequence to the probe's correlation
489/// registry (#197).
490///
491/// Never silent. An unmatched answer is a real fact about this link — the
492/// worker replied after the probe's cadence had already expired, or echoed a
493/// sequence that was not asked — and it is exactly the evidence an operator
494/// needs when a worker keeps failing to clear its probation while looking
495/// healthy from the outside. A poisoned waiter map is louder still: it means
496/// no gRPC worker on this server can clear probation at all.
497fn deliver_liveness_answer(session: &WorkerSession<'_>, sequence: u64) {
498    match session.liveness_waiters.answer(session.worker_id, sequence) {
499        Ok(true) => {}
500        Ok(false) => tracing::warn!(
501            worker_id = ?session.worker_id,
502            liveness_ping = sequence,
503            "worker answered a liveness ping the server was no longer waiting for; the answer \
504             arrived after its probe cadence expired, or echoed a sequence that was never asked. \
505             It banks NOTHING toward the dispatch probation"
506        ),
507        Err(error) => tracing::error!(
508            worker_id = ?session.worker_id,
509            liveness_ping = sequence,
510            %error,
511            "gRPC liveness waiter map is poisoned; no gRPC worker on this server can clear its \
512             dispatch probation until the process is restarted"
513        ),
514    }
515}
516
517fn refresh_connection_lease(session: &WorkerSession<'_>) -> Result<(), Status> {
518    session
519        .heartbeat
520        .record_connection_activity(session.worker_id, std::time::Instant::now())
521        .map(|_| ())
522        .map_err(|error| {
523            tracing::error!(
524                worker_id = ?session.worker_id,
525                %error,
526                "failed to advance worker connection lease"
527            );
528            status_from_server_error(&error)
529        })
530}
531
532fn warn_duplicate_registration(worker_id: WorkerId) {
533    tracing::warn!(
534        worker_id = ?worker_id,
535        "ignoring subsequent RegisterWorker message; \
536         only the first registration is accepted per stream"
537    );
538}
539
540async fn enforce_token_expiration(
541    session: &WorkerSession<'_>,
542    expired_since: &mut Option<std::time::Instant>,
543) -> Result<(), Status> {
544    if !token_expired(session.token_expires_at) {
545        return Ok(());
546    }
547    let first_expired = *expired_since.get_or_insert_with(std::time::Instant::now);
548    let _ = session
549        .task_tx
550        .send(Err(Status::unauthenticated(
551            "worker token expired; re-authentication required",
552        )))
553        .await;
554    if first_expired.elapsed() >= session.heartbeat_grace {
555        return Err(Status::unauthenticated("worker token expired"));
556    }
557    Ok(())
558}
559
560async fn worker_caller_from_metadata(
561    metadata: &tonic::metadata::MetadataMap,
562    state: &ServerState,
563) -> Result<CallerIdentity, Status> {
564    crate::api::grpc::caller_from_metadata(metadata, state).await
565}
566
567async fn token_expiration_from_metadata(
568    metadata: &tonic::metadata::MetadataMap,
569    state: &ServerState,
570) -> Result<Option<u64>, Status> {
571    if !state.runtime_config().auth.enabled {
572        return Ok(None);
573    }
574    #[cfg(feature = "auth")]
575    {
576        let bearer = metadata
577            .get("authorization")
578            .and_then(|value| value.to_str().ok())
579            .and_then(parse_bearer)
580            .ok_or_else(|| Status::unauthenticated("missing bearer token"))?;
581        let Some(cache) = state.jwks_cache() else {
582            return Err(Status::unauthenticated("invalid bearer token"));
583        };
584        return cache
585            .validate(&bearer)
586            .await
587            .map(|claims| Some(claims.expires_at()))
588            .map_err(|_error| Status::unauthenticated("invalid bearer token"));
589    }
590    #[cfg(not(feature = "auth"))]
591    {
592        let _ = metadata;
593        // Yield to preserve the async signature required by the auth-feature branch.
594        tokio::task::yield_now().await;
595        Ok(None)
596    }
597}
598
599#[cfg(feature = "auth")]
600fn parse_bearer(value: &str) -> Option<String> {
601    let token = value.strip_prefix("Bearer ")?.trim();
602    if token.is_empty() {
603        return None;
604    }
605    Some(token.to_owned())
606}
607
608fn token_expired(expires_at: Option<u64>) -> bool {
609    expires_at.is_some_and(|expires_at| {
610        #[cfg(feature = "auth")]
611        {
612            crate::auth::jwks::is_expired(expires_at)
613        }
614        #[cfg(not(feature = "auth"))]
615        {
616            let _ = expires_at;
617            false
618        }
619    })
620}
621
622fn status_from_server_error(error: &crate::ServerError) -> Status {
623    let wire = error.to_wire_error();
624    if wire.code == aion_proto::WireErrorCode::NamespaceDenied {
625        Status::permission_denied(wire.message)
626    } else {
627        Status::internal(wire.message)
628    }
629}
630
631/// Build the positive registration acknowledgement frame — the guaranteed
632/// first frame on every successful worker response stream.
633fn register_ack_frame(
634    worker_id: WorkerId,
635    namespace: &str,
636    heartbeat_window: std::time::Duration,
637) -> generated::ServerToWorker {
638    generated::ServerToWorker {
639        message: Some(generated::server_to_worker::Message::RegisterAck(
640            generated::RegisterAck {
641                worker_id: worker_id.value(),
642                namespace: namespace.to_owned(),
643                heartbeat_window_ms: u64::try_from(heartbeat_window.as_millis())
644                    .unwrap_or(u64::MAX),
645            },
646        )),
647    }
648}
649
650/// Build the per-result acknowledgement frame for a consumed `ActivityResult`.
651fn result_ack_frame(
652    workflow_id: &aion_core::WorkflowId,
653    activity_id: &aion_core::ActivityId,
654) -> generated::ServerToWorker {
655    generated::ServerToWorker {
656        message: Some(generated::server_to_worker::Message::ResultAck(
657            generated::ResultAck {
658                workflow_id: Some(generated::WorkflowId {
659                    uuid: workflow_id.to_string(),
660                }),
661                activity_id: Some(generated::ActivityId {
662                    sequence_position: activity_id.sequence_position(),
663                }),
664            },
665        )),
666    }
667}
668
669fn decode_register(r: generated::RegisterWorker) -> ProtoRegisterWorker {
670    ProtoRegisterWorker {
671        namespaces: r.namespaces,
672        activity_types: r.activity_types,
673        task_queue: r.task_queue,
674        node: r.node,
675        activities: r
676            .activities
677            .into_iter()
678            .map(|activity| ProtoActivityDescriptor {
679                name: activity.name,
680                input_schema_json: activity.input_schema_json,
681                output_schema_json: activity.output_schema_json,
682            })
683            .collect(),
684        identity: r.identity,
685        instance: r.instance.map(|instance| ProtoWorkerInstanceIdentity {
686            deployment: instance.deployment,
687            instance_id: instance.instance_id,
688        }),
689    }
690}
691
692fn validate_worker_contracts(
693    state: &ServerState,
694    register: &ProtoRegisterWorker,
695) -> Result<(), Status> {
696    let advertised = register
697        .activities
698        .iter()
699        .map(|activity| {
700            let input_schema =
701                serde_json::from_str(&activity.input_schema_json).map_err(|error| {
702                    Status::invalid_argument(format!(
703                        "worker activity `{}` input_schema_json is invalid: {error}",
704                        activity.name
705                    ))
706                })?;
707            let output_schema =
708                serde_json::from_str(&activity.output_schema_json).map_err(|error| {
709                    Status::invalid_argument(format!(
710                        "worker activity `{}` output_schema_json is invalid: {error}",
711                        activity.name
712                    ))
713                })?;
714            Ok(aion_package::ActivityDescriptor {
715                name: activity.name.clone(),
716                input_schema,
717                output_schema,
718            })
719        })
720        .collect::<Result<Vec<_>, Status>>()?;
721    // Mirrors the liminal transport's no-catalog admission: a state built
722    // from parts without an engine handle has no durable catalog, so no
723    // deployed `.v4` contract can exist to contradict this worker. Refusing
724    // here would turn every registration away forever on such a server; the
725    // strict field-level check applies on every engine-backed state.
726    let Ok(engine) = state.engine() else {
727        tracing::warn!(
728            task_queue = %register.task_queue,
729            identity = %register.identity,
730            "worker contract check skipped: server state has no engine handle, \
731             so no deployed contracts exist to check against"
732        );
733        return Ok(());
734    };
735    // Both advertised forms travel into the gate together — see
736    // [`crate::worker::contracts::WorkerAdvertisement`] for why carrying only
737    // one of them makes a refusal contradict itself.
738    let activity_types = register
739        .activity_types
740        .iter()
741        .cloned()
742        .collect::<std::collections::BTreeSet<_>>();
743    crate::worker::contracts::validate_worker_contracts(
744        &engine,
745        state.worker_registry().admission_audit(),
746        &register.task_queue,
747        crate::worker::registry::optional_node(&register.node).as_deref(),
748        &register.identity,
749        crate::worker::contracts::WorkerAdvertisement {
750            activity_types: &activity_types,
751            contracts: &advertised,
752        },
753    )
754    .map_err(|error| match error {
755        crate::worker::contracts::ContractAdmissionError::Mismatch { .. } => {
756            Status::failed_precondition(error.to_string())
757        }
758        crate::worker::contracts::ContractAdmissionError::Catalog { .. } => {
759            Status::internal(error.to_string())
760        }
761    })
762}
763
764fn encode_server_to_worker(message: WorkerMessage) -> generated::ServerToWorker {
765    let message = match message {
766        WorkerMessage::ActivityTask(task) => {
767            generated::server_to_worker::Message::Task(encode_task(*task))
768        }
769        WorkerMessage::DrainRequest => {
770            generated::server_to_worker::Message::Drain(generated::DrainRequest {})
771        }
772        // #197: the liveness ping rides the SAME forwarder a dispatch rides,
773        // deliberately — that is the whole point of it. Encoding it anywhere
774        // else would measure a channel no dispatch travels.
775        WorkerMessage::LivenessPing(ping) => {
776            generated::server_to_worker::Message::LivenessPing(generated::LivenessPing {
777                liveness_ping: ping.liveness_ping,
778                silence_window_ms: ping.silence_window_ms,
779            })
780        }
781        // #233: the cancel rides the SAME forwarder the dispatch rode, so it
782        // cannot overtake the task it interrupts and a worker whose dispatch
783        // path is dead cannot appear to have been told.
784        WorkerMessage::CancelActivity(cancel) => {
785            generated::server_to_worker::Message::CancelActivity(generated::CancelActivity {
786                workflow_id: cancel
787                    .workflow_id
788                    .map(|id| generated::WorkflowId { uuid: id.uuid }),
789                activity_id: cancel.activity_id.map(|id| generated::ActivityId {
790                    sequence_position: id.sequence_position,
791                }),
792            })
793        }
794    };
795    generated::ServerToWorker {
796        message: Some(message),
797    }
798}
799
800fn encode_task(task: aion_proto::ProtoActivityTask) -> generated::ActivityTask {
801    generated::ActivityTask {
802        workflow_id: task
803            .workflow_id
804            .map(|id| generated::WorkflowId { uuid: id.uuid }),
805        activity_id: task.activity_id.map(|id| generated::ActivityId {
806            sequence_position: id.sequence_position,
807        }),
808        activity_type: task.activity_type,
809        input: task.input.map(|p| generated::Payload {
810            content_type: p.content_type,
811            bytes: p.bytes,
812        }),
813        attempt: task.attempt,
814        labels: task.labels,
815        run_id: task.run_id.map(|id| generated::RunId { uuid: id.uuid }),
816        completion_token: task.completion_token,
817        idempotency_key: task.idempotency_key,
818    }
819}
820
821fn decode_activity_result(r: generated::ActivityResult) -> ProtoActivityResult {
822    ProtoActivityResult {
823        workflow_id: r
824            .workflow_id
825            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
826        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
827            sequence_position: id.sequence_position,
828        }),
829        outcome: r.outcome.map(decode_outcome),
830        run_id: r.run_id.map(|id| aion_proto::ProtoRunId { uuid: id.uuid }),
831        completion_token: r.completion_token,
832    }
833}
834
835fn decode_heartbeat(r: generated::Heartbeat) -> aion_proto::ProtoHeartbeat {
836    aion_proto::ProtoHeartbeat {
837        workflow_id: r
838            .workflow_id
839            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
840        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
841            sequence_position: id.sequence_position,
842        }),
843        progress: r.progress.map(|p| aion_proto::ProtoPayload {
844            content_type: p.content_type,
845            bytes: p.bytes,
846        }),
847    }
848}
849
850fn decode_outcome(
851    outcome: generated::activity_result::Outcome,
852) -> aion_proto::proto_activity_result::Outcome {
853    match outcome {
854        generated::activity_result::Outcome::Result(p) => {
855            aion_proto::proto_activity_result::Outcome::Result(aion_proto::ProtoPayload {
856                content_type: p.content_type,
857                bytes: p.bytes,
858            })
859        }
860        generated::activity_result::Outcome::Error(e) => {
861            aion_proto::proto_activity_result::Outcome::Error(aion_proto::ProtoActivityError {
862                kind: e.kind,
863                message: e.message,
864                details: e.details.map(|p| aion_proto::ProtoPayload {
865                    content_type: p.content_type,
866                    bytes: p.bytes,
867                }),
868            })
869        }
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use std::time::{Duration, Instant};
876
877    use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
878
879    use crate::shutdown::DrainState;
880    use crate::worker::dispatch::{
881        ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
882    };
883    use crate::worker::heartbeat::InFlightActivity;
884    use crate::worker::registry::ConnectedWorkerRegistry;
885    use crate::worker::{HeartbeatTracker, PendingActivities};
886
887    use super::{decode_register, teardown_worker_stream};
888
889    type TestError = Box<dyn std::error::Error>;
890
891    #[test]
892    fn decode_register_maps_tag_seven_instance_without_changing_absent_registration() {
893        let generated = super::generated::RegisterWorker {
894            namespaces: vec!["orders".to_owned()],
895            activity_types: vec!["shell".to_owned()],
896            task_queue: "shell".to_owned(),
897            node: "node-a".to_owned(),
898            activities: Vec::new(),
899            identity: "build-a".to_owned(),
900            instance: Some(super::generated::WorkerInstanceIdentity {
901                deployment: "shells".to_owned(),
902                instance_id: "instance-1".to_owned(),
903            }),
904        };
905        let mapped = decode_register(generated.clone());
906        let instance = mapped.instance.as_ref();
907        assert_eq!(
908            instance.map(|value| value.deployment.as_str()),
909            Some("shells")
910        );
911        assert_eq!(
912            instance.map(|value| value.instance_id.as_str()),
913            Some("instance-1")
914        );
915
916        let mut absent = generated;
917        absent.instance = None;
918        let mapped_absent = decode_register(absent);
919        assert!(mapped_absent.instance.is_none());
920        assert_eq!(mapped_absent.identity, "build-a");
921    }
922
923    /// One tracked in-flight dispatch with a live pending waiter, ready for a
924    /// stream teardown: the registered worker, the shared tracker/pending/drain
925    /// state, and the waiter's receiver.
926    struct TeardownFixture {
927        registry: ConnectedWorkerRegistry,
928        tracker: HeartbeatTracker,
929        pending: PendingActivities,
930        drain: DrainState,
931        worker_id: crate::worker::registry::WorkerId,
932        workflow_id: WorkflowId,
933        /// The concrete run this dispatch belongs to. Held so a test can mint a
934        /// SIBLING authorization for the same attempt of the same execution
935        /// generation, which is what an at-least-once redelivery is.
936        run_id: RunId,
937        activity_id: ActivityId,
938        completion_token: crate::worker::CompletionToken,
939        rx: std::sync::mpsc::Receiver<Result<String, String>>,
940        /// Held so the registered worker stays routable until the teardown
941        /// under test deregisters it (dropping the guard would race that).
942        _registration: crate::worker::registry::WorkerRegistration,
943    }
944
945    fn fixture() -> Result<TeardownFixture, TestError> {
946        let registry = ConnectedWorkerRegistry::default();
947        let (tx, _rx) = tokio::sync::mpsc::channel(1);
948        let activity_types = [String::from("greet")];
949        let registration = registry.register("default", activity_types.iter(), tx)?;
950        let worker_id = registration
951            .worker_id()
952            .ok_or("test worker registration missing id")?;
953        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
954        let pending = PendingActivities::new(Duration::from_secs(5));
955        let workflow_id = WorkflowId::new_v4();
956        let run_id = RunId::new_v4();
957        let activity_id = ActivityId::from_sequence_position(0);
958        let (completion_token, rx, _) =
959            pending.insert_for_test(workflow_id.clone(), &run_id, activity_id.clone(), 1)?;
960        tracker.track_task(
961            worker_id,
962            InFlightActivity {
963                workflow_id: workflow_id.clone(),
964                activity_id: activity_id.clone(),
965                attempt: 1,
966                completion_token: completion_token.clone(),
967            },
968            Instant::now(),
969        )?;
970        Ok(TeardownFixture {
971            registry,
972            tracker,
973            pending,
974            drain: DrainState::default(),
975            worker_id,
976            workflow_id,
977            run_id,
978            activity_id,
979            completion_token,
980            rx,
981            _registration: registration,
982        })
983    }
984
985    /// #207: with drain begun, a stream teardown PARKS the in-flight dispatch —
986    /// the waiter resolves with the ephemeral parked sentinel, no lost-worker
987    /// failure is synthesized, and the tracker empties for drain accounting.
988    #[test]
989    fn teardown_under_drain_parks_instead_of_failing() -> Result<(), TestError> {
990        let fixture = fixture()?;
991        assert!(fixture.drain.begin());
992
993        teardown_worker_stream(
994            fixture.worker_id,
995            &fixture.tracker,
996            &fixture.registry,
997            &fixture.pending,
998            &fixture.drain,
999        );
1000
1001        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1002        assert_eq!(
1003            resolved,
1004            Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
1005            "a drain teardown must resolve the waiter with the parked sentinel"
1006        );
1007        assert_eq!(fixture.tracker.in_flight_count()?, 0);
1008        assert!(
1009            !fixture.tracker.is_tracked(
1010                fixture.worker_id,
1011                &fixture.workflow_id,
1012                &fixture.activity_id
1013            )?,
1014            "parking must retire the tracked entry"
1015        );
1016        Ok(())
1017    }
1018
1019    /// Regression pin: WITHOUT drain, the teardown path still resolves the
1020    /// waiter with a lost-worker failure — but in the TRANSPORT domain, not the
1021    /// action's.
1022    ///
1023    /// The old contract here was `retryable:`, and that framing was the Hit D
1024    /// defect: an authored retry policy governs how often the ACTION may fail,
1025    /// and an activity with no policy (the SDK default) turned a worker death
1026    /// into a TERMINAL failure. Worker loss now carries the `lost:` class, which
1027    /// the engine re-dispatches attempt-neutrally.
1028    #[test]
1029    fn teardown_without_drain_fails_with_the_transport_domain_lost_worker_class()
1030    -> Result<(), TestError> {
1031        let fixture = fixture()?;
1032
1033        teardown_worker_stream(
1034            fixture.worker_id,
1035            &fixture.tracker,
1036            &fixture.registry,
1037            &fixture.pending,
1038            &fixture.drain,
1039        );
1040
1041        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1042        let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1043        assert!(
1044            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1045            "a mid-run teardown must surface the TRANSPORT-domain loss class, never the \
1046             action's retry vocabulary: {reason}"
1047        );
1048        assert!(
1049            reason.contains("lost before reporting activity result"),
1050            "the failure must name worker loss: {reason}"
1051        );
1052        assert_eq!(fixture.tracker.in_flight_count()?, 0);
1053        Ok(())
1054    }
1055
1056    /// R2 fencing red: after heartbeat loss resolves worker A's attempt and a
1057    /// retry installs worker B's waiter for the same activity, A's late result
1058    /// must not resolve B's generation.
1059    #[test]
1060    fn stale_worker_completion_after_heartbeat_loss_does_not_resolve_retry() -> Result<(), TestError>
1061    {
1062        let fixture = fixture()?;
1063        teardown_worker_stream(
1064            fixture.worker_id,
1065            &fixture.tracker,
1066            &fixture.registry,
1067            &fixture.pending,
1068            &fixture.drain,
1069        );
1070        let first = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1071        assert!(
1072            first
1073                .err()
1074                .is_some_and(|reason| reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX)),
1075            "worker A loss must release attempt 1 in the transport-loss class"
1076        );
1077
1078        // A genuine RETRY: the loss released attempt 1, so worker B is serving
1079        // attempt 2 of the same run.
1080        let (retry_token, retry_rx, _) = fixture.pending.insert_for_test(
1081            fixture.workflow_id.clone(),
1082            &fixture.run_id,
1083            fixture.activity_id.clone(),
1084            2,
1085        )?;
1086        let rejected = fixture.pending.complete_activity(ActivityCompletion {
1087            workflow_id: fixture.workflow_id,
1088            activity_id: fixture.activity_id,
1089            run_id: None,
1090            completion_token: fixture.completion_token,
1091            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1092                ContentType::Json,
1093                br#"{"worker":"A","stale":true}"#.to_vec(),
1094            )),
1095        });
1096
1097        assert!(matches!(
1098            rejected,
1099            Err(crate::ServerError::ActivityCompletionRejected { .. })
1100        ));
1101        drop(retry_token);
1102        assert!(
1103            retry_rx.recv_timeout(Duration::from_millis(50)).is_err(),
1104            "worker A's late completion must be rejected instead of resolving worker B's retry"
1105        );
1106        Ok(())
1107    }
1108
1109    /// FENCE-1 §3.4, ruled and pinned: a `WorkerLost` completion consumes EVERY
1110    /// outstanding token of the attempt it names, a redelivery's sibling
1111    /// included.
1112    ///
1113    /// `HeartbeatTracker`'s loss sweep synthesizes the completion carrying the
1114    /// token it tracked for the worker it declared dead, and that completion
1115    /// goes through the same `accept`. When a redelivery of the SAME attempt has
1116    /// put a sibling authorization in a second worker's hands, the loss verdict
1117    /// retires the whole generation and the second worker's later result is
1118    /// refused. This is the ruled shape behaving as ruled — recorded here as a
1119    /// decision rather than left to be discovered.
1120    ///
1121    /// It is deliberately better than the base rather than merely different: the
1122    /// loss resolves in the TRANSPORT domain (`lost:`), which is attempt-neutral
1123    /// and re-dispatches the SAME attempt, so the work is re-done. Under the
1124    /// base this same interleaving left the workflow waiting forever with
1125    /// nothing that could retry it.
1126    #[test]
1127    fn a_worker_loss_consumes_the_sibling_token_of_a_redelivered_attempt() -> Result<(), TestError>
1128    {
1129        let fixture = fixture()?;
1130        // The redelivery: a SECOND authorization for the same attempt of the
1131        // same run, exactly as the push path mints one when a parked dispatch is
1132        // re-offered to a restored queue while the first worker still executes.
1133        let sibling = fixture.pending.completion_fences().issue(
1134            &fixture.workflow_id,
1135            &fixture.run_id,
1136            &fixture.activity_id,
1137            1,
1138        )?;
1139
1140        teardown_worker_stream(
1141            fixture.worker_id,
1142            &fixture.tracker,
1143            &fixture.registry,
1144            &fixture.pending,
1145            &fixture.drain,
1146        );
1147
1148        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1149        let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1150        assert!(
1151            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1152            "the loss must resolve in the transport domain, which re-dispatches the same attempt \
1153             rather than faulting the action: {reason}"
1154        );
1155
1156        let refused = fixture.pending.complete_activity(ActivityCompletion {
1157            workflow_id: fixture.workflow_id.clone(),
1158            activity_id: fixture.activity_id.clone(),
1159            run_id: None,
1160            completion_token: sibling,
1161            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1162                ContentType::Json,
1163                br#"{"worker":"B"}"#.to_vec(),
1164            )),
1165        });
1166        assert!(
1167            matches!(
1168                refused,
1169                Err(crate::ServerError::ActivityCompletionRejected {
1170                    reason: crate::error::CompletionRejectionReason::NoCurrentGeneration,
1171                    ..
1172                })
1173            ),
1174            "the first accepted completion — here the loss verdict — consumes the whole \
1175             generation, so the sibling worker's result is the duplicate: {refused:?}"
1176        );
1177        Ok(())
1178    }
1179}