Skip to main content

aion_server/worker/
registry.rs

1//! Connected-worker registry keyed by namespace 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::mpsc;
8
9use crate::error::ServerError;
10use crate::namespace::{CallerIdentity, NamespaceGuard, NamespaceOperation};
11use crate::observability::Metrics;
12
13/// Server-side handle used to push activity tasks to a connected worker stream.
14pub type WorkerTaskSender = mpsc::Sender<WorkerMessage>;
15
16/// Message queued from server-side dispatch/shutdown into a worker stream writer.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub enum WorkerMessage {
19    /// Activity invocation pushed to a worker.
20    ActivityTask(ProtoActivityTask),
21    /// Graceful-shutdown notification; no new work will be dispatched.
22    DrainRequest,
23}
24
25type ActivityKey = (String, String);
26type WorkerMap = HashMap<WorkerId, WorkerHandle>;
27type RegistryMap = HashMap<ActivityKey, WorkerMap>;
28
29/// Stable identifier assigned to a connected worker stream.
30#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub struct WorkerId(u64);
32
33impl WorkerId {
34    /// Raw numeric value, as carried by the wire `RegisterAck.worker_id` so
35    /// workers can correlate their logs with the server's.
36    #[must_use]
37    pub const fn value(self) -> u64 {
38        self.0
39    }
40}
41
42/// Cloneable handle for a registered worker stream.
43#[derive(Clone, Debug)]
44pub struct WorkerHandle {
45    id: WorkerId,
46    namespace: String,
47    activity_types: BTreeSet<String>,
48    sender: WorkerTaskSender,
49}
50
51impl WorkerHandle {
52    /// Worker identifier assigned by this server process.
53    #[must_use]
54    pub const fn id(&self) -> WorkerId {
55        self.id
56    }
57
58    /// Namespace authorized for this worker stream.
59    #[must_use]
60    pub fn namespace(&self) -> &str {
61        &self.namespace
62    }
63
64    /// Activity types advertised by this worker.
65    #[must_use]
66    pub fn activity_types(&self) -> &BTreeSet<String> {
67        &self.activity_types
68    }
69
70    /// Sender used by dispatch to push work to the stream task.
71    #[must_use]
72    pub fn sender(&self) -> &WorkerTaskSender {
73        &self.sender
74    }
75}
76
77#[derive(Debug)]
78struct RegistryState {
79    next_worker_id: u64,
80    workers: BTreeMap<WorkerId, WorkerHandle>,
81    by_activity: RegistryMap,
82}
83
84impl Default for RegistryState {
85    fn default() -> Self {
86        Self {
87            next_worker_id: 1,
88            workers: BTreeMap::new(),
89            by_activity: HashMap::new(),
90        }
91    }
92}
93
94/// Cloneable registry of currently connected worker streams.
95#[derive(Clone, Debug, Default)]
96pub struct ConnectedWorkerRegistry {
97    inner: Arc<Mutex<RegistryState>>,
98    metrics: Option<Metrics>,
99}
100
101impl ConnectedWorkerRegistry {
102    /// Build a registry that records connected-worker gauge updates.
103    #[must_use]
104    pub fn with_metrics(metrics: Metrics) -> Self {
105        Self {
106            inner: Arc::new(Mutex::new(RegistryState::default())),
107            metrics: Some(metrics),
108        }
109    }
110
111    /// Authorize a worker registration and insert it into the connected-worker registry.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`ServerError`] if namespace authorization fails or the registry lock is poisoned.
116    pub async fn accept_registration(
117        &self,
118        guard: &NamespaceGuard,
119        caller: &CallerIdentity,
120        registration: &ProtoRegisterWorker,
121        sender: WorkerTaskSender,
122    ) -> Result<WorkerRegistration, ServerError> {
123        let scoped = guard
124            .scope(caller, &NamespaceOperation::register_worker(registration))
125            .await?;
126        self.register(
127            scoped.namespace(),
128            registration.activity_types.iter(),
129            sender,
130        )
131    }
132
133    /// Insert an already-authorized worker stream.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
138    pub fn register<'a>(
139        &self,
140        namespace: impl Into<String>,
141        activity_types: impl IntoIterator<Item = &'a String>,
142        sender: WorkerTaskSender,
143    ) -> Result<WorkerRegistration, ServerError> {
144        let namespace = namespace.into();
145        let activity_types = activity_types.into_iter().cloned().collect::<BTreeSet<_>>();
146        let mut state = self.state()?;
147        let worker_id = WorkerId(state.next_worker_id);
148        state.next_worker_id = state.next_worker_id.saturating_add(1);
149
150        let handle = WorkerHandle {
151            id: worker_id,
152            namespace: namespace.clone(),
153            activity_types: activity_types.clone(),
154            sender,
155        };
156
157        for activity_type in &activity_types {
158            state
159                .by_activity
160                .entry((namespace.clone(), activity_type.clone()))
161                .or_default()
162                .insert(worker_id, handle.clone());
163        }
164        state.workers.insert(worker_id, handle);
165        drop(state);
166
167        if let Some(metrics) = &self.metrics {
168            metrics.worker_connected(&namespace);
169        }
170
171        Ok(WorkerRegistration {
172            registry: self.clone(),
173            parts: Some(WorkerRegistrationParts {
174                worker_id,
175                namespace,
176                activity_types,
177            }),
178        })
179    }
180
181    /// Return a snapshot of workers registered for the namespace and activity type.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
186    pub fn workers_for(
187        &self,
188        namespace: &str,
189        activity_type: &str,
190    ) -> Result<Vec<WorkerHandle>, ServerError> {
191        let state = self.state()?;
192        let key = (namespace.to_owned(), activity_type.to_owned());
193        Ok(state
194            .by_activity
195            .get(&key)
196            .map(|workers| workers.values().cloned().collect())
197            .unwrap_or_default())
198    }
199
200    /// Return a snapshot of every connected worker stream.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
205    pub fn all_workers(&self) -> Result<Vec<WorkerHandle>, ServerError> {
206        let state = self.state()?;
207        Ok(state.workers.values().cloned().collect())
208    }
209
210    /// Broadcast a graceful drain request to every connected worker stream.
211    ///
212    /// # Errors
213    ///
214    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
215    pub fn broadcast_drain(&self) -> Result<usize, ServerError> {
216        let workers = self.all_workers()?;
217        let mut delivered = 0usize;
218        for worker in workers {
219            if worker
220                .sender()
221                .try_send(WorkerMessage::DrainRequest)
222                .is_ok()
223            {
224                delivered = delivered.saturating_add(1);
225            } else {
226                self.deregister(worker.id())?;
227            }
228        }
229        Ok(delivered)
230    }
231
232    /// Select one worker for the namespace and activity type.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
237    pub fn select_worker(
238        &self,
239        namespace: &str,
240        activity_type: &str,
241    ) -> Result<Option<WorkerHandle>, ServerError> {
242        let state = self.state()?;
243        let key = (namespace.to_owned(), activity_type.to_owned());
244        Ok(state
245            .by_activity
246            .get(&key)
247            .and_then(|workers| workers.values().min_by_key(|worker| worker.id).cloned()))
248    }
249
250    /// Return whether a worker stream is currently registered.
251    ///
252    /// The activity dispatch path uses this after queuing a task to detect a
253    /// worker whose stream tore down concurrently: a sweep that ran before
254    /// the dispatch tracked its task can never complete it, so the dispatch
255    /// must fail the activity itself instead of waiting forever.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
260    pub fn is_registered(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
261        Ok(self.state()?.workers.contains_key(&worker_id))
262    }
263
264    /// Remove a worker by id from every namespace/activity index it advertised.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
269    pub fn deregister(&self, worker_id: WorkerId) -> Result<(), ServerError> {
270        let mut state = self.state()?;
271        let removed_namespace = Self::remove_worker(&mut state, worker_id);
272        drop(state);
273
274        if let (Some(namespace), Some(metrics)) = (removed_namespace, &self.metrics) {
275            metrics.worker_disconnected(&namespace);
276        }
277
278        Ok(())
279    }
280
281    fn remove_worker(state: &mut RegistryState, worker_id: WorkerId) -> Option<String> {
282        let handle = state.workers.remove(&worker_id)?;
283        let namespace = handle.namespace.clone();
284
285        for activity_type in handle.activity_types {
286            let key = (handle.namespace.clone(), activity_type);
287            if let Some(workers) = state.by_activity.get_mut(&key) {
288                workers.remove(&worker_id);
289                if workers.is_empty() {
290                    state.by_activity.remove(&key);
291                }
292            }
293        }
294
295        Some(namespace)
296    }
297
298    fn state(&self) -> Result<MutexGuard<'_, RegistryState>, ServerError> {
299        self.inner
300            .lock()
301            .map_err(|_| ServerError::lock_poisoned("connected worker registry"))
302    }
303}
304
305#[derive(Clone, Debug)]
306struct WorkerRegistrationParts {
307    worker_id: WorkerId,
308    namespace: String,
309    activity_types: BTreeSet<String>,
310}
311
312/// Registration token owned by the worker stream task.
313///
314/// Dropping the token performs best-effort cleanup for disconnect paths. Call
315/// [`WorkerRegistration::deregister`] when the caller needs a typed poison error.
316#[derive(Debug)]
317pub struct WorkerRegistration {
318    registry: ConnectedWorkerRegistry,
319    parts: Option<WorkerRegistrationParts>,
320}
321
322impl WorkerRegistration {
323    /// Worker id assigned to this registration.
324    #[must_use]
325    pub fn worker_id(&self) -> Option<WorkerId> {
326        self.parts.as_ref().map(|parts| parts.worker_id)
327    }
328
329    /// Authorized namespace for this registration.
330    #[must_use]
331    pub fn namespace(&self) -> Option<&str> {
332        self.parts.as_ref().map(|parts| parts.namespace.as_str())
333    }
334
335    /// Activity types advertised by this registration.
336    #[must_use]
337    pub fn activity_types(&self) -> Option<&BTreeSet<String>> {
338        self.parts.as_ref().map(|parts| &parts.activity_types)
339    }
340
341    /// Explicitly remove this worker from the registry.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
346    pub fn deregister(mut self) -> Result<(), ServerError> {
347        let Some(parts) = self.parts.take() else {
348            return Ok(());
349        };
350        self.registry.deregister(parts.worker_id)
351    }
352}
353
354impl Drop for WorkerRegistration {
355    fn drop(&mut self) {
356        let Some(parts) = self.parts.take() else {
357            return;
358        };
359        if let Ok(mut state) = self.registry.inner.lock() {
360            let removed_namespace =
361                ConnectedWorkerRegistry::remove_worker(&mut state, parts.worker_id);
362            if let (Some(namespace), Some(metrics)) = (removed_namespace, &self.registry.metrics) {
363                metrics.worker_disconnected(&namespace);
364            }
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use crate::config::NamespaceMode;
372    use crate::namespace::{NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces};
373
374    use super::*;
375
376    fn guard() -> NamespaceGuard {
377        NamespaceGuard::new(NamespaceResolver::authorization_only(
378            NamespaceMode::SharedEngine,
379            StaticWorkflowNamespaces::default(),
380            StaticScheduleNamespaces::default(),
381        ))
382    }
383
384    fn caller(namespace: &str) -> CallerIdentity {
385        CallerIdentity::new("worker", [namespace.to_owned()])
386    }
387
388    fn registration(namespace: &str, activity_types: &[&str]) -> ProtoRegisterWorker {
389        ProtoRegisterWorker {
390            namespace: namespace.to_owned(),
391            activity_types: activity_types
392                .iter()
393                .map(|value| (*value).to_owned())
394                .collect(),
395        }
396    }
397
398    #[tokio::test]
399    async fn register_and_deregister_are_namespace_isolated() -> Result<(), ServerError> {
400        let registry = ConnectedWorkerRegistry::default();
401        let (tenant_a_tx, _tenant_a_rx) = mpsc::channel(1);
402        let (tenant_b_tx, _tenant_b_rx) = mpsc::channel(1);
403
404        let tenant_a = registry
405            .accept_registration(
406                &guard(),
407                &caller("tenant-a"),
408                &registration("tenant-a", &["charge", "charge"]),
409                tenant_a_tx,
410            )
411            .await?;
412        let tenant_b = registry
413            .accept_registration(
414                &guard(),
415                &caller("tenant-b"),
416                &registration("tenant-b", &["charge"]),
417                tenant_b_tx,
418            )
419            .await?;
420
421        assert_eq!(registry.workers_for("tenant-a", "charge")?.len(), 1);
422        assert_eq!(registry.workers_for("tenant-b", "charge")?.len(), 1);
423        assert!(registry.workers_for("tenant-a", "missing")?.is_empty());
424
425        let tenant_a_id = tenant_a.worker_id();
426        tenant_a.deregister()?;
427
428        assert!(registry.workers_for("tenant-a", "charge")?.is_empty());
429        assert_eq!(registry.workers_for("tenant-b", "charge")?.len(), 1);
430        assert_ne!(tenant_a_id, tenant_b.worker_id());
431
432        tenant_b.deregister()?;
433        assert!(registry.workers_for("tenant-b", "charge")?.is_empty());
434        Ok(())
435    }
436
437    #[tokio::test]
438    async fn denied_namespace_is_not_registered() -> Result<(), ServerError> {
439        let registry = ConnectedWorkerRegistry::default();
440        let (tx, _rx) = mpsc::channel(1);
441        let denied = registry
442            .accept_registration(
443                &guard(),
444                &caller("tenant-a"),
445                &registration("tenant-b", &["charge"]),
446                tx,
447            )
448            .await;
449
450        assert!(denied.is_err());
451        assert!(registry.workers_for("tenant-b", "charge")?.is_empty());
452        Ok(())
453    }
454}