Skip to main content

aion_server/worker/
registry.rs

1//! Connected-worker registry keyed by worker-pool address and activity type.
2
3use 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
13/// The literal task queue an empty/absent selector normalizes to.
14///
15/// A worker-pool address has two disjoint dimensions; the second one
16/// (`task_queue`) is a liveness selector, not a correctness boundary. An empty
17/// `task_queue` is normalized to this one named default pool so a producer that
18/// names no queue and a worker that advertises none both land on the same pool.
19///
20/// Re-exported from [`aion_core::DEFAULT_TASK_QUEUE`] so the server cannot drift
21/// from the canonical domain default; the name is kept stable here for existing
22/// call sites.
23pub use aion_core::DEFAULT_TASK_QUEUE;
24
25/// Server-side handle used to push activity tasks to a connected worker stream.
26pub type WorkerTaskSender = mpsc::Sender<WorkerMessage>;
27
28/// Transport through which the server delivers a dispatch to a registered worker.
29///
30/// A worker is selected the SAME way regardless of transport (`select_worker`
31/// over the `(namespace, task_queue, node)` pool key); only the delivery leg
32/// differs. The default gRPC path pushes a [`WorkerMessage`] onto the worker's
33/// stream `mpsc` ([`WorkerDelivery::Grpc`]); a liminal-connected worker is
34/// delivered to by pushing the dispatch out on its existing liminal connection
35/// ([`WorkerDelivery::Liminal`], feature-gated). This enum is the minimal
36/// transport-agnostic seam: the registry holds it on each [`WorkerHandle`], and
37/// the dispatch path reads the variant it needs. The gRPC variant carries exactly
38/// the `mpsc::Sender` it always did, so the gRPC dispatch path is unchanged.
39#[derive(Clone, Debug)]
40pub enum WorkerDelivery {
41    /// gRPC stream delivery: the dispatch path pushes a [`WorkerMessage`] onto
42    /// this `mpsc` sender, exactly as before this enum existed.
43    Grpc(WorkerTaskSender),
44    /// Liminal server-push delivery: the dispatch path pushes the serialized
45    /// dispatch out on the worker's existing liminal connection and awaits the
46    /// correlated reply. Carries the connection identity needed to address that
47    /// push.
48    #[cfg(feature = "liminal-transport")]
49    Liminal(crate::worker::liminal_transport::LiminalWorkerDelivery),
50}
51
52/// Message queued from server-side dispatch/shutdown into a worker stream writer.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum WorkerMessage {
55    /// Activity invocation pushed to a worker.
56    ActivityTask(ProtoActivityTask),
57    /// Graceful-shutdown notification; no new work will be dispatched.
58    DrainRequest,
59}
60
61/// Address of a worker pool: the two disjoint routing dimensions that select a
62/// pool, before an `activity_type` is matched within it.
63///
64/// `namespace` is the correctness/isolation boundary — a workflow's activities
65/// only ever reach workers in the workflow's namespace, so crossing it is a bug.
66/// `task_queue` is the pool/flavour selector within that namespace (norn /
67/// claude / cpu / gpu) — a miss is a liveness issue, never a correctness one.
68///
69/// This is a named type rather than a `(String, String)` tuple so a `node`
70/// dimension (Tier 3 affinity) can be added later without re-threading every
71/// call site.
72#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct PoolAddress {
74    namespace: String,
75    task_queue: String,
76}
77
78impl PoolAddress {
79    /// Build a pool address, normalizing an empty `task_queue` to the named
80    /// [`DEFAULT_TASK_QUEUE`] pool. The `namespace` is the authorization
81    /// boundary and is never normalized.
82    #[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    /// The correctness/isolation boundary of this pool.
97    #[must_use]
98    pub fn namespace(&self) -> &str {
99        &self.namespace
100    }
101
102    /// The pool/flavour selector within the namespace.
103    #[must_use]
104    pub fn task_queue(&self) -> &str {
105        &self.task_queue
106    }
107}
108
109/// Registry match key: a worker-pool address plus the activity type matched
110/// within that pool. A named type (not an anonymous tuple) so the routing
111/// identity stays self-describing and extensible.
112#[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/// Stable identifier assigned to a connected worker stream.
131#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
132pub struct WorkerId(u64);
133
134impl WorkerId {
135    /// Raw numeric value, as carried by the wire `RegisterAck.worker_id` so
136    /// workers can correlate their logs with the server's.
137    #[must_use]
138    pub const fn value(self) -> u64 {
139        self.0
140    }
141}
142
143/// Cloneable handle for a registered worker stream.
144///
145/// A worker serves a SET of namespaces under a single `task_queue`, so it is
146/// indexed under one `(namespace, task_queue, activity_type)` key per namespace
147/// in its set. `node` is an OPTIONAL locality affinity (a locality, not a
148/// process — many handles may share a node id) used as a within-pool filter at
149/// selection time; `None` means the worker advertised no locality.
150#[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    /// Worker identifier assigned by this server process.
162    #[must_use]
163    pub const fn id(&self) -> WorkerId {
164        self.id
165    }
166
167    /// Namespaces authorized for this worker stream. The worker is reachable for
168    /// a dispatch only when its set includes the workflow's namespace.
169    #[must_use]
170    pub const fn namespaces(&self) -> &BTreeSet<String> {
171        &self.namespaces
172    }
173
174    /// Task queue (pool/flavour) this worker serves within each namespace.
175    #[must_use]
176    pub fn task_queue(&self) -> &str {
177        &self.task_queue
178    }
179
180    /// Optional locality affinity this worker advertised. `None` means the
181    /// worker carries no node and is reachable only for unpinned dispatches.
182    #[must_use]
183    pub fn node(&self) -> Option<&str> {
184        self.node.as_deref()
185    }
186
187    /// Activity types advertised by this worker.
188    #[must_use]
189    pub fn activity_types(&self) -> &BTreeSet<String> {
190        &self.activity_types
191    }
192
193    /// The transport this worker is delivered to through.
194    #[must_use]
195    pub const fn delivery(&self) -> &WorkerDelivery {
196        &self.delivery
197    }
198
199    /// gRPC stream sender used by the gRPC dispatch path to push work, or `None`
200    /// when this worker is delivered to over a non-gRPC transport (liminal).
201    ///
202    /// The gRPC dispatch path registers every worker with a [`WorkerDelivery::Grpc`]
203    /// delivery, so this is always `Some` for a gRPC-registered worker — the
204    /// behaviour the path relied on before delivery became transport-agnostic.
205    #[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    /// Round-robin cursor per `(namespace, task_queue, activity_type)` triple, so
221    /// each pool rotates independently of every other pool.
222    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/// Cloneable registry of currently connected worker streams.
237#[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    /// Build a registry that records connected-worker gauge updates.
256    #[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    /// Authorize a worker registration and insert it into the connected-worker registry.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`ServerError`] if namespace authorization fails or the registry lock is poisoned.
270    pub async fn accept_registration(
271        &self,
272        guard: &NamespaceGuard,
273        caller: &CallerIdentity,
274        registration: &ProtoRegisterWorker,
275        sender: WorkerTaskSender,
276    ) -> Result<WorkerRegistration, ServerError> {
277        // Verify the operation against the guard's worker-registration policy,
278        // then authorize EACH namespace in the worker's set: a worker serves a
279        // SET of correctness boundaries, so the registration is denied unless
280        // the caller is granted every one. The wire's empty `node` carries no
281        // locality affinity; a non-empty value is the worker's advertised node.
282        guard
283            .scope(caller, &NamespaceOperation::register_worker(registration))
284            .await?;
285        let namespaces = guard.scope_worker_namespaces(caller, &registration.namespaces)?;
286        let node = optional_node(&registration.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    /// Insert an already-authorized worker stream into the default task queue of
297    /// a single `namespace`, with no node affinity.
298    ///
299    /// Convenience over [`Self::register_namespaces`] for callers that serve one
300    /// namespace and do not select a task queue (notably tests of the default
301    /// pool).
302    ///
303    /// # Errors
304    ///
305    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
306    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    /// Insert an already-authorized worker stream into one explicit worker pool
322    /// (single namespace + task queue), with no node affinity.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
327    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    /// Insert an already-authorized worker stream serving a SET of namespaces
341    /// under one `task_queue`, with an optional `node` locality affinity.
342    ///
343    /// The worker is indexed under one `(namespace, task_queue, activity_type)`
344    /// key per namespace in its set, so a dispatch in any of those namespaces
345    /// can reach it. `node` is recorded on the handle and used only as a
346    /// within-pool filter at selection time — it is NOT part of [`PoolAddress`].
347    ///
348    /// # Errors
349    ///
350    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
351    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    /// Insert an already-authorized worker serving a SET of namespaces under one
369    /// `task_queue` and optional `node`, delivered to through an explicit
370    /// [`WorkerDelivery`] transport.
371    ///
372    /// This is the transport-agnostic registration core: [`Self::register_namespaces`]
373    /// is the gRPC façade over it (it wraps the stream sender in
374    /// [`WorkerDelivery::Grpc`]). Selection (`select_worker`/`workers_for`) is
375    /// identical across transports; only the held delivery differs.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
380    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    /// Wait until at least one new worker registers.
437    ///
438    /// Returns immediately if a registration occurred since the last call.
439    /// Callers should re-check the registry after waking — the newly arrived
440    /// worker may not serve the namespace or activity type the caller needs.
441    pub async fn wait_for_worker(&self) {
442        self.worker_arrived.notified().await;
443    }
444
445    /// Return a snapshot of workers registered for the
446    /// `(namespace, task_queue, activity_type)` pool, ordered by worker id and
447    /// then rotated so each call starts from the next worker in the pool. The
448    /// rotation cursor is per triple, so each pool round-robins independently.
449    ///
450    /// When `node` is `Some`, the result is filtered to workers whose advertised
451    /// node equals it — a dispatch pinned to a node reaches only workers on that
452    /// node (NODE affinity = require). When `node` is `None`, the behaviour is
453    /// exactly the unpinned pool: every worker in the `(namespace, task_queue)`
454    /// pool is a candidate regardless of locality. node is a within-pool filter,
455    /// NOT part of the pool key, so the per-triple rotation cursor is shared
456    /// across pinned and unpinned lookups of the same pool.
457    ///
458    /// The id sort matters: `by_activity` holds workers in a `HashMap`, whose
459    /// iteration order is unspecified. Sorting first makes the rotation below
460    /// the sole, deterministic source of ordering — true round-robin across
461    /// calls with the same membership, not a wobble layered on hash order.
462    ///
463    /// # Errors
464    ///
465    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
466    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    /// Return a snapshot of every connected worker stream.
500    ///
501    /// # Errors
502    ///
503    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
504    pub fn all_workers(&self) -> Result<Vec<WorkerHandle>, ServerError> {
505        let state = self.state()?;
506        Ok(state.workers.values().cloned().collect())
507    }
508
509    /// Broadcast a graceful drain request to every connected worker stream.
510    ///
511    /// # Errors
512    ///
513    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
514    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            // Only gRPC-stream workers carry a drain mpsc. A worker on a non-gRPC
519            // transport (liminal) has no drain frame in this spike, so it is left
520            // untouched rather than spuriously deregistered.
521            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    /// Select one worker for the `(namespace, task_queue, activity_type)` pool.
534    ///
535    /// When `node` is `Some`, only workers whose advertised node equals it are
536    /// considered (NODE affinity = require); `None` considers every worker in
537    /// the pool. node is a within-pool filter, NOT part of the pool key.
538    ///
539    /// # Errors
540    ///
541    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
542    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    /// Return whether a worker stream is currently registered.
561    ///
562    /// The activity dispatch path uses this after queuing a task to detect a
563    /// worker whose stream tore down concurrently: a sweep that ran before
564    /// the dispatch tracked its task can never complete it, so the dispatch
565    /// must fail the activity itself instead of waiting forever.
566    ///
567    /// # Errors
568    ///
569    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
570    pub fn is_registered(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
571        Ok(self.state()?.workers.contains_key(&worker_id))
572    }
573
574    /// Remove a worker by id from every namespace/activity index it advertised.
575    ///
576    /// # Errors
577    ///
578    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
579    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    /// Remove a worker from every `(namespace, task_queue, activity_type)` index
594    /// it advertised. Returns the namespace set it served (for metrics), or
595    /// `None` if the worker was already gone.
596    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                        // Prune the round-robin cursor in lockstep: the cursor
608                        // map is keyed on arbitrary caller-supplied strings and
609                        // is lazily created by `workers_for`, so leaving stale
610                        // entries behind leaks memory unboundedly on a
611                        // never-dying server. When the last worker for a triple
612                        // leaves, its cursor has no remaining meaning.
613                        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
629/// Normalize a wire `node` string into an optional locality affinity: an empty
630/// value (the proto3 default) carries no node, anything else is the worker's
631/// advertised node id.
632fn optional_node(node: &str) -> Option<String> {
633    if node.is_empty() {
634        None
635    } else {
636        Some(node.to_owned())
637    }
638}
639
640/// Whether a worker satisfies an optional node filter. `None` (unpinned) matches
641/// every worker; `Some(node)` matches only a worker advertising that exact node
642/// (NODE affinity = require). A worker with no advertised node never matches a
643/// pinned dispatch.
644fn 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/// Registration token owned by the worker stream task.
660///
661/// Dropping the token performs best-effort cleanup for disconnect paths. Call
662/// [`WorkerRegistration::deregister`] when the caller needs a typed poison error.
663#[derive(Debug)]
664pub struct WorkerRegistration {
665    registry: ConnectedWorkerRegistry,
666    parts: Option<WorkerRegistrationParts>,
667}
668
669impl WorkerRegistration {
670    /// Worker id assigned to this registration.
671    #[must_use]
672    pub fn worker_id(&self) -> Option<WorkerId> {
673        self.parts.as_ref().map(|parts| parts.worker_id)
674    }
675
676    /// Authorized namespace set for this registration.
677    #[must_use]
678    pub fn namespaces(&self) -> Option<&BTreeSet<String>> {
679        self.parts.as_ref().map(|parts| &parts.namespaces)
680    }
681
682    /// Task queue (pool/flavour) this registration serves within each namespace.
683    #[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    /// Activity types advertised by this registration.
689    #[must_use]
690    pub fn activity_types(&self) -> Option<&BTreeSet<String>> {
691        self.parts.as_ref().map(|parts| &parts.activity_types)
692    }
693
694    /// Explicitly remove this worker from the registry.
695    ///
696    /// # Errors
697    ///
698    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
699    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                &registration("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                &registration("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                &registration("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        // Same namespace + same activity_type, two DIFFERENT task queues: the
864        // pools are disjoint, a lookup for one queue never returns the other's
865        // worker, and round-robin holds independently per (ns, tq, type) triple.
866        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                &registration_with_queue("local", "norn", &["dev"]),
876                norn_tx,
877            )
878            .await?;
879        // Two workers on the SAME (local, claude) pool to exercise round-robin.
880        let claude_a = registry
881            .accept_registration(
882                &guard(),
883                &caller("local"),
884                &registration_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                &registration_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        // A dispatch targeting `norn` never reaches a `claude` worker, and vice
915        // versa: the disjoint key is the boundary.
916        assert!(
917            !registry
918                .workers_for("local", "norn", "dev", None)?
919                .iter()
920                .any(|worker| claude_ids.contains(&worker.id()))
921        );
922
923        // Round-robin per triple: the (local, claude, dev) cursor advances
924        // independently and cycles through both claude workers, while the
925        // (local, norn, dev) cursor keeps returning its single worker.
926        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        // Same task_queue string, two DIFFERENT namespaces: namespace is the
947        // correctness boundary, so the pools are isolated.
948        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                &registration_with_queue("local", "gpu", &["render"]),
957                local_tx,
958            )
959            .await?;
960        let remote = registry
961            .accept_registration(
962                &guard(),
963                &caller("remote"),
964                &registration_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        // A worker advertising {a, b} is reachable for dispatch in BOTH a and b;
998        // a worker in {a} is NOT reachable in b.
999        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                &registration_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                &registration_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        // Namespace a sees BOTH workers; namespace b sees ONLY the {a, b} worker.
1026        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        // Deregistering the {a, b} worker removes it from BOTH buckets.
1036        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        // Two workers in the same (namespace, task_queue) pool on different
1051        // nodes: unpinned round-robins across both; pinned to node N reaches
1052        // ONLY the worker(s) on N; pinned to a node with no worker finds none.
1053        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                &registration_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                &registration_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        // Unpinned: both workers are candidates and round-robin advances.
1077        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        // Pinned to n1: only the n1 worker; pinned to n2: only the n2 worker.
1084        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        // select_worker honours the same filter.
1092        assert_eq!(
1093            registry
1094                .select_worker("ns", "tq", "dev", Some("n1"))?
1095                .map(|worker| worker.id()),
1096            Some(n1_id)
1097        );
1098
1099        // Pinned to a node with no worker finds no candidate (the dispatcher
1100        // then waits via the same no-worker path the existing test exercises).
1101        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        // Two workers SHARING a node id in the same pool: a dispatch pinned to
1121        // that node round-robins across BOTH (node is locality, not process).
1122        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                &registration_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                &registration_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        // The round-robin cursor is keyed on arbitrary caller-supplied strings;
1169        // it must not outlive the pool it rotates, or a never-dying server leaks
1170        // memory. After the last worker for a triple deregisters, no cursor for
1171        // that triple may remain.
1172        let registry = ConnectedWorkerRegistry::default();
1173        let (tx, _rx) = mpsc::channel(1);
1174        let worker = registry
1175            .accept_registration(
1176                &guard(),
1177                &caller("ns"),
1178                &registration_full(&["ns"], "tq", "", &["dev"]),
1179                tx,
1180            )
1181            .await?;
1182
1183        // Drive the lazy cursor insert.
1184        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}