1use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use aion_proto::{ProtoActivityTask, ProtoRegisterWorker};
7use tokio::sync::{Notify, mpsc};
8
9use crate::error::ServerError;
10use crate::namespace::{CallerIdentity, NamespaceGuard, NamespaceOperation};
11use crate::observability::Metrics;
12
13pub use aion_core::DEFAULT_TASK_QUEUE;
24
25pub type WorkerTaskSender = mpsc::Sender<WorkerMessage>;
27
28#[derive(Clone, Debug)]
40pub enum WorkerDelivery {
41 Grpc(WorkerTaskSender),
44 #[cfg(feature = "liminal-transport")]
49 Liminal(crate::worker::liminal_transport::LiminalWorkerDelivery),
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum WorkerMessage {
55 ActivityTask(ProtoActivityTask),
57 DrainRequest,
59}
60
61#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct PoolAddress {
74 namespace: String,
75 task_queue: String,
76}
77
78impl PoolAddress {
79 #[must_use]
83 pub fn new(namespace: impl Into<String>, task_queue: impl Into<String>) -> Self {
84 let task_queue = task_queue.into();
85 let task_queue = if task_queue.is_empty() {
86 String::from(DEFAULT_TASK_QUEUE)
87 } else {
88 task_queue
89 };
90 Self {
91 namespace: namespace.into(),
92 task_queue,
93 }
94 }
95
96 #[must_use]
98 pub fn namespace(&self) -> &str {
99 &self.namespace
100 }
101
102 #[must_use]
104 pub fn task_queue(&self) -> &str {
105 &self.task_queue
106 }
107}
108
109#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
113struct ActivityKey {
114 pool: PoolAddress,
115 activity_type: String,
116}
117
118impl ActivityKey {
119 fn new(pool: PoolAddress, activity_type: impl Into<String>) -> Self {
120 Self {
121 pool,
122 activity_type: activity_type.into(),
123 }
124 }
125}
126
127type WorkerMap = HashMap<WorkerId, WorkerHandle>;
128type RegistryMap = HashMap<ActivityKey, WorkerMap>;
129
130#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub struct WorkerId(u64);
133
134impl WorkerId {
135 #[must_use]
138 pub const fn value(self) -> u64 {
139 self.0
140 }
141}
142
143#[derive(Clone, Debug)]
151pub struct WorkerHandle {
152 id: WorkerId,
153 namespaces: BTreeSet<String>,
154 task_queue: String,
155 node: Option<String>,
156 activity_types: BTreeSet<String>,
157 delivery: WorkerDelivery,
158}
159
160impl WorkerHandle {
161 #[must_use]
163 pub const fn id(&self) -> WorkerId {
164 self.id
165 }
166
167 #[must_use]
170 pub const fn namespaces(&self) -> &BTreeSet<String> {
171 &self.namespaces
172 }
173
174 #[must_use]
176 pub fn task_queue(&self) -> &str {
177 &self.task_queue
178 }
179
180 #[must_use]
183 pub fn node(&self) -> Option<&str> {
184 self.node.as_deref()
185 }
186
187 #[must_use]
189 pub fn activity_types(&self) -> &BTreeSet<String> {
190 &self.activity_types
191 }
192
193 #[must_use]
195 pub const fn delivery(&self) -> &WorkerDelivery {
196 &self.delivery
197 }
198
199 #[must_use]
206 pub fn sender(&self) -> Option<&WorkerTaskSender> {
207 match &self.delivery {
208 WorkerDelivery::Grpc(sender) => Some(sender),
209 #[cfg(feature = "liminal-transport")]
210 WorkerDelivery::Liminal(_) => None,
211 }
212 }
213}
214
215#[derive(Debug)]
216struct RegistryState {
217 next_worker_id: u64,
218 workers: BTreeMap<WorkerId, WorkerHandle>,
219 by_activity: RegistryMap,
220 rotation: HashMap<ActivityKey, usize>,
223}
224
225impl Default for RegistryState {
226 fn default() -> Self {
227 Self {
228 next_worker_id: 1,
229 workers: BTreeMap::new(),
230 by_activity: HashMap::new(),
231 rotation: HashMap::new(),
232 }
233 }
234}
235
236#[derive(Clone, Debug)]
238pub struct ConnectedWorkerRegistry {
239 inner: Arc<Mutex<RegistryState>>,
240 metrics: Option<Metrics>,
241 worker_arrived: Arc<Notify>,
242}
243
244impl Default for ConnectedWorkerRegistry {
245 fn default() -> Self {
246 Self {
247 inner: Arc::new(Mutex::new(RegistryState::default())),
248 metrics: None,
249 worker_arrived: Arc::new(Notify::new()),
250 }
251 }
252}
253
254impl ConnectedWorkerRegistry {
255 #[must_use]
257 pub fn with_metrics(metrics: Metrics) -> Self {
258 Self {
259 inner: Arc::new(Mutex::new(RegistryState::default())),
260 metrics: Some(metrics),
261 worker_arrived: Arc::new(Notify::new()),
262 }
263 }
264
265 pub async fn accept_registration(
271 &self,
272 guard: &NamespaceGuard,
273 caller: &CallerIdentity,
274 registration: &ProtoRegisterWorker,
275 sender: WorkerTaskSender,
276 ) -> Result<WorkerRegistration, ServerError> {
277 guard
283 .scope(caller, &NamespaceOperation::register_worker(registration))
284 .await?;
285 let namespaces = guard.scope_worker_namespaces(caller, ®istration.namespaces)?;
286 let node = optional_node(®istration.node);
287 self.register_namespaces(
288 namespaces,
289 registration.task_queue.clone(),
290 node,
291 registration.activity_types.iter(),
292 sender,
293 )
294 }
295
296 pub fn register<'a>(
307 &self,
308 namespace: impl Into<String>,
309 activity_types: impl IntoIterator<Item = &'a String>,
310 sender: WorkerTaskSender,
311 ) -> Result<WorkerRegistration, ServerError> {
312 self.register_namespaces(
313 [namespace.into()],
314 String::from(DEFAULT_TASK_QUEUE),
315 None,
316 activity_types,
317 sender,
318 )
319 }
320
321 pub fn register_pool<'a>(
328 &self,
329 pool: PoolAddress,
330 activity_types: impl IntoIterator<Item = &'a String>,
331 sender: WorkerTaskSender,
332 ) -> Result<WorkerRegistration, ServerError> {
333 let PoolAddress {
334 namespace,
335 task_queue,
336 } = pool;
337 self.register_namespaces([namespace], task_queue, None, activity_types, sender)
338 }
339
340 pub fn register_namespaces<'a>(
352 &self,
353 namespaces: impl IntoIterator<Item = String>,
354 task_queue: impl Into<String>,
355 node: Option<String>,
356 activity_types: impl IntoIterator<Item = &'a String>,
357 sender: WorkerTaskSender,
358 ) -> Result<WorkerRegistration, ServerError> {
359 self.register_delivery(
360 namespaces,
361 task_queue,
362 node,
363 activity_types,
364 WorkerDelivery::Grpc(sender),
365 )
366 }
367
368 pub fn register_delivery<'a>(
381 &self,
382 namespaces: impl IntoIterator<Item = String>,
383 task_queue: impl Into<String>,
384 node: Option<String>,
385 activity_types: impl IntoIterator<Item = &'a String>,
386 delivery: WorkerDelivery,
387 ) -> Result<WorkerRegistration, ServerError> {
388 let namespaces = namespaces.into_iter().collect::<BTreeSet<_>>();
389 let task_queue = task_queue.into();
390 let activity_types = activity_types.into_iter().cloned().collect::<BTreeSet<_>>();
391 let mut state = self.state()?;
392 let worker_id = WorkerId(state.next_worker_id);
393 state.next_worker_id = state.next_worker_id.saturating_add(1);
394
395 let handle = WorkerHandle {
396 id: worker_id,
397 namespaces: namespaces.clone(),
398 task_queue: task_queue.clone(),
399 node,
400 activity_types: activity_types.clone(),
401 delivery,
402 };
403
404 for namespace in &namespaces {
405 let pool = PoolAddress::new(namespace.clone(), task_queue.clone());
406 for activity_type in &activity_types {
407 state
408 .by_activity
409 .entry(ActivityKey::new(pool.clone(), activity_type.clone()))
410 .or_default()
411 .insert(worker_id, handle.clone());
412 }
413 }
414 state.workers.insert(worker_id, handle);
415 drop(state);
416
417 if let Some(metrics) = &self.metrics {
418 for namespace in &namespaces {
419 metrics.worker_connected(namespace);
420 }
421 }
422
423 self.worker_arrived.notify_waiters();
424
425 Ok(WorkerRegistration {
426 registry: self.clone(),
427 parts: Some(WorkerRegistrationParts {
428 worker_id,
429 namespaces,
430 task_queue,
431 activity_types,
432 }),
433 })
434 }
435
436 pub async fn wait_for_worker(&self) {
442 self.worker_arrived.notified().await;
443 }
444
445 pub fn workers_for(
467 &self,
468 namespace: &str,
469 task_queue: &str,
470 activity_type: &str,
471 node: Option<&str>,
472 ) -> Result<Vec<WorkerHandle>, ServerError> {
473 let mut state = self.state()?;
474 let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
475 let mut workers: Vec<WorkerHandle> = state
476 .by_activity
477 .get(&key)
478 .map(|workers| {
479 workers
480 .values()
481 .filter(|worker| worker_matches_node(worker, node))
482 .cloned()
483 .collect()
484 })
485 .unwrap_or_default();
486 if workers.is_empty() {
487 return Ok(workers);
488 }
489 workers.sort_by_key(WorkerHandle::id);
490 let idx = state.rotation.entry(key).or_insert(0);
491 let start = *idx % workers.len();
492 *idx = idx.wrapping_add(1);
493 let mut rotated = Vec::with_capacity(workers.len());
494 rotated.extend_from_slice(&workers[start..]);
495 rotated.extend_from_slice(&workers[..start]);
496 Ok(rotated)
497 }
498
499 pub fn all_workers(&self) -> Result<Vec<WorkerHandle>, ServerError> {
505 let state = self.state()?;
506 Ok(state.workers.values().cloned().collect())
507 }
508
509 pub fn broadcast_drain(&self) -> Result<usize, ServerError> {
515 let workers = self.all_workers()?;
516 let mut delivered = 0usize;
517 for worker in workers {
518 let Some(sender) = worker.sender() else {
522 continue;
523 };
524 if sender.try_send(WorkerMessage::DrainRequest).is_ok() {
525 delivered = delivered.saturating_add(1);
526 } else {
527 self.deregister(worker.id())?;
528 }
529 }
530 Ok(delivered)
531 }
532
533 pub fn select_worker(
543 &self,
544 namespace: &str,
545 task_queue: &str,
546 activity_type: &str,
547 node: Option<&str>,
548 ) -> Result<Option<WorkerHandle>, ServerError> {
549 let state = self.state()?;
550 let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
551 Ok(state.by_activity.get(&key).and_then(|workers| {
552 workers
553 .values()
554 .filter(|worker| worker_matches_node(worker, node))
555 .min_by_key(|worker| worker.id)
556 .cloned()
557 }))
558 }
559
560 pub fn is_registered(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
571 Ok(self.state()?.workers.contains_key(&worker_id))
572 }
573
574 pub fn deregister(&self, worker_id: WorkerId) -> Result<(), ServerError> {
580 let mut state = self.state()?;
581 let removed_namespaces = Self::remove_worker(&mut state, worker_id);
582 drop(state);
583
584 if let (Some(namespaces), Some(metrics)) = (removed_namespaces, &self.metrics) {
585 for namespace in &namespaces {
586 metrics.worker_disconnected(namespace);
587 }
588 }
589
590 Ok(())
591 }
592
593 fn remove_worker(state: &mut RegistryState, worker_id: WorkerId) -> Option<BTreeSet<String>> {
597 let handle = state.workers.remove(&worker_id)?;
598
599 for namespace in &handle.namespaces {
600 let pool = PoolAddress::new(namespace.clone(), handle.task_queue.clone());
601 for activity_type in &handle.activity_types {
602 let key = ActivityKey::new(pool.clone(), activity_type.clone());
603 if let Some(workers) = state.by_activity.get_mut(&key) {
604 workers.remove(&worker_id);
605 if workers.is_empty() {
606 state.by_activity.remove(&key);
607 state.rotation.remove(&key);
614 }
615 }
616 }
617 }
618
619 Some(handle.namespaces)
620 }
621
622 fn state(&self) -> Result<MutexGuard<'_, RegistryState>, ServerError> {
623 self.inner
624 .lock()
625 .map_err(|_| ServerError::lock_poisoned("connected worker registry"))
626 }
627}
628
629fn optional_node(node: &str) -> Option<String> {
633 if node.is_empty() {
634 None
635 } else {
636 Some(node.to_owned())
637 }
638}
639
640fn worker_matches_node(worker: &WorkerHandle, node: Option<&str>) -> bool {
645 match node {
646 None => true,
647 Some(node) => worker.node() == Some(node),
648 }
649}
650
651#[derive(Clone, Debug)]
652struct WorkerRegistrationParts {
653 worker_id: WorkerId,
654 namespaces: BTreeSet<String>,
655 task_queue: String,
656 activity_types: BTreeSet<String>,
657}
658
659#[derive(Debug)]
664pub struct WorkerRegistration {
665 registry: ConnectedWorkerRegistry,
666 parts: Option<WorkerRegistrationParts>,
667}
668
669impl WorkerRegistration {
670 #[must_use]
672 pub fn worker_id(&self) -> Option<WorkerId> {
673 self.parts.as_ref().map(|parts| parts.worker_id)
674 }
675
676 #[must_use]
678 pub fn namespaces(&self) -> Option<&BTreeSet<String>> {
679 self.parts.as_ref().map(|parts| &parts.namespaces)
680 }
681
682 #[must_use]
684 pub fn task_queue(&self) -> Option<&str> {
685 self.parts.as_ref().map(|parts| parts.task_queue.as_str())
686 }
687
688 #[must_use]
690 pub fn activity_types(&self) -> Option<&BTreeSet<String>> {
691 self.parts.as_ref().map(|parts| &parts.activity_types)
692 }
693
694 pub fn deregister(mut self) -> Result<(), ServerError> {
700 let Some(parts) = self.parts.take() else {
701 return Ok(());
702 };
703 self.registry.deregister(parts.worker_id)
704 }
705}
706
707impl Drop for WorkerRegistration {
708 fn drop(&mut self) {
709 let Some(parts) = self.parts.take() else {
710 return;
711 };
712 if let Ok(mut state) = self.registry.inner.lock() {
713 let removed_namespaces =
714 ConnectedWorkerRegistry::remove_worker(&mut state, parts.worker_id);
715 if let (Some(namespaces), Some(metrics)) = (removed_namespaces, &self.registry.metrics)
716 {
717 for namespace in &namespaces {
718 metrics.worker_disconnected(namespace);
719 }
720 }
721 }
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use crate::config::NamespaceMode;
728 use crate::namespace::{NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces};
729
730 use super::*;
731
732 fn guard() -> NamespaceGuard {
733 NamespaceGuard::new(NamespaceResolver::authorization_only(
734 NamespaceMode::SharedEngine,
735 StaticWorkflowNamespaces::default(),
736 StaticScheduleNamespaces::default(),
737 ))
738 }
739
740 fn caller(namespace: &str) -> CallerIdentity {
741 CallerIdentity::new("worker", [namespace.to_owned()])
742 }
743
744 fn registration(namespace: &str, activity_types: &[&str]) -> ProtoRegisterWorker {
745 registration_with_queue(namespace, "", activity_types)
746 }
747
748 fn registration_with_queue(
749 namespace: &str,
750 task_queue: &str,
751 activity_types: &[&str],
752 ) -> ProtoRegisterWorker {
753 registration_full(&[namespace], task_queue, "", activity_types)
754 }
755
756 fn registration_full(
757 namespaces: &[&str],
758 task_queue: &str,
759 node: &str,
760 activity_types: &[&str],
761 ) -> ProtoRegisterWorker {
762 ProtoRegisterWorker {
763 namespaces: namespaces.iter().map(|value| (*value).to_owned()).collect(),
764 activity_types: activity_types
765 .iter()
766 .map(|value| (*value).to_owned())
767 .collect(),
768 task_queue: task_queue.to_owned(),
769 node: node.to_owned(),
770 }
771 }
772
773 fn multi_caller(namespaces: &[&str]) -> CallerIdentity {
774 CallerIdentity::new("worker", namespaces.iter().map(|value| (*value).to_owned()))
775 }
776
777 #[tokio::test]
778 async fn register_and_deregister_are_namespace_isolated() -> Result<(), ServerError> {
779 let registry = ConnectedWorkerRegistry::default();
780 let (tenant_a_tx, _tenant_a_rx) = mpsc::channel(1);
781 let (tenant_b_tx, _tenant_b_rx) = mpsc::channel(1);
782
783 let tenant_a = registry
784 .accept_registration(
785 &guard(),
786 &caller("tenant-a"),
787 ®istration("tenant-a", &["charge", "charge"]),
788 tenant_a_tx,
789 )
790 .await?;
791 let tenant_b = registry
792 .accept_registration(
793 &guard(),
794 &caller("tenant-b"),
795 ®istration("tenant-b", &["charge"]),
796 tenant_b_tx,
797 )
798 .await?;
799
800 let tq = DEFAULT_TASK_QUEUE;
801 assert_eq!(
802 registry.workers_for("tenant-a", tq, "charge", None)?.len(),
803 1
804 );
805 assert_eq!(
806 registry.workers_for("tenant-b", tq, "charge", None)?.len(),
807 1
808 );
809 assert!(
810 registry
811 .workers_for("tenant-a", tq, "missing", None)?
812 .is_empty()
813 );
814
815 let tenant_a_id = tenant_a.worker_id();
816 tenant_a.deregister()?;
817
818 assert!(
819 registry
820 .workers_for("tenant-a", tq, "charge", None)?
821 .is_empty()
822 );
823 assert_eq!(
824 registry.workers_for("tenant-b", tq, "charge", None)?.len(),
825 1
826 );
827 assert_ne!(tenant_a_id, tenant_b.worker_id());
828
829 tenant_b.deregister()?;
830 assert!(
831 registry
832 .workers_for("tenant-b", tq, "charge", None)?
833 .is_empty()
834 );
835 Ok(())
836 }
837
838 #[tokio::test]
839 async fn denied_namespace_is_not_registered() -> Result<(), ServerError> {
840 let registry = ConnectedWorkerRegistry::default();
841 let (tx, _rx) = mpsc::channel(1);
842 let denied = registry
843 .accept_registration(
844 &guard(),
845 &caller("tenant-a"),
846 ®istration("tenant-b", &["charge"]),
847 tx,
848 )
849 .await;
850
851 assert!(denied.is_err());
852 assert!(
853 registry
854 .workers_for("tenant-b", DEFAULT_TASK_QUEUE, "charge", None)?
855 .is_empty()
856 );
857 Ok(())
858 }
859
860 #[tokio::test]
861 async fn task_queues_partition_disjoint_pools_within_one_namespace() -> Result<(), ServerError>
862 {
863 let registry = ConnectedWorkerRegistry::default();
867 let (norn_tx, _norn_rx) = mpsc::channel(1);
868 let (claude_a_tx, _claude_a_rx) = mpsc::channel(1);
869 let (claude_b_tx, _claude_b_rx) = mpsc::channel(1);
870
871 let norn = registry
872 .accept_registration(
873 &guard(),
874 &caller("local"),
875 ®istration_with_queue("local", "norn", &["dev"]),
876 norn_tx,
877 )
878 .await?;
879 let claude_a = registry
881 .accept_registration(
882 &guard(),
883 &caller("local"),
884 ®istration_with_queue("local", "claude", &["dev"]),
885 claude_a_tx,
886 )
887 .await?;
888 let claude_b = registry
889 .accept_registration(
890 &guard(),
891 &caller("local"),
892 ®istration_with_queue("local", "claude", &["dev"]),
893 claude_b_tx,
894 )
895 .await?;
896
897 let norn_pool = registry.workers_for("local", "norn", "dev", None)?;
898 assert_eq!(norn_pool.len(), 1, "norn pool has exactly its one worker");
899 let norn_id = norn.worker_id().ok_or_else(missing_id)?;
900 assert_eq!(norn_pool[0].id(), norn_id);
901
902 let claude_pool = registry.workers_for("local", "claude", "dev", None)?;
903 assert_eq!(
904 claude_pool.len(),
905 2,
906 "claude pool sees only its two workers"
907 );
908 let claude_ids: BTreeSet<WorkerId> = claude_pool.iter().map(WorkerHandle::id).collect();
909 assert!(
910 !claude_ids.contains(&norn_id),
911 "the norn worker must never appear in the claude pool"
912 );
913
914 assert!(
917 !registry
918 .workers_for("local", "norn", "dev", None)?
919 .iter()
920 .any(|worker| claude_ids.contains(&worker.id()))
921 );
922
923 let first = registry.workers_for("local", "claude", "dev", None)?[0].id();
927 let second = registry.workers_for("local", "claude", "dev", None)?[0].id();
928 assert_ne!(
929 first, second,
930 "claude pool round-robins across both workers"
931 );
932 assert_eq!(
933 registry.workers_for("local", "norn", "dev", None)?[0].id(),
934 norn_id,
935 "the norn pool rotation is unaffected by claude traffic"
936 );
937
938 norn.deregister()?;
939 claude_a.deregister()?;
940 claude_b.deregister()?;
941 Ok(())
942 }
943
944 #[tokio::test]
945 async fn same_task_queue_in_different_namespaces_is_isolated() -> Result<(), ServerError> {
946 let registry = ConnectedWorkerRegistry::default();
949 let (local_tx, _local_rx) = mpsc::channel(1);
950 let (remote_tx, _remote_rx) = mpsc::channel(1);
951
952 let local = registry
953 .accept_registration(
954 &guard(),
955 &caller("local"),
956 ®istration_with_queue("local", "gpu", &["render"]),
957 local_tx,
958 )
959 .await?;
960 let remote = registry
961 .accept_registration(
962 &guard(),
963 &caller("remote"),
964 ®istration_with_queue("remote", "gpu", &["render"]),
965 remote_tx,
966 )
967 .await?;
968
969 let local_pool = registry.workers_for("local", "gpu", "render", None)?;
970 let remote_pool = registry.workers_for("remote", "gpu", "render", None)?;
971 assert_eq!(local_pool.len(), 1);
972 assert_eq!(remote_pool.len(), 1);
973 assert_ne!(
974 local_pool[0].id(),
975 remote_pool[0].id(),
976 "a shared task_queue string does not merge two namespaces"
977 );
978
979 local.deregister()?;
980 assert!(
981 registry
982 .workers_for("local", "gpu", "render", None)?
983 .is_empty(),
984 "deregistering the local worker leaves the remote namespace untouched"
985 );
986 assert_eq!(
987 registry.workers_for("remote", "gpu", "render", None)?.len(),
988 1
989 );
990
991 remote.deregister()?;
992 Ok(())
993 }
994
995 #[tokio::test]
996 async fn worker_serving_a_namespace_set_is_reachable_in_each() -> Result<(), ServerError> {
997 let registry = ConnectedWorkerRegistry::default();
1000 let (ab_tx, _ab_rx) = mpsc::channel(1);
1001 let (a_tx, _a_rx) = mpsc::channel(1);
1002
1003 let worker_ab = registry
1004 .accept_registration(
1005 &guard(),
1006 &multi_caller(&["a", "b"]),
1007 ®istration_full(&["a", "b"], "default", "", &["dev"]),
1008 ab_tx,
1009 )
1010 .await?;
1011 let worker_a = registry
1012 .accept_registration(
1013 &guard(),
1014 &caller("a"),
1015 ®istration_full(&["a"], "default", "", &["dev"]),
1016 a_tx,
1017 )
1018 .await?;
1019
1020 let in_a = registry.workers_for("a", "default", "dev", None)?;
1021 let in_b = registry.workers_for("b", "default", "dev", None)?;
1022 let both_id = worker_ab.worker_id().ok_or_else(missing_id)?;
1023 let only_a_id = worker_a.worker_id().ok_or_else(missing_id)?;
1024
1025 let a_ids: BTreeSet<WorkerId> = in_a.iter().map(WorkerHandle::id).collect();
1027 assert_eq!(a_ids, BTreeSet::from([both_id, only_a_id]));
1028 assert_eq!(in_b.len(), 1, "only the {{a, b}} worker is reachable in b");
1029 assert_eq!(in_b[0].id(), both_id);
1030 assert!(
1031 !in_b.iter().any(|worker| worker.id() == only_a_id),
1032 "the {{a}}-only worker must not be reachable in b"
1033 );
1034
1035 worker_ab.deregister()?;
1037 assert!(
1038 registry
1039 .workers_for("b", "default", "dev", None)?
1040 .is_empty()
1041 );
1042 assert_eq!(registry.workers_for("a", "default", "dev", None)?.len(), 1);
1043
1044 worker_a.deregister()?;
1045 Ok(())
1046 }
1047
1048 #[tokio::test]
1049 async fn node_pin_filters_within_pool() -> Result<(), ServerError> {
1050 let registry = ConnectedWorkerRegistry::default();
1054 let (n1_tx, _n1_rx) = mpsc::channel(1);
1055 let (n2_tx, _n2_rx) = mpsc::channel(1);
1056
1057 let on_n1 = registry
1058 .accept_registration(
1059 &guard(),
1060 &caller("ns"),
1061 ®istration_full(&["ns"], "tq", "n1", &["dev"]),
1062 n1_tx,
1063 )
1064 .await?;
1065 let on_n2 = registry
1066 .accept_registration(
1067 &guard(),
1068 &caller("ns"),
1069 ®istration_full(&["ns"], "tq", "n2", &["dev"]),
1070 n2_tx,
1071 )
1072 .await?;
1073 let n1_id = on_n1.worker_id().ok_or_else(missing_id)?;
1074 let n2_id = on_n2.worker_id().ok_or_else(missing_id)?;
1075
1076 let unpinned = registry.workers_for("ns", "tq", "dev", None)?;
1078 assert_eq!(unpinned.len(), 2, "unpinned reaches the whole pool");
1079 let first = registry.workers_for("ns", "tq", "dev", None)?[0].id();
1080 let second = registry.workers_for("ns", "tq", "dev", None)?[0].id();
1081 assert_ne!(first, second, "unpinned round-robins across both nodes");
1082
1083 let pinned_n1 = registry.workers_for("ns", "tq", "dev", Some("n1"))?;
1085 assert_eq!(pinned_n1.len(), 1);
1086 assert_eq!(pinned_n1[0].id(), n1_id);
1087 let pinned_n2 = registry.workers_for("ns", "tq", "dev", Some("n2"))?;
1088 assert_eq!(pinned_n2.len(), 1);
1089 assert_eq!(pinned_n2[0].id(), n2_id);
1090
1091 assert_eq!(
1093 registry
1094 .select_worker("ns", "tq", "dev", Some("n1"))?
1095 .map(|worker| worker.id()),
1096 Some(n1_id)
1097 );
1098
1099 assert!(
1102 registry
1103 .workers_for("ns", "tq", "dev", Some("absent"))?
1104 .is_empty(),
1105 "a pin to a node with no worker yields no candidate"
1106 );
1107 assert!(
1108 registry
1109 .select_worker("ns", "tq", "dev", Some("absent"))?
1110 .is_none()
1111 );
1112
1113 on_n1.deregister()?;
1114 on_n2.deregister()?;
1115 Ok(())
1116 }
1117
1118 #[tokio::test]
1119 async fn shared_node_id_round_robins_across_workers() -> Result<(), ServerError> {
1120 let registry = ConnectedWorkerRegistry::default();
1123 let (a_tx, _a_rx) = mpsc::channel(1);
1124 let (b_tx, _b_rx) = mpsc::channel(1);
1125
1126 let worker_a = registry
1127 .accept_registration(
1128 &guard(),
1129 &caller("ns"),
1130 ®istration_full(&["ns"], "tq", "shared", &["dev"]),
1131 a_tx,
1132 )
1133 .await?;
1134 let worker_b = registry
1135 .accept_registration(
1136 &guard(),
1137 &caller("ns"),
1138 ®istration_full(&["ns"], "tq", "shared", &["dev"]),
1139 b_tx,
1140 )
1141 .await?;
1142 let a_id = worker_a.worker_id().ok_or_else(missing_id)?;
1143 let b_id = worker_b.worker_id().ok_or_else(missing_id)?;
1144
1145 let pinned = registry.workers_for("ns", "tq", "dev", Some("shared"))?;
1146 assert_eq!(
1147 pinned.len(),
1148 2,
1149 "both workers on the shared node are candidates"
1150 );
1151 let pinned_ids: BTreeSet<WorkerId> = pinned.iter().map(WorkerHandle::id).collect();
1152 assert_eq!(pinned_ids, BTreeSet::from([a_id, b_id]));
1153
1154 let first = registry.workers_for("ns", "tq", "dev", Some("shared"))?[0].id();
1155 let second = registry.workers_for("ns", "tq", "dev", Some("shared"))?[0].id();
1156 assert_ne!(
1157 first, second,
1158 "a pin to a shared node round-robins across both workers on it"
1159 );
1160
1161 worker_a.deregister()?;
1162 worker_b.deregister()?;
1163 Ok(())
1164 }
1165
1166 #[tokio::test]
1167 async fn rotation_cursor_is_pruned_when_last_worker_leaves() -> Result<(), ServerError> {
1168 let registry = ConnectedWorkerRegistry::default();
1173 let (tx, _rx) = mpsc::channel(1);
1174 let worker = registry
1175 .accept_registration(
1176 &guard(),
1177 &caller("ns"),
1178 ®istration_full(&["ns"], "tq", "", &["dev"]),
1179 tx,
1180 )
1181 .await?;
1182
1183 let _ = registry.workers_for("ns", "tq", "dev", None)?;
1185 let key = ActivityKey::new(PoolAddress::new("ns", "tq"), "dev");
1186 assert!(
1187 registry.state()?.rotation.contains_key(&key),
1188 "a lookup must have created the rotation cursor"
1189 );
1190
1191 worker.deregister()?;
1192 let state = registry.state()?;
1193 assert!(
1194 !state.rotation.contains_key(&key),
1195 "the rotation cursor must be pruned once the last worker leaves"
1196 );
1197 assert!(
1198 !state.by_activity.contains_key(&key),
1199 "the activity bucket must also be gone"
1200 );
1201 Ok(())
1202 }
1203
1204 fn missing_id() -> ServerError {
1205 ServerError::lock_poisoned("registration unexpectedly missing a worker id")
1206 }
1207}