1use 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#[derive(Clone)]
21pub struct WorkerGrpcService {
22 state: ServerState,
23}
24
25impl WorkerGrpcService {
26 #[must_use]
28 pub const fn new(state: ServerState) -> Self {
29 Self { state }
30 }
31}
32
33#[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, ®ister)?;
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, ®ister, 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 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 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 let teardown = StreamTeardown {
121 worker_id,
122 heartbeat: &heartbeat,
123 registry: ®istry,
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 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
163fn 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
199struct 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
227fn 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 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 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 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 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 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 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 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 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
541fn 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
560fn 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 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 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 ®ister.task_queue,
657 crate::worker::registry::optional_node(®ister.node).as_deref(),
658 ®ister.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 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 _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 #[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 #[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 #[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}