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;
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 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 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 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 let teardown = StreamTeardown {
122 worker_id,
123 heartbeat: &heartbeat,
124 registry: ®istry,
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 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
166fn 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
202struct 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 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 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
250fn 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 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 liveness_waiters: crate::worker::GrpcLivenessWaiters,
333}
334
335async 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 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 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 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 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 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 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 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
488fn 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 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
631fn 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
650fn 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 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 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 ®ister.task_queue,
747 crate::worker::registry::optional_node(®ister.node).as_deref(),
748 ®ister.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 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 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 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 run_id: RunId,
937 activity_id: ActivityId,
938 completion_token: crate::worker::CompletionToken,
939 rx: std::sync::mpsc::Receiver<Result<String, String>>,
940 _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 #[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 #[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 #[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 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 #[test]
1127 fn a_worker_loss_consumes_the_sibling_token_of_a_redelivered_attempt() -> Result<(), TestError>
1128 {
1129 let fixture = fixture()?;
1130 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}