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
19mod refusal_arm;
20mod register_ack;
21
22use refusal_arm::handle_task_refusal;
23use register_ack::{recorded_capacity, register_ack_frame};
24
25#[derive(Clone)]
27pub struct WorkerGrpcService {
28 state: ServerState,
29}
30
31impl WorkerGrpcService {
32 #[must_use]
34 pub const fn new(state: ServerState) -> Self {
35 Self { state }
36 }
37}
38
39#[must_use]
41pub fn worker_service(state: ServerState) -> WorkerProtocolServer<WorkerGrpcService> {
42 WorkerProtocolServer::new(WorkerGrpcService::new(state))
43}
44
45#[tonic::async_trait]
46impl WorkerProtocol for WorkerGrpcService {
47 type StreamWorkerStream = ReceiverStream<Result<generated::ServerToWorker, Status>>;
48
49 async fn stream_worker(
50 &self,
51 request: Request<Streaming<generated::WorkerToServer>>,
52 ) -> Result<Response<Self::StreamWorkerStream>, Status> {
53 let metadata = request.metadata().clone();
54 let caller = worker_caller_from_metadata(&metadata, &self.state).await?;
55 let token_expires_at = token_expiration_from_metadata(&metadata, &self.state).await?;
56 let heartbeat_grace = self.state.runtime_config().worker.heartbeat_window;
57 let mut inbound = request.into_inner();
58
59 let first = inbound
60 .message()
61 .await?
62 .and_then(|msg| msg.message)
63 .ok_or_else(|| Status::invalid_argument("first message must be RegisterWorker"))?;
64
65 let register = match first {
66 generated::worker_to_server::Message::Register(r) => decode_register(r),
67 _ => {
68 return Err(Status::invalid_argument(
69 "first message must be RegisterWorker",
70 ));
71 }
72 };
73 validate_worker_contracts(&self.state, ®ister)?;
74
75 let (task_tx, task_rx) = mpsc::channel::<Result<generated::ServerToWorker, Status>>(32);
76 let (worker_tx, worker_rx) = mpsc::channel(32);
77
78 let registration = self
79 .state
80 .worker_registry()
81 .accept_registration(self.state.namespace_guard(), &caller, ®ister, worker_tx)
82 .await
83 .map_err(|error| status_from_server_error(&error))?;
84
85 let pending = self.state.pending_activities().clone();
86 let heartbeat = self.state.heartbeat_tracker().clone();
87 let drain = self.state.drain_state().clone();
88 let registry = self.state.worker_registry().clone();
89 let liveness_waiters = self.state.grpc_liveness_waiters().clone();
90 let worker_id = registration
91 .worker_id()
92 .ok_or_else(|| Status::internal("worker registration missing id"))?;
93 heartbeat
94 .register_connection(worker_id, std::time::Instant::now())
95 .map_err(|error| status_from_server_error(&error))?;
96 let authorized_namespace = registration
100 .namespaces()
101 .filter(|namespaces| !namespaces.is_empty())
102 .ok_or_else(|| Status::internal("worker registration missing namespace"))?
103 .iter()
104 .cloned()
105 .collect::<Vec<_>>()
106 .join(",");
107
108 task_tx
113 .try_send(Ok(register_ack_frame(
114 worker_id,
115 &authorized_namespace,
116 heartbeat_grace,
117 recorded_capacity(®istry, worker_id)?,
118 )))
119 .map_err(|_| Status::internal("worker response channel closed before RegisterAck"))?;
120
121 tokio::spawn(async move {
122 let write_handle = spawn_write_forwarder(worker_rx, task_tx.clone());
123
124 let teardown = StreamTeardown {
129 worker_id,
130 heartbeat: &heartbeat,
131 registry: ®istry,
132 pending: &pending,
133 drain: &drain,
134 liveness_waiters: &liveness_waiters,
135 };
136 let session = WorkerSession {
137 worker_id,
138 pending: &pending,
139 heartbeat: &heartbeat,
140 registry: ®istry,
141 drain: &drain,
142 token_expires_at,
143 heartbeat_grace,
144 task_tx: task_tx.clone(),
145 liveness_waiters: liveness_waiters.clone(),
146 };
147 if let Err(status) = process_inbound(inbound, session).await {
148 tracing::info!(
149 worker_id = ?worker_id,
150 %status,
151 "worker stream closed with status"
152 );
153 }
154
155 write_handle.abort();
156 drop(task_tx);
157 drop(teardown);
158 if let Err(error) = registration.deregister() {
162 tracing::error!(
163 worker_id = ?worker_id,
164 %error,
165 "worker deregistration failed during stream teardown"
166 );
167 }
168 });
169
170 Ok(Response::new(ReceiverStream::new(task_rx)))
171 }
172}
173
174fn spawn_write_forwarder(
213 mut worker_rx: mpsc::Receiver<WorkerMessage>,
214 task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
215) -> tokio::task::JoinHandle<()> {
216 tokio::spawn(async move {
217 while let Some(message) = worker_rx.recv().await {
218 let msg = encode_server_to_worker(message);
219 if task_tx.send(Ok(msg)).await.is_err() {
220 return;
221 }
222 }
223 let _ = task_tx
224 .send(Err(Status::unavailable(
225 "worker was deregistered by the server; reconnect and re-register. The \
226 server-side log line for this worker id carries the reason — this stream only \
227 observed that its delivery channel was dropped",
228 )))
229 .await;
230 })
231}
232
233struct StreamTeardown<'a> {
242 worker_id: WorkerId,
243 heartbeat: &'a crate::worker::HeartbeatTracker,
244 registry: &'a crate::worker::ConnectedWorkerRegistry,
245 pending: &'a PendingActivities,
246 drain: &'a crate::shutdown::DrainState,
247 liveness_waiters: &'a crate::worker::GrpcLivenessWaiters,
251}
252
253impl Drop for StreamTeardown<'_> {
254 fn drop(&mut self) {
255 teardown_worker_stream(
256 self.worker_id,
257 self.heartbeat,
258 self.registry,
259 self.pending,
260 self.drain,
261 );
262 if let Err(error) = self.liveness_waiters.disarm(self.worker_id) {
271 tracing::error!(
272 worker_id = ?self.worker_id,
273 %error,
274 "gRPC liveness waiter map is poisoned; armed pings for departed workers can no \
275 longer be released"
276 );
277 }
278 }
279}
280
281fn teardown_worker_stream(
301 worker_id: WorkerId,
302 heartbeat: &crate::worker::HeartbeatTracker,
303 registry: &crate::worker::ConnectedWorkerRegistry,
304 pending: &PendingActivities,
305 drain: &crate::shutdown::DrainState,
306) {
307 if drain.is_draining() {
308 match heartbeat.park_disconnected_worker(worker_id, registry, pending) {
309 Ok(report) if report.tasks.is_empty() => {}
310 Ok(report) => {
311 tracing::info!(
312 worker_id = ?worker_id,
313 parked_tasks = report.tasks.len(),
314 "worker stream ended during drain; in-flight activities \
315 parked for restart recovery"
316 );
317 }
318 Err(error) => {
319 tracing::error!(
320 worker_id = ?worker_id,
321 %error,
322 "failed to park draining worker's in-flight activities"
323 );
324 }
325 }
326 } else {
327 match heartbeat.fail_disconnected_worker(worker_id, registry, pending) {
328 Ok(report) if report.tasks.is_empty() => {}
329 Ok(report) => {
330 tracing::warn!(
331 worker_id = ?worker_id,
332 failed_tasks = report.tasks.len(),
333 "worker disconnected with in-flight activities; \
334 surfaced as transport losses, to be re-dispatched \
335 attempt-neutrally"
336 );
337 }
338 Err(error) => {
339 tracing::error!(
340 worker_id = ?worker_id,
341 %error,
342 "failed to sweep disconnected worker's in-flight activities"
343 );
344 }
345 }
346 }
347 drain.notify_activity_drained();
350}
351
352struct WorkerSession<'a> {
353 worker_id: WorkerId,
354 pending: &'a PendingActivities,
355 heartbeat: &'a crate::worker::HeartbeatTracker,
356 registry: &'a crate::worker::ConnectedWorkerRegistry,
360 drain: &'a crate::shutdown::DrainState,
361 token_expires_at: Option<u64>,
362 heartbeat_grace: std::time::Duration,
363 task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
364 liveness_waiters: crate::worker::GrpcLivenessWaiters,
368}
369
370async fn await_lease_before_completion(
376 session: &WorkerSession<'_>,
377 completion: &ActivityCompletion,
378) {
379 if let Err(error) = session
380 .pending
381 .completion_fences()
382 .lease_settled(&completion.completion_token)
383 .await
384 {
385 tracing::error!(
386 worker_id = ?session.worker_id,
387 workflow_id = %completion.workflow_id,
388 activity_id = %completion.activity_id,
389 %error,
390 "lease gate unreadable; accepting the completion without waiting for its lease"
391 );
392 }
393}
394
395async fn process_inbound(
396 mut inbound: Streaming<generated::WorkerToServer>,
397 session: WorkerSession<'_>,
398) -> Result<(), Status> {
399 let mut expired_since: Option<std::time::Instant> = None;
400 while let Some(msg) = inbound.message().await? {
401 refresh_connection_lease(&session)?;
402 let Some(inner) = msg.message else {
403 continue;
404 };
405 match inner {
406 generated::worker_to_server::Message::Result(result) => {
407 let proto_result = decode_activity_result(result);
408 match ActivityCompletion::try_from(proto_result) {
409 Ok(completion) => {
410 let workflow_id = completion.workflow_id.clone();
411 let activity_id = completion.activity_id.clone();
412 await_lease_before_completion(&session, &completion).await;
413 let after_accept = || {
414 let _ = crate::worker::bridge::clear_completed_task_tracking(
420 session.heartbeat,
421 session.registry,
422 session.worker_id,
423 &workflow_id,
424 &activity_id,
425 );
426 session.drain.notify_activity_drained();
427 Ok(())
428 };
429 if let Err(error) = session
430 .pending
431 .complete_activity_after_accept(completion, after_accept)
432 {
433 tracing::error!(
440 worker_id = ?session.worker_id,
441 workflow_id = %workflow_id,
442 activity_id = %activity_id,
443 %error,
444 "activity completion rejected by execution-generation proof"
445 );
446 }
447 let ack = result_ack_frame(&workflow_id, &activity_id);
454 if let Err(error) = session.task_tx.try_send(Ok(ack)) {
455 tracing::warn!(
456 worker_id = ?session.worker_id,
457 workflow_id = %workflow_id,
458 activity_id = %activity_id,
459 %error,
460 "result ack dropped: worker stream channel unavailable"
461 );
462 }
463 }
464 Err(error) => {
465 tracing::error!(
469 worker_id = ?session.worker_id,
470 %error,
471 "malformed activity result frame; no ack sent"
472 );
473 }
474 }
475 }
476 generated::worker_to_server::Message::Register(_) => {
477 warn_duplicate_registration(session.worker_id);
478 }
479 generated::worker_to_server::Message::TaskRefused(refusal) => {
492 handle_task_refusal(&session, refusal);
493 }
494 generated::worker_to_server::Message::LivenessAnswer(answer) => {
500 deliver_liveness_answer(&session, answer.liveness_ping);
501 }
502 generated::worker_to_server::Message::Heartbeat(heartbeat_msg) => {
503 if heartbeat_msg.workflow_id.is_none() && heartbeat_msg.activity_id.is_none() {
507 continue;
508 }
509 if let Err(error) = session.heartbeat.record_heartbeat(
510 session.worker_id,
511 decode_heartbeat(heartbeat_msg),
512 std::time::Instant::now(),
513 ) {
514 if matches!(error, crate::ServerError::LockPoisoned { .. }) {
519 tracing::error!(
520 worker_id = ?session.worker_id,
521 %error,
522 "heartbeat tracker lock poisoned; liveness state untrustworthy"
523 );
524 } else {
525 tracing::warn!(
526 worker_id = ?session.worker_id,
527 %error,
528 "worker heartbeat rejected"
529 );
530 }
531 }
532 enforce_token_expiration(&session, &mut expired_since).await?;
533 }
534 }
535 }
536 Ok(())
537}
538
539fn deliver_liveness_answer(session: &WorkerSession<'_>, sequence: u64) {
549 match session.liveness_waiters.answer(session.worker_id, sequence) {
550 Ok(true) => {}
551 Ok(false) => tracing::warn!(
552 worker_id = ?session.worker_id,
553 liveness_ping = sequence,
554 "worker answered a liveness ping the server was no longer waiting for; the answer \
555 arrived after its probe cadence expired, or echoed a sequence that was never asked. \
556 It banks NOTHING toward the dispatch probation"
557 ),
558 Err(error) => tracing::error!(
559 worker_id = ?session.worker_id,
560 liveness_ping = sequence,
561 %error,
562 "gRPC liveness waiter map is poisoned; no gRPC worker on this server can clear its \
563 dispatch probation until the process is restarted"
564 ),
565 }
566}
567
568fn refresh_connection_lease(session: &WorkerSession<'_>) -> Result<(), Status> {
569 session
570 .heartbeat
571 .record_connection_activity(session.worker_id, std::time::Instant::now())
572 .map(|_| ())
573 .map_err(|error| {
574 tracing::error!(
575 worker_id = ?session.worker_id,
576 %error,
577 "failed to advance worker connection lease"
578 );
579 status_from_server_error(&error)
580 })
581}
582
583fn warn_duplicate_registration(worker_id: WorkerId) {
584 tracing::warn!(
585 worker_id = ?worker_id,
586 "ignoring subsequent RegisterWorker message; \
587 only the first registration is accepted per stream"
588 );
589}
590
591async fn enforce_token_expiration(
592 session: &WorkerSession<'_>,
593 expired_since: &mut Option<std::time::Instant>,
594) -> Result<(), Status> {
595 if !token_expired(session.token_expires_at) {
596 return Ok(());
597 }
598 let first_expired = *expired_since.get_or_insert_with(std::time::Instant::now);
599 let _ = session
600 .task_tx
601 .send(Err(Status::unauthenticated(
602 "worker token expired; re-authentication required",
603 )))
604 .await;
605 if first_expired.elapsed() >= session.heartbeat_grace {
606 return Err(Status::unauthenticated("worker token expired"));
607 }
608 Ok(())
609}
610
611async fn worker_caller_from_metadata(
612 metadata: &tonic::metadata::MetadataMap,
613 state: &ServerState,
614) -> Result<CallerIdentity, Status> {
615 crate::api::grpc::caller_from_metadata(metadata, state).await
616}
617
618async fn token_expiration_from_metadata(
619 metadata: &tonic::metadata::MetadataMap,
620 state: &ServerState,
621) -> Result<Option<u64>, Status> {
622 if !state.runtime_config().auth.enabled {
623 return Ok(None);
624 }
625 #[cfg(feature = "auth")]
626 {
627 let bearer = metadata
628 .get("authorization")
629 .and_then(|value| value.to_str().ok())
630 .and_then(parse_bearer)
631 .ok_or_else(|| Status::unauthenticated("missing bearer token"))?;
632 let Some(cache) = state.jwks_cache() else {
633 return Err(Status::unauthenticated("invalid bearer token"));
634 };
635 return cache
636 .validate(&bearer)
637 .await
638 .map(|claims| Some(claims.expires_at()))
639 .map_err(|_error| Status::unauthenticated("invalid bearer token"));
640 }
641 #[cfg(not(feature = "auth"))]
642 {
643 let _ = metadata;
644 tokio::task::yield_now().await;
646 Ok(None)
647 }
648}
649
650#[cfg(feature = "auth")]
651fn parse_bearer(value: &str) -> Option<String> {
652 let token = value.strip_prefix("Bearer ")?.trim();
653 if token.is_empty() {
654 return None;
655 }
656 Some(token.to_owned())
657}
658
659fn token_expired(expires_at: Option<u64>) -> bool {
660 expires_at.is_some_and(|expires_at| {
661 #[cfg(feature = "auth")]
662 {
663 crate::auth::jwks::is_expired(expires_at)
664 }
665 #[cfg(not(feature = "auth"))]
666 {
667 let _ = expires_at;
668 false
669 }
670 })
671}
672
673fn status_from_server_error(error: &crate::ServerError) -> Status {
674 let wire = error.to_wire_error();
675 if wire.code == aion_proto::WireErrorCode::NamespaceDenied {
676 Status::permission_denied(wire.message)
677 } else {
678 Status::internal(wire.message)
679 }
680}
681
682fn result_ack_frame(
684 workflow_id: &aion_core::WorkflowId,
685 activity_id: &aion_core::ActivityId,
686) -> generated::ServerToWorker {
687 generated::ServerToWorker {
688 message: Some(generated::server_to_worker::Message::ResultAck(
689 generated::ResultAck {
690 workflow_id: Some(generated::WorkflowId {
691 uuid: workflow_id.to_string(),
692 }),
693 activity_id: Some(generated::ActivityId {
694 sequence_position: activity_id.sequence_position(),
695 }),
696 },
697 )),
698 }
699}
700
701fn decode_register(r: generated::RegisterWorker) -> ProtoRegisterWorker {
702 ProtoRegisterWorker {
703 namespaces: r.namespaces,
704 activity_types: r.activity_types,
705 task_queue: r.task_queue,
706 node: r.node,
707 activities: r
708 .activities
709 .into_iter()
710 .map(|activity| ProtoActivityDescriptor {
711 name: activity.name,
712 input_schema_json: activity.input_schema_json,
713 output_schema_json: activity.output_schema_json,
714 })
715 .collect(),
716 identity: r.identity,
717 instance: r.instance.map(|instance| ProtoWorkerInstanceIdentity {
718 deployment: instance.deployment,
719 instance_id: instance.instance_id,
720 }),
721 max_concurrency: r.max_concurrency,
727 }
728}
729
730fn validate_worker_contracts(
731 state: &ServerState,
732 register: &ProtoRegisterWorker,
733) -> Result<(), Status> {
734 let advertised = register
735 .activities
736 .iter()
737 .map(|activity| {
738 let input_schema =
739 serde_json::from_str(&activity.input_schema_json).map_err(|error| {
740 Status::invalid_argument(format!(
741 "worker activity `{}` input_schema_json is invalid: {error}",
742 activity.name
743 ))
744 })?;
745 let output_schema =
746 serde_json::from_str(&activity.output_schema_json).map_err(|error| {
747 Status::invalid_argument(format!(
748 "worker activity `{}` output_schema_json is invalid: {error}",
749 activity.name
750 ))
751 })?;
752 Ok(aion_package::ActivityDescriptor {
753 name: activity.name.clone(),
754 input_schema,
755 output_schema,
756 })
757 })
758 .collect::<Result<Vec<_>, Status>>()?;
759 let Ok(engine) = state.engine() else {
765 tracing::warn!(
766 task_queue = %register.task_queue,
767 identity = %register.identity,
768 "worker contract check skipped: server state has no engine handle, \
769 so no deployed contracts exist to check against"
770 );
771 return Ok(());
772 };
773 let activity_types = register
777 .activity_types
778 .iter()
779 .cloned()
780 .collect::<std::collections::BTreeSet<_>>();
781 crate::worker::contracts::validate_worker_contracts(
782 &engine,
783 state.worker_registry().admission_audit(),
784 ®ister.task_queue,
785 crate::worker::registry::optional_node(®ister.node).as_deref(),
786 ®ister.identity,
787 crate::worker::contracts::WorkerAdvertisement {
788 activity_types: &activity_types,
789 contracts: &advertised,
790 },
791 )
792 .map_err(|error| match error {
793 crate::worker::contracts::ContractAdmissionError::Mismatch { .. } => {
794 Status::failed_precondition(error.to_string())
795 }
796 crate::worker::contracts::ContractAdmissionError::Catalog { .. } => {
797 Status::internal(error.to_string())
798 }
799 })
800}
801
802fn encode_server_to_worker(message: WorkerMessage) -> generated::ServerToWorker {
803 let message = match message {
804 WorkerMessage::ActivityTask(task) => {
805 generated::server_to_worker::Message::Task(encode_task(*task))
806 }
807 WorkerMessage::DrainRequest => {
808 generated::server_to_worker::Message::Drain(generated::DrainRequest {})
809 }
810 WorkerMessage::LivenessPing(ping) => {
814 generated::server_to_worker::Message::LivenessPing(generated::LivenessPing {
815 liveness_ping: ping.liveness_ping,
816 silence_window_ms: ping.silence_window_ms,
817 })
818 }
819 WorkerMessage::CancelActivity(cancel) => {
823 generated::server_to_worker::Message::CancelActivity(generated::CancelActivity {
824 workflow_id: cancel
825 .workflow_id
826 .map(|id| generated::WorkflowId { uuid: id.uuid }),
827 activity_id: cancel.activity_id.map(|id| generated::ActivityId {
828 sequence_position: id.sequence_position,
829 }),
830 })
831 }
832 };
833 generated::ServerToWorker {
834 message: Some(message),
835 }
836}
837
838fn encode_task(task: aion_proto::ProtoActivityTask) -> generated::ActivityTask {
839 generated::ActivityTask {
840 workflow_id: task
841 .workflow_id
842 .map(|id| generated::WorkflowId { uuid: id.uuid }),
843 activity_id: task.activity_id.map(|id| generated::ActivityId {
844 sequence_position: id.sequence_position,
845 }),
846 activity_type: task.activity_type,
847 input: task.input.map(|p| generated::Payload {
848 content_type: p.content_type,
849 bytes: p.bytes,
850 }),
851 attempt: task.attempt,
852 labels: task.labels,
853 run_id: task.run_id.map(|id| generated::RunId { uuid: id.uuid }),
854 completion_token: task.completion_token,
855 idempotency_key: task.idempotency_key,
856 }
857}
858
859fn decode_activity_result(r: generated::ActivityResult) -> ProtoActivityResult {
860 ProtoActivityResult {
861 workflow_id: r
862 .workflow_id
863 .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
864 activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
865 sequence_position: id.sequence_position,
866 }),
867 outcome: r.outcome.map(decode_outcome),
868 run_id: r.run_id.map(|id| aion_proto::ProtoRunId { uuid: id.uuid }),
869 completion_token: r.completion_token,
870 }
871}
872
873fn decode_heartbeat(r: generated::Heartbeat) -> aion_proto::ProtoHeartbeat {
874 aion_proto::ProtoHeartbeat {
875 workflow_id: r
876 .workflow_id
877 .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
878 activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
879 sequence_position: id.sequence_position,
880 }),
881 progress: r.progress.map(|p| aion_proto::ProtoPayload {
882 content_type: p.content_type,
883 bytes: p.bytes,
884 }),
885 }
886}
887
888fn decode_outcome(
889 outcome: generated::activity_result::Outcome,
890) -> aion_proto::proto_activity_result::Outcome {
891 match outcome {
892 generated::activity_result::Outcome::Result(p) => {
893 aion_proto::proto_activity_result::Outcome::Result(aion_proto::ProtoPayload {
894 content_type: p.content_type,
895 bytes: p.bytes,
896 })
897 }
898 generated::activity_result::Outcome::Error(e) => {
899 aion_proto::proto_activity_result::Outcome::Error(aion_proto::ProtoActivityError {
900 kind: e.kind,
901 message: e.message,
902 details: e.details.map(|p| aion_proto::ProtoPayload {
903 content_type: p.content_type,
904 bytes: p.bytes,
905 }),
906 })
907 }
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use std::time::{Duration, Instant};
914
915 use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
916
917 use crate::shutdown::DrainState;
918 use crate::worker::dispatch::{
919 ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
920 };
921 use crate::worker::heartbeat::InFlightActivity;
922 use crate::worker::registry::ConnectedWorkerRegistry;
923 use crate::worker::{HeartbeatTracker, PendingActivities};
924
925 use super::{
926 WorkerSession, decode_register, generated, handle_task_refusal, teardown_worker_stream,
927 };
928
929 type TestError = Box<dyn std::error::Error>;
930
931 #[test]
932 fn decode_register_maps_tag_seven_instance_without_changing_absent_registration() {
933 let generated = super::generated::RegisterWorker {
934 namespaces: vec!["orders".to_owned()],
935 activity_types: vec!["shell".to_owned()],
936 task_queue: "shell".to_owned(),
937 node: "node-a".to_owned(),
938 activities: Vec::new(),
939 identity: "build-a".to_owned(),
940 instance: Some(super::generated::WorkerInstanceIdentity {
941 deployment: "shells".to_owned(),
942 instance_id: "instance-1".to_owned(),
943 }),
944 max_concurrency: Some(4),
945 };
946 let mapped = decode_register(generated.clone());
947 let instance = mapped.instance.as_ref();
948 assert_eq!(
949 instance.map(|value| value.deployment.as_str()),
950 Some("shells")
951 );
952 assert_eq!(
953 instance.map(|value| value.instance_id.as_str()),
954 Some("instance-1")
955 );
956
957 let mut absent = generated;
958 absent.instance = None;
959 let mapped_absent = decode_register(absent);
960 assert!(mapped_absent.instance.is_none());
961 assert_eq!(mapped_absent.identity, "build-a");
962 }
963
964 struct TeardownFixture {
968 registry: ConnectedWorkerRegistry,
969 tracker: HeartbeatTracker,
970 pending: PendingActivities,
971 drain: DrainState,
972 worker_id: crate::worker::registry::WorkerId,
973 workflow_id: WorkflowId,
974 run_id: RunId,
978 activity_id: ActivityId,
979 completion_token: crate::worker::CompletionToken,
980 rx: std::sync::mpsc::Receiver<Result<String, String>>,
981 _registration: crate::worker::registry::WorkerRegistration,
984 }
985
986 fn fixture() -> Result<TeardownFixture, TestError> {
987 let registry = ConnectedWorkerRegistry::default();
988 let (tx, _rx) = tokio::sync::mpsc::channel(1);
989 let activity_types = [String::from("greet")];
990 let registration = registry.register(
991 "default",
992 activity_types.iter(),
993 tx,
994 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
995 )?;
996 let worker_id = registration
997 .worker_id()
998 .ok_or("test worker registration missing id")?;
999 let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1000 let pending = PendingActivities::new(Duration::from_secs(5));
1001 let workflow_id = WorkflowId::new_v4();
1002 let run_id = RunId::new_v4();
1003 let activity_id = ActivityId::from_sequence_position(0);
1004 let (completion_token, rx, _) =
1005 pending.insert_for_test(workflow_id.clone(), &run_id, activity_id.clone(), 1)?;
1006 tracker.track_task(
1007 worker_id,
1008 InFlightActivity {
1009 workflow_id: workflow_id.clone(),
1010 activity_id: activity_id.clone(),
1011 attempt: 1,
1012 completion_token: completion_token.clone(),
1013 },
1014 Instant::now(),
1015 ®istry,
1016 None,
1017 )?;
1018 Ok(TeardownFixture {
1019 registry,
1020 tracker,
1021 pending,
1022 drain: DrainState::default(),
1023 worker_id,
1024 workflow_id,
1025 run_id,
1026 activity_id,
1027 completion_token,
1028 rx,
1029 _registration: registration,
1030 })
1031 }
1032
1033 #[test]
1045 fn a_task_refusal_reparks_the_dispatch_and_frees_the_slot() -> Result<(), TestError> {
1046 let fixture = fixture()?;
1047 assert_eq!(
1048 fixture.registry.in_flight_for_worker(fixture.worker_id)?,
1049 1,
1050 "the dispatch is on this worker's account before it is refused"
1051 );
1052
1053 let session = WorkerSession {
1054 worker_id: fixture.worker_id,
1055 pending: &fixture.pending,
1056 heartbeat: &fixture.tracker,
1057 registry: &fixture.registry,
1058 drain: &fixture.drain,
1059 token_expires_at: None,
1060 heartbeat_grace: Duration::from_secs(5),
1061 task_tx: tokio::sync::mpsc::channel(1).0,
1062 liveness_waiters: crate::worker::GrpcLivenessWaiters::default(),
1063 };
1064 handle_task_refusal(
1065 &session,
1066 generated::TaskRefused {
1067 workflow_id: Some(generated::WorkflowId {
1068 uuid: fixture.workflow_id.to_string(),
1069 }),
1070 activity_id: Some(generated::ActivityId {
1071 sequence_position: fixture.activity_id.sequence_position(),
1072 }),
1073 reason: String::from("worker is already running 4 activities"),
1074 completion_token: fixture.completion_token.as_str().to_owned(),
1075 },
1076 );
1077
1078 let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1079 let reason = resolved
1080 .err()
1081 .ok_or("a refusal must not resolve as success")?;
1082 assert!(
1083 reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1084 "a refusal is a TRANSPORT-domain non-start, so it must carry the class the engine re-dispatches attempt-neutrally — not a class that spends the action's own retry budget: {reason}"
1085 );
1086 assert!(
1087 reason.contains("refused the dispatch"),
1088 "and it must say a worker DECLINED, not that one died: nothing was lost here and an operator sent looking for a dead worker would find a healthy one: {reason}"
1089 );
1090 assert_eq!(
1091 fixture.registry.in_flight_for_worker(fixture.worker_id)?,
1092 0,
1093 "the refusal must free the slot the server believed this worker was using, or the very next selection reads the same full count and picks it again"
1094 );
1095 assert_eq!(
1096 fixture.tracker.in_flight_for_worker(fixture.worker_id)?,
1097 0,
1098 "the tracker's own record must go with it: an activity that is not running has no liveness to keep"
1099 );
1100 assert!(
1101 fixture.registry.is_registered(fixture.worker_id)?,
1102 "the worker declined ONE task; it is neither gone nor at fault"
1103 );
1104 Ok(())
1105 }
1106
1107 #[test]
1114 fn a_refusal_from_a_stale_generation_is_refused() -> Result<(), TestError> {
1115 let fixture = fixture()?;
1116 let session = WorkerSession {
1117 worker_id: fixture.worker_id,
1118 pending: &fixture.pending,
1119 heartbeat: &fixture.tracker,
1120 registry: &fixture.registry,
1121 drain: &fixture.drain,
1122 token_expires_at: None,
1123 heartbeat_grace: Duration::from_secs(5),
1124 task_tx: tokio::sync::mpsc::channel(1).0,
1125 liveness_waiters: crate::worker::GrpcLivenessWaiters::default(),
1126 };
1127 let stale = crate::worker::CompletionToken::from_wire(
1129 &fixture.workflow_id,
1130 &fixture.activity_id,
1131 uuid::Uuid::new_v4().to_string(),
1132 )?;
1133 handle_task_refusal(
1134 &session,
1135 generated::TaskRefused {
1136 workflow_id: Some(generated::WorkflowId {
1137 uuid: fixture.workflow_id.to_string(),
1138 }),
1139 activity_id: Some(generated::ActivityId {
1140 sequence_position: fixture.activity_id.sequence_position(),
1141 }),
1142 reason: String::from("worker is already running 4 activities"),
1143 completion_token: stale.as_str().to_owned(),
1144 },
1145 );
1146 assert!(
1147 fixture.rx.recv_timeout(Duration::from_millis(100)).is_err(),
1148 "a refusal carrying a superseded generation must not resolve the live attempt's waiter"
1149 );
1150 assert!(
1157 fixture.tracker.is_tracked(
1158 fixture.worker_id,
1159 &fixture.workflow_id,
1160 &fixture.activity_id
1161 )?,
1162 "the live attempt's liveness entry must survive a stale refusal; without it the \
1163 expiry sweep can no longer see a dispatch that is still running"
1164 );
1165 assert_eq!(
1166 fixture.registry.in_flight_for_worker(fixture.worker_id)?,
1167 1,
1168 "the live attempt's capacity slot must survive a stale refusal; releasing it \
1169 over-reports what this worker can take and lets selection push past its admission"
1170 );
1171 Ok(())
1172 }
1173
1174 #[test]
1178 fn teardown_under_drain_parks_instead_of_failing() -> Result<(), TestError> {
1179 let fixture = fixture()?;
1180 assert!(fixture.drain.begin());
1181
1182 teardown_worker_stream(
1183 fixture.worker_id,
1184 &fixture.tracker,
1185 &fixture.registry,
1186 &fixture.pending,
1187 &fixture.drain,
1188 );
1189
1190 let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1191 assert_eq!(
1192 resolved,
1193 Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
1194 "a drain teardown must resolve the waiter with the parked sentinel"
1195 );
1196 assert_eq!(fixture.tracker.in_flight_count()?, 0);
1197 assert!(
1198 !fixture.tracker.is_tracked(
1199 fixture.worker_id,
1200 &fixture.workflow_id,
1201 &fixture.activity_id
1202 )?,
1203 "parking must retire the tracked entry"
1204 );
1205 Ok(())
1206 }
1207
1208 #[test]
1218 fn teardown_without_drain_fails_with_the_transport_domain_lost_worker_class()
1219 -> Result<(), TestError> {
1220 let fixture = fixture()?;
1221
1222 teardown_worker_stream(
1223 fixture.worker_id,
1224 &fixture.tracker,
1225 &fixture.registry,
1226 &fixture.pending,
1227 &fixture.drain,
1228 );
1229
1230 let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1231 let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1232 assert!(
1233 reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1234 "a mid-run teardown must surface the TRANSPORT-domain loss class, never the \
1235 action's retry vocabulary: {reason}"
1236 );
1237 assert!(
1238 reason.contains("lost before reporting activity result"),
1239 "the failure must name worker loss: {reason}"
1240 );
1241 assert_eq!(fixture.tracker.in_flight_count()?, 0);
1242 Ok(())
1243 }
1244
1245 #[test]
1249 fn stale_worker_completion_after_heartbeat_loss_does_not_resolve_retry() -> Result<(), TestError>
1250 {
1251 let fixture = fixture()?;
1252 teardown_worker_stream(
1253 fixture.worker_id,
1254 &fixture.tracker,
1255 &fixture.registry,
1256 &fixture.pending,
1257 &fixture.drain,
1258 );
1259 let first = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1260 assert!(
1261 first
1262 .err()
1263 .is_some_and(|reason| reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX)),
1264 "worker A loss must release attempt 1 in the transport-loss class"
1265 );
1266
1267 let (retry_token, retry_rx, _) = fixture.pending.insert_for_test(
1270 fixture.workflow_id.clone(),
1271 &fixture.run_id,
1272 fixture.activity_id.clone(),
1273 2,
1274 )?;
1275 let rejected = fixture.pending.complete_activity(ActivityCompletion {
1276 workflow_id: fixture.workflow_id,
1277 activity_id: fixture.activity_id,
1278 run_id: None,
1279 completion_token: fixture.completion_token,
1280 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1281 ContentType::Json,
1282 br#"{"worker":"A","stale":true}"#.to_vec(),
1283 )),
1284 });
1285
1286 assert!(matches!(
1287 rejected,
1288 Err(crate::ServerError::ActivityCompletionRejected { .. })
1289 ));
1290 drop(retry_token);
1291 assert!(
1292 retry_rx.recv_timeout(Duration::from_millis(50)).is_err(),
1293 "worker A's late completion must be rejected instead of resolving worker B's retry"
1294 );
1295 Ok(())
1296 }
1297
1298 #[test]
1316 fn a_worker_loss_consumes_the_sibling_token_of_a_redelivered_attempt() -> Result<(), TestError>
1317 {
1318 let fixture = fixture()?;
1319 let sibling = fixture.pending.completion_fences().issue(
1323 &fixture.workflow_id,
1324 &fixture.run_id,
1325 &fixture.activity_id,
1326 1,
1327 )?;
1328
1329 teardown_worker_stream(
1330 fixture.worker_id,
1331 &fixture.tracker,
1332 &fixture.registry,
1333 &fixture.pending,
1334 &fixture.drain,
1335 );
1336
1337 let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1338 let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1339 assert!(
1340 reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1341 "the loss must resolve in the transport domain, which re-dispatches the same attempt \
1342 rather than faulting the action: {reason}"
1343 );
1344
1345 let refused = fixture.pending.complete_activity(ActivityCompletion {
1346 workflow_id: fixture.workflow_id.clone(),
1347 activity_id: fixture.activity_id.clone(),
1348 run_id: None,
1349 completion_token: sibling,
1350 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1351 ContentType::Json,
1352 br#"{"worker":"B"}"#.to_vec(),
1353 )),
1354 });
1355 assert!(
1356 matches!(
1357 refused,
1358 Err(crate::ServerError::ActivityCompletionRejected {
1359 reason: crate::error::CompletionRejectionReason::NoCurrentGeneration,
1360 ..
1361 })
1362 ),
1363 "the first accepted completion — here the loss verdict — consumes the whole \
1364 generation, so the sibling worker's result is the duplicate: {refused:?}"
1365 );
1366 Ok(())
1367 }
1368}