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::num::NonZeroU32;
5use std::pin::Pin;
6use std::sync::{Arc, Mutex, MutexGuard};
7use std::task::{Context, Poll};
8use std::time::Instant;
9
10use aion_core::{
11 ClusterEvent, DeploymentAssociation, InterventionCapabilities, WorkerDeathReason,
12 WorkerTransport,
13};
14use aion_proto::{ProtoActivityTask, ProtoCancelActivity, ProtoLivenessPing, ProtoRegisterWorker};
15use aion_store::{NamespaceOrigin, NamespacePlacement, NamespaceStore, WorkerDeploymentStore};
16use tokio::sync::{Notify, mpsc};
17
18use crate::cluster_publisher::ClusterEventPublisher;
19use crate::config::AutoCreate;
20use crate::error::ServerError;
21use crate::namespace::{CallerIdentity, NamespaceGuard, NamespaceMinter, NamespaceOperation};
22use crate::observability::Metrics;
23use crate::worker::admission_audit::AdmissionAudit;
24use crate::worker::heartbeat::DispatchExclusion;
25
26mod capacity;
27mod census;
28mod reservation;
29
30use capacity::advertised_capacity;
31pub use reservation::DispatchReservation;
32use reservation::eligible_candidates_in_rotation;
33
34/// The literal task queue an empty/absent selector normalizes to.
35///
36/// A worker-pool address has two disjoint dimensions; the second one
37/// (`task_queue`) is a liveness selector, not a correctness boundary. An empty
38/// `task_queue` is normalized to this one named default pool so a producer that
39/// names no queue and a worker that advertises none both land on the same pool.
40///
41/// Re-exported from [`aion_core::DEFAULT_TASK_QUEUE`] so the server cannot drift
42/// from the canonical domain default; the name is kept stable here for existing
43/// call sites.
44pub use aion_core::DEFAULT_TASK_QUEUE;
45
46/// Server-side handle used to push activity tasks to a connected worker stream.
47pub type WorkerTaskSender = mpsc::Sender<WorkerMessage>;
48
49/// Transport through which the server delivers a dispatch to a registered worker.
50///
51/// A worker is selected the SAME way regardless of transport (`select_worker`
52/// over the `(namespace, task_queue, node)` pool key); only the delivery leg
53/// differs. The default gRPC path pushes a [`WorkerMessage`] onto the worker's
54/// stream `mpsc` ([`WorkerDelivery::Grpc`]); a liminal-connected worker is
55/// delivered to by pushing the dispatch out on its existing liminal connection
56/// ([`WorkerDelivery::Liminal`], feature-gated). This enum is the minimal
57/// transport-agnostic seam: the registry holds it on each [`WorkerHandle`], and
58/// the dispatch path reads the variant it needs. The gRPC variant carries exactly
59/// the `mpsc::Sender` it always did, so the gRPC dispatch path is unchanged.
60#[derive(Clone, Debug)]
61pub enum WorkerDelivery {
62 /// gRPC stream delivery: the dispatch path pushes a [`WorkerMessage`] onto
63 /// this `mpsc` sender, exactly as before this enum existed.
64 Grpc(WorkerTaskSender),
65 /// Liminal server-push delivery: the dispatch path pushes the serialized
66 /// dispatch out on the worker's existing liminal connection and awaits the
67 /// correlated reply. Carries the connection identity needed to address that
68 /// push.
69 #[cfg(feature = "liminal-transport")]
70 Liminal(crate::worker::liminal_transport::LiminalWorkerDelivery),
71}
72
73impl WorkerDelivery {
74 /// Which transport this delivery rides, stripped of the handle that
75 /// addresses it.
76 ///
77 /// The liveness probe needs to know a worker's transport WITHOUT holding its
78 /// connection handle, because the question it asks is not "how do I reach
79 /// this worker" but "do I carry any wire on which this worker could be
80 /// asked" (#25). Answering that from a cloned sender or a connection pid
81 /// would tie a coverage decision to a live handle it does not need.
82 ///
83 /// This is the ONE mapping from a held delivery to its wire discriminant.
84 /// Two byte-identical private copies of it existed — one here for the
85 /// cluster-event emitter, one in
86 /// [`cluster_stream`](crate::stream::cluster_stream) for the snapshot — and
87 /// a third was nearly written for the liveness verdict. A discriminant table
88 /// kept in three places is three chances for a transport to be added to two
89 /// of them.
90 #[must_use]
91 pub const fn transport(&self) -> WorkerTransport {
92 match self {
93 Self::Grpc(_) => WorkerTransport::Grpc,
94 #[cfg(feature = "liminal-transport")]
95 Self::Liminal(_) => WorkerTransport::Liminal,
96 }
97 }
98}
99
100/// Message queued from server-side dispatch/shutdown into a worker stream writer.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum WorkerMessage {
103 /// Activity invocation pushed to a worker.
104 ActivityTask(Box<ProtoActivityTask>),
105 /// Graceful-shutdown notification; no new work will be dispatched.
106 DrainRequest,
107 /// Transport liveness ping pushed by the liveness probe (#197).
108 ///
109 /// It travels this channel — the SAME one dispatches travel — deliberately.
110 /// The fact the probe needs is whether the server can reach this worker's
111 /// DISPATCH path, and a ping on a parallel channel could be answered by a
112 /// process whose dispatch path is dead. Answered by the worker SDK RUNTIME,
113 /// never by action code.
114 LivenessPing(ProtoLivenessPing),
115 /// Ask the worker to stop ONE in-flight activity (#233).
116 ///
117 /// A request, not a guarantee: pushing this proves only that the server
118 /// asked. Whether the work stops depends on the worker still holding the
119 /// activity and on the action being interruptible, and neither is
120 /// observable from here.
121 ///
122 /// It travels this channel — the SAME one the dispatch travelled — so the
123 /// cancel cannot overtake or bypass the task it interrupts, and so a worker
124 /// whose dispatch path is dead cannot appear to have been told.
125 CancelActivity(ProtoCancelActivity),
126}
127
128/// Address of a worker pool: the two disjoint routing dimensions that select a
129/// pool, before an `activity_type` is matched within it.
130///
131/// `namespace` is the correctness/isolation boundary — a workflow's activities
132/// only ever reach workers in the workflow's namespace, so crossing it is a bug.
133/// `task_queue` is the pool/flavour selector within that namespace (norn /
134/// claude / cpu / gpu) — a miss is a liveness issue, never a correctness one.
135///
136/// This is a named type rather than a `(String, String)` tuple so a `node`
137/// dimension (Tier 3 affinity) can be added later without re-threading every
138/// call site.
139#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
140pub struct PoolAddress {
141 namespace: String,
142 task_queue: String,
143}
144
145impl PoolAddress {
146 /// Build a pool address, normalizing an empty `task_queue` to the named
147 /// [`DEFAULT_TASK_QUEUE`] pool. The `namespace` is the authorization
148 /// boundary and is never normalized.
149 #[must_use]
150 pub fn new(namespace: impl Into<String>, task_queue: impl Into<String>) -> Self {
151 let task_queue = task_queue.into();
152 let task_queue = if task_queue.is_empty() {
153 String::from(DEFAULT_TASK_QUEUE)
154 } else {
155 task_queue
156 };
157 Self {
158 namespace: namespace.into(),
159 task_queue,
160 }
161 }
162
163 /// The correctness/isolation boundary of this pool.
164 #[must_use]
165 pub fn namespace(&self) -> &str {
166 &self.namespace
167 }
168
169 /// The pool/flavour selector within the namespace.
170 #[must_use]
171 pub fn task_queue(&self) -> &str {
172 &self.task_queue
173 }
174}
175
176/// Registry match key: a worker-pool address plus the activity type matched
177/// within that pool. A named type (not an anonymous tuple) so the routing
178/// identity stays self-describing and extensible.
179#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
180struct ActivityKey {
181 pool: PoolAddress,
182 activity_type: String,
183}
184
185impl ActivityKey {
186 fn new(pool: PoolAddress, activity_type: impl Into<String>) -> Self {
187 Self {
188 pool,
189 activity_type: activity_type.into(),
190 }
191 }
192}
193
194type WorkerMap = HashMap<WorkerId, WorkerHandle>;
195type RegistryMap = HashMap<ActivityKey, WorkerMap>;
196
197/// The capacity advertised by a worker that has no executor of its own.
198///
199/// A worker registered with a bare `mpsc` sender — the in-process façades'
200/// callers, the built-in workers, and every harness that drains the channel
201/// itself — runs nothing on its own account: what happens to a pushed task is
202/// decided by whoever holds the receiver, so there is no number of simultaneous
203/// activities such a registration would refuse. This states that, once, instead
204/// of a literal repeated at every call.
205///
206/// It is NOT a default and no production registration can reach it. A gRPC
207/// worker states its capacity in its registration frame and is refused if it
208/// does not; a liminal worker, whose frame cannot carry one, registers with
209/// capacity unknown and announces its own number a frame later. Neither route
210/// reaches this constant — it belongs to the in-process façades alone.
211pub const UNBOUNDED_SENDER_WORKER_CONCURRENCY: u32 = u32::MAX;
212
213/// Stable identifier assigned to a connected worker stream.
214#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
215pub struct WorkerId(u64);
216
217impl WorkerId {
218 /// Build a worker id from the numeric value exposed by administrative and
219 /// wire surfaces.
220 #[must_use]
221 pub const fn from_value(value: u64) -> Self {
222 Self(value)
223 }
224
225 /// Raw numeric value, as carried by the wire `RegisterAck.worker_id` so
226 /// workers can correlate their logs with the server's.
227 #[must_use]
228 pub const fn value(self) -> u64 {
229 self.0
230 }
231}
232
233/// Worker-supplied identity for one instance of a durable deployment.
234#[derive(Clone, Debug, PartialEq, Eq)]
235pub struct WorkerInstanceIdentity {
236 /// Durable deployment name supplied on the wire.
237 pub deployment: String,
238 /// Operator/launcher-assigned instance identifier.
239 pub instance_id: String,
240 /// Deployment-store lookup result captured when registration was accepted.
241 pub association: DeploymentAssociation,
242}
243
244/// What a registration says about the worker beyond where it is routed:
245/// the identity it registered under, its deployment instance, and the
246/// intervention capabilities its harness advertises.
247#[derive(Debug, Clone)]
248pub struct RegistrationOptions {
249 identity: String,
250 max_concurrency: Option<u32>,
251 instance: Option<WorkerInstanceIdentity>,
252 intervention_capabilities: InterventionCapabilities,
253}
254
255impl RegistrationOptions {
256 /// Options for a worker registering under `identity` and advertising
257 /// `max_concurrency`, with no deployment instance and the empty
258 /// (observability-only) capability set.
259 ///
260 /// Capacity is a CONSTRUCTOR argument rather than a `with_` setter because
261 /// there is no honest value to fall back to: a default would decide how
262 /// much work a process is handed on the strength of nobody having said, and
263 /// selection reads it on every dispatch. Every caller states it.
264 ///
265 /// Zero is rejected by [`ConnectedWorkerRegistry::register_delivery`], the
266 /// one funnel every transport and every façade passes through, so the check
267 /// has a single site and no caller can route around it.
268 #[must_use]
269 pub fn identified(identity: impl Into<String>, max_concurrency: u32) -> Self {
270 Self {
271 identity: identity.into(),
272 max_concurrency: Some(max_concurrency),
273 instance: None,
274 intervention_capabilities: InterventionCapabilities::none(),
275 }
276 }
277
278 /// Options for a worker that has registered over a transport whose
279 /// registration frame cannot carry capacity, and will announce it in the
280 /// next frame instead.
281 ///
282 /// This is NOT a default and NOT an unbounded worker. It records that the
283 /// server does not yet know, which selection reads as "do not dispatch"
284 /// until [`ConnectedWorkerRegistry::set_advertised_capacity`] supplies the
285 /// worker's own configured number. The only caller is the liminal
286 /// transport: `liminal::protocol::WorkerRegistration` is a published wire
287 /// type with six fields — namespaces, task queue, node, activity types,
288 /// identity, activities — and none of them is capacity, so a liminal worker
289 /// physically cannot state it in the frame that registers it.
290 ///
291 /// Failing closed for that one round trip is deliberate. The alternative is
292 /// to pick a number on the worker's behalf, and every value is wrong: too
293 /// low serializes a worker that fans, too high pushes it past its own
294 /// admission — which is the defect this whole landing exists to remove.
295 #[must_use]
296 pub fn identified_pending_capacity(identity: impl Into<String>) -> Self {
297 Self {
298 identity: identity.into(),
299 max_concurrency: None,
300 instance: None,
301 intervention_capabilities: InterventionCapabilities::none(),
302 }
303 }
304
305 /// The deployment instance the worker registered as, when it sent one.
306 #[must_use]
307 pub fn with_instance(mut self, instance: Option<WorkerInstanceIdentity>) -> Self {
308 self.instance = instance;
309 self
310 }
311
312 /// The neutral [`InterventionCapabilities`] the worker's harness advertises
313 /// (NOI-6). Metadata the intervention router gates on, never a routing
314 /// dimension.
315 #[must_use]
316 pub fn with_intervention_capabilities(
317 mut self,
318 capabilities: InterventionCapabilities,
319 ) -> Self {
320 self.intervention_capabilities = capabilities;
321 self
322 }
323}
324
325/// Cloneable handle for a registered worker stream.
326///
327/// A worker serves a SET of namespaces under a single `task_queue`, so it is
328/// indexed under one `(namespace, task_queue, activity_type)` key per namespace
329/// in its set. `node` is an OPTIONAL locality affinity (a locality, not a
330/// process — many handles may share a node id) used as a within-pool filter at
331/// selection time; `None` means the worker advertised no locality.
332#[derive(Clone, Debug)]
333pub struct WorkerHandle {
334 id: WorkerId,
335 /// The identity string the worker registered under — the wire's
336 /// `ProtoRegisterWorker.identity`, carried so a lease can name the process
337 /// that took an attempt (WA-010 R3). Never a routing dimension.
338 identity: String,
339 namespaces: BTreeSet<String>,
340 task_queue: String,
341 node: Option<String>,
342 activity_types: BTreeSet<String>,
343 instance: Option<WorkerInstanceIdentity>,
344 /// How many activities this worker will run AT ONCE, exactly as it
345 /// advertised at registration.
346 ///
347 /// `NonZeroU32` rather than a plain integer because zero is not a capacity
348 /// this server can act on — a worker advertising it could never be
349 /// selected — so it is refused at admission and the type carries that
350 /// refusal forward instead of leaving every reader to re-check it.
351 max_concurrency: Option<NonZeroU32>,
352 delivery: WorkerDelivery,
353 /// The neutral mid-run intervention primitives this worker's harness advertises
354 /// support for (NOI-6). The server gates every intervention command on THIS set
355 /// and NEVER routes an unadvertised primitive. Empty = observability-only (the
356 /// default for every non-agent worker), so a normal activity worker advertises
357 /// no controls and the intervention router refuses every command for it.
358 intervention_capabilities: InterventionCapabilities,
359}
360
361impl WorkerHandle {
362 /// Worker identifier assigned by this server process.
363 #[must_use]
364 pub const fn id(&self) -> WorkerId {
365 self.id
366 }
367
368 /// Namespaces authorized for this worker stream. The worker is reachable for
369 /// a dispatch only when its set includes the workflow's namespace.
370 #[must_use]
371 pub const fn namespaces(&self) -> &BTreeSet<String> {
372 &self.namespaces
373 }
374
375 /// The identity string this worker registered under, exactly as the
376 /// registration frame carried it (empty when the worker sent none).
377 #[must_use]
378 pub fn identity(&self) -> &str {
379 &self.identity
380 }
381
382 /// Task queue (pool/flavour) this worker serves within each namespace.
383 #[must_use]
384 pub fn task_queue(&self) -> &str {
385 &self.task_queue
386 }
387
388 /// Optional locality affinity this worker advertised. `None` means the
389 /// worker carries no node and is reachable only for unpinned dispatches.
390 #[must_use]
391 pub fn node(&self) -> Option<&str> {
392 self.node.as_deref()
393 }
394
395 /// Activity types advertised by this worker.
396 #[must_use]
397 pub fn activity_types(&self) -> &BTreeSet<String> {
398 &self.activity_types
399 }
400
401 /// Optional durable deployment/instance association supplied at registration.
402 #[must_use]
403 pub const fn instance(&self) -> Option<&WorkerInstanceIdentity> {
404 self.instance.as_ref()
405 }
406
407 /// How many activities this worker runs AT ONCE, as it advertised, or
408 /// `None` while it has registered but not yet said.
409 ///
410 /// A dispatch PRECONDITION, read by [`eligible_candidates_in_rotation`]:
411 /// a worker holding this many in-flight activities is passed over rather
412 /// than pushed past its own admission. Pushing past it is what produced a
413 /// worker that stopped reading its stream, answered no pings, and was then
414 /// deregistered as lost while it was in fact working.
415 ///
416 /// `None` is reachable only on the liminal transport, whose published
417 /// registration frame has no capacity field, and only for the one round
418 /// trip between that registration and the worker's capacity announcement.
419 /// It means UNKNOWN, never unlimited: selection treats an unknown capacity
420 /// as full, because the alternative is to invent a number for a process
421 /// that is about to state its own.
422 #[must_use]
423 pub const fn max_concurrency(&self) -> Option<NonZeroU32> {
424 self.max_concurrency
425 }
426
427 /// Whether the server still holds an OPEN push channel to this worker.
428 ///
429 /// The explicit connected fact, as opposed to the absence of noise. Silence
430 /// alone cannot tell a worker that is gone from one that is busy, and the
431 /// expiry sweep used to read the second as the first; this is the third
432 /// input that separates them.
433 ///
434 /// gRPC answers from the stream sender itself — a closed receiver means the
435 /// tonic task that owned the stream is gone. Liminal answers from its
436 /// connection: the transport holds a live push connection or it does not.
437 #[must_use]
438 pub fn is_connected(&self) -> bool {
439 match &self.delivery {
440 WorkerDelivery::Grpc(sender) => !sender.is_closed(),
441 #[cfg(feature = "liminal-transport")]
442 WorkerDelivery::Liminal(delivery) => delivery.is_connected(),
443 }
444 }
445
446 /// The transport this worker is delivered to through.
447 #[must_use]
448 pub const fn delivery(&self) -> &WorkerDelivery {
449 &self.delivery
450 }
451
452 /// The neutral intervention primitives this worker's harness advertises (NOI-6).
453 ///
454 /// The intervention router gates on this set and never routes an unadvertised
455 /// primitive. Empty (the default for a plain activity worker) means the worker
456 /// is observability-only: the router refuses every intervention command for it.
457 #[must_use]
458 pub const fn intervention_capabilities(&self) -> &InterventionCapabilities {
459 &self.intervention_capabilities
460 }
461
462 /// gRPC stream sender used by the gRPC dispatch path to push work, or `None`
463 /// when this worker is delivered to over a non-gRPC transport (liminal).
464 ///
465 /// The gRPC dispatch path registers every worker with a [`WorkerDelivery::Grpc`]
466 /// delivery, so this is always `Some` for a gRPC-registered worker — the
467 /// behaviour the path relied on before delivery became transport-agnostic.
468 #[must_use]
469 pub fn sender(&self) -> Option<&WorkerTaskSender> {
470 match &self.delivery {
471 WorkerDelivery::Grpc(sender) => Some(sender),
472 #[cfg(feature = "liminal-transport")]
473 WorkerDelivery::Liminal(_) => None,
474 }
475 }
476}
477
478#[derive(Debug)]
479struct RegistryState {
480 next_worker_id: u64,
481 workers: BTreeMap<WorkerId, WorkerHandle>,
482 by_activity: RegistryMap,
483 /// Round-robin cursor per `(namespace, task_queue, activity_type)` triple, so
484 /// each pool rotates independently of every other pool.
485 rotation: HashMap<ActivityKey, usize>,
486 /// When a worker advertising a given `(pool, activity_type)` and node last
487 /// LEFT service, per advertised node. Read by [`ConnectedWorkerRegistry::pool_census`]
488 /// to answer "how old is the last compatible poller" for an address that has
489 /// none right now (R1). Node-keyed because a node-pinned dispatch's notion
490 /// of "compatible" is node-specific: a worker still serving the activity on
491 /// another node must never make a pinned address look freshly served.
492 last_departure: HashMap<ActivityKey, BTreeMap<Option<String>, Instant>>,
493 /// Workers the server currently cannot reach on the server-to-worker push
494 /// leg, and which are therefore excluded from selection while they remain
495 /// so.
496 ///
497 /// Held here rather than derived at selection time because selection must
498 /// not take the heartbeat tracker's lock: the liveness probe owns the
499 /// evidence and publishes the verdict, and selection only reads it.
500 ///
501 /// This is exclusion from DISPATCH, not deregistration. An unreachable
502 /// worker stays registered, keeps its in-flight work, and becomes eligible
503 /// again the moment a ping is answered — nothing about it is torn down on
504 /// the strength of a push failure.
505 ///
506 /// 🔴 A MAP, not a set, and the value is load-bearing.
507 /// [`DispatchExclusion`] separates two facts its own documentation calls
508 /// "DIFFERENT FACTS an operator must be able to tell apart": an
509 /// `OpeningProbation` clears itself within seconds and is the ordinary cost
510 /// of connecting, while a `ReachabilityLost` is an incident that does not
511 /// clear on its own. A flat set collapses them, and a pool parked on the
512 /// second one then waits with nothing published about why — the same shape
513 /// as a delivery gate that cannot tell a key never begun from one
514 /// released. The prober has the value already; this is simply where it
515 /// stopped being thrown away.
516 dispatch_ineligible: BTreeMap<WorkerId, DispatchExclusion>,
517 /// How many dispatches each worker is currently holding.
518 ///
519 /// The selection-side projection of the heartbeat tracker's in-flight task
520 /// map, and NOT a second accounting kept in parallel with it: both are
521 /// written by the SAME call — [`HeartbeatTracker::track_task`] and
522 /// [`HeartbeatTracker::complete_task`](super::heartbeat::HeartbeatTracker::complete_task)
523 /// take this registry precisely so the two cannot be updated apart. The
524 /// projection lives here because selection must not take the tracker's
525 /// lock, which is the same reason `dispatch_ineligible` lives here rather
526 /// than being derived at selection time.
527 ///
528 /// "One call" is not the same as "one idempotency", and the difference bit
529 /// once: the tracker's map is keyed by `(worker, workflow, activity)` and an
530 /// insert over a live key overwrites, while this count increments. Tracking
531 /// one key twice without an intervening completion therefore counted twice
532 /// and could only be decremented once — a slot leaked for the life of the
533 /// registration. `track_task` now increments only when its insert actually
534 /// added an entry, so the two agree on how many times one dispatch counts.
535 ///
536 /// It is also raised by [`ConnectedWorkerRegistry::select_and_reserve`] and
537 /// [`ConnectedWorkerRegistry::reserve_worker`], which claim a slot at
538 /// SELECTION and hold it — as a [`DispatchReservation`] — until the tracker
539 /// takes the count over. That overlap is one extra held slot for the width
540 /// of the hand-over and can only park a dispatch that would otherwise have
541 /// raced, never admit one.
542 ///
543 /// An entry is dropped when the worker leaves service, so this map is
544 /// bounded by the workers actually registered.
545 in_flight: BTreeMap<WorkerId, u32>,
546}
547
548impl Default for RegistryState {
549 fn default() -> Self {
550 Self {
551 next_worker_id: 1,
552 workers: BTreeMap::new(),
553 by_activity: HashMap::new(),
554 rotation: HashMap::new(),
555 last_departure: HashMap::new(),
556 dispatch_ineligible: BTreeMap::new(),
557 in_flight: BTreeMap::new(),
558 }
559 }
560}
561
562/// Cloneable registry of currently connected worker streams.
563#[derive(Clone)]
564pub struct ConnectedWorkerRegistry {
565 inner: Arc<Mutex<RegistryState>>,
566 metrics: Option<Metrics>,
567 /// WS3 cluster-event publisher: emits `WorkerConnected`/`WorkerDisconnected`
568 /// topology deltas on register/deregister. `None` keeps existing
569 /// constructions (and every test) silent, exactly like `metrics`.
570 cluster_publisher: Option<ClusterEventPublisher>,
571 /// Minted-on-use hook (Control-Plane Phase 1). `None` disables minting, so
572 /// registration is byte-identical to before the registry existed; `Some`
573 /// durably records (open) or gates (closed) each authorized namespace.
574 minter: Option<NamespaceMinter>,
575 /// Durable deployment store used only to classify instance associations.
576 deployment_store: Option<Arc<dyn WorkerDeploymentStore>>,
577 worker_arrived: Arc<Notify>,
578 /// The refusal side of this registry's ledger, shared by every transport.
579 ///
580 /// The registry records who was ADMITTED; this records who was turned away,
581 /// so a repeated identical refusal can be met with silence. It lives here
582 /// because both callers of the admission gate already hold the registry, so
583 /// one home serves both — and one shared record is what stops the two
584 /// transports drifting apart the way #147 found them.
585 audit: Arc<AdmissionAudit>,
586 /// Pulsed whenever a worker's capacity slot is FREED, so a dispatcher that
587 /// parked work for want of capacity is woken by the event itself rather than
588 /// waiting out a timer.
589 ///
590 /// This is the outbox dispatcher's existing advisory wake (the one the stage
591 /// seam pulses when a row is committed), shared rather than duplicated: a
592 /// freed slot and a newly-staged row are the same kind of news to that loop
593 /// — "there may be work you can place now" — and it already coalesces and
594 /// degrades cleanly to its interval poll. `None` (every in-process façade
595 /// and most tests) simply means nobody is listening.
596 capacity_wake: Option<Arc<Notify>>,
597}
598
599impl std::fmt::Debug for ConnectedWorkerRegistry {
600 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
601 formatter
602 .debug_struct("ConnectedWorkerRegistry")
603 .field(
604 "deployment_store_attached",
605 &self.deployment_store.is_some(),
606 )
607 .finish_non_exhaustive()
608 }
609}
610
611impl Default for ConnectedWorkerRegistry {
612 fn default() -> Self {
613 Self {
614 inner: Arc::new(Mutex::new(RegistryState::default())),
615 metrics: None,
616 cluster_publisher: None,
617 minter: None,
618 deployment_store: None,
619 worker_arrived: Arc::new(Notify::new()),
620 audit: Arc::new(AdmissionAudit::new()),
621 capacity_wake: None,
622 }
623 }
624}
625
626/// A live subscription to the next change in what dispatch selection can see,
627/// taken from [`ConnectedWorkerRegistry::worker_arrival`] and awaited as a
628/// future.
629///
630/// # Why this is a value and not a bare `wait` method
631///
632/// The registry's two wake sources — a registration
633/// ([`ConnectedWorkerRegistry::register`] and every sibling, through their
634/// shared tail) and a published reachability verdict
635/// ([`ConnectedWorkerRegistry::set_dispatch_ineligible`]) — are both
636/// `Notify::notify_waiters`, which stores **no permit**. A caller that reads the
637/// registry, misses, and only then constructs its wait has already lost any
638/// arrival that landed during the read: the broadcast fired into an empty waiter
639/// list. That caller then sleeps on a pool it holds positive evidence is served,
640/// until some unrelated later registration happens to wake it — and on the gRPC
641/// transport, where no liveness probe runs and no verdict is ever published, an
642/// unrelated registration is the ONLY thing that ever could.
643///
644/// So the subscription is a value the caller takes **before** it looks, and
645/// awaits **after** it has missed. Everything that fires in between is retained.
646///
647/// # Why it is retained — tokio 1.52.3 at the bytes
648///
649/// Line numbers are in `tokio-1.52.3/src/sync/notify.rs`, the version
650/// `Cargo.lock` pins.
651///
652/// - `Notify::notified_owned` (`:613`) snapshots the process-wide
653/// `notify_waiters` call counter into the future **at construction**
654/// (`:619`, `notify_waiters_calls: get_num_notify_waiters_calls(state)`).
655/// - `notify_waiters` (`:743`) increments that counter unconditionally —
656/// `inner_notify_waiters` bumps it at `:755` when nobody is parked and at
657/// `:761` when someone is. A broadcast into an empty list is therefore not
658/// lost; it moves a number.
659/// - `poll_notified`'s `State::Init` arm compares the snapshot against the live
660/// counter at `:1124`, and again under the waiter lock at `:1156`; a
661/// difference sends the future straight to `State::Done` → `Poll::Ready`.
662///
663/// That comparison is the retention property, and it belongs to **construction**.
664/// `OwnedNotified::enable` (`:1059`) is called here too: it runs that same `Init`
665/// arm eagerly and, on the not-yet-notified path, pushes the waiter into the list
666/// at `:1219` — so the waiter is registered at a defined point rather than at
667/// first poll, and a `notify_one` permit would be retained as well if one were
668/// ever added to this `Notify`. There is none today; this type must not rest its
669/// correctness on that staying true.
670///
671/// # What it costs
672///
673/// `OwnedNotified` rather than the borrowed `Notified<'_>` because the value
674/// crosses a `&mut dyn FnMut(..)` boundary (the wait path's `park` contract) and
675/// sits in a `tokio::select!` arm (the bridge's park); the borrowed form forces
676/// a higher-ranked bound through the first and pinning ceremony at the second.
677///
678/// `Pin<Box<_>>` because `enable` needs `Pin<&mut Self>` at construction, and
679/// because it makes this type `Unpin` so no call site owes pinning ceremony. The
680/// price is one heap allocation per selection-loop iteration, on a path that is
681/// about to park. The alternative — hand back a bare `OwnedNotified` and ask
682/// every call site to remember `pin!` and `enable()` — puts the obligation back
683/// on the call sites, and a call site that forgot its obligation is precisely
684/// the defect this type exists to end.
685#[must_use = "a WorkerArrival that is constructed and dropped is a subscription thrown away"]
686pub struct WorkerArrival {
687 notified: Pin<Box<tokio::sync::futures::OwnedNotified>>,
688}
689
690impl std::fmt::Debug for WorkerArrival {
691 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
692 formatter.debug_struct("WorkerArrival").finish()
693 }
694}
695
696impl std::future::Future for WorkerArrival {
697 type Output = ();
698
699 fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
700 // `Pin<Box<_>>` is `Unpin`, so the outer pin carries no obligation and
701 // the inner one is what the notification state is actually pinned by.
702 std::future::Future::poll(self.get_mut().notified.as_mut(), context)
703 }
704}
705
706impl ConnectedWorkerRegistry {
707 /// Build a registry that records connected-worker gauge updates.
708 #[must_use]
709 pub fn with_metrics(metrics: Metrics) -> Self {
710 Self {
711 inner: Arc::new(Mutex::new(RegistryState::default())),
712 metrics: Some(metrics),
713 cluster_publisher: None,
714 minter: None,
715 deployment_store: None,
716 worker_arrived: Arc::new(Notify::new()),
717 audit: Arc::new(AdmissionAudit::new()),
718 capacity_wake: None,
719 }
720 }
721
722 /// Share the dispatcher wake this registry pulses when a capacity slot is
723 /// FREED.
724 ///
725 /// The busy answer a full pool gives is attempt-neutral, so a parked row is
726 /// waiting for capacity — and capacity-freeing is an event this server
727 /// produces, not a deadline it should guess at. Without this the only thing
728 /// re-offering a busy-parked row was its durable `visible_after`, which
729 /// rides the operator's FAILURE backoff: raising that legitimately (to slow
730 /// genuine retries) parked healthy work behind it for the same duration.
731 ///
732 /// Pulsed by
733 /// [`record_dispatch_finished`](Self::record_dispatch_finished), which every
734 /// slot-free goes through — a completed dispatch's untrack and a released
735 /// reservation alike.
736 #[must_use]
737 pub fn with_capacity_wake(mut self, wake: Arc<Notify>) -> Self {
738 self.capacity_wake = Some(wake);
739 self
740 }
741
742 /// The admission audit every registration transport names its refusals
743 /// through. Clones of a registry share one, so a worker refused over gRPC
744 /// and then over liminal is one site, not two.
745 #[must_use]
746 pub fn admission_audit(&self) -> &AdmissionAudit {
747 &self.audit
748 }
749
750 /// Attach the WS3 cluster-event publisher so worker topology changes are
751 /// pushed to the dashboard. Pure builder addition.
752 #[must_use]
753 pub fn with_cluster_publisher(mut self, publisher: ClusterEventPublisher) -> Self {
754 self.cluster_publisher = Some(publisher);
755 self
756 }
757
758 /// Attach the durable worker-deployment store used for association lookup.
759 #[must_use]
760 pub fn with_worker_deployment_store(mut self, store: Arc<dyn WorkerDeploymentStore>) -> Self {
761 self.deployment_store = Some(store);
762 self
763 }
764
765 /// Install the minted-on-use namespace hook (Control-Plane Phase 1).
766 ///
767 /// After a registration is authorized and its namespace set scoped, each
768 /// authorized namespace is durably recorded ([`AutoCreate::Open`]) or gated
769 /// ([`AutoCreate::Closed`]) through `store`. Without this builder the
770 /// registry never touches the namespace registry, so registration stays
771 /// byte-identical to before the registry existed. Pure builder addition,
772 /// mirroring [`Self::with_cluster_publisher`].
773 ///
774 /// When a cluster publisher has already been attached
775 /// ([`Self::with_cluster_publisher`], called first on the boot path), it is
776 /// threaded into the minter so a first worker-mint emits the live
777 /// `namespace created` delta to the ops console (S8). Order-independence is
778 /// not assumed: callers wire the publisher before minting on the boot path.
779 #[must_use]
780 pub fn with_namespace_minting(
781 mut self,
782 store: Arc<dyn NamespaceStore>,
783 policy: AutoCreate,
784 ) -> Self {
785 let minter = NamespaceMinter::new(store, policy);
786 let minter = match &self.cluster_publisher {
787 Some(publisher) => minter.with_cluster_publisher(publisher.clone()),
788 None => minter,
789 };
790 self.minter = Some(minter);
791 self
792 }
793
794 /// Thread the boot's namespace-mint routing context into the registry's
795 /// minter, so a worker registering for a namespace whose registry shard this
796 /// node does not own mints through the shard's owner instead of being
797 /// refused `NotOwner` forever.
798 ///
799 /// The SECOND of the two minter construction sites (the first is
800 /// [`ServerState::namespace_minter`](crate::ServerState::namespace_minter),
801 /// which serves the gRPC and HTTP start seams). Called after
802 /// [`Self::with_namespace_minting`] on the boot path; a no-op when no minter
803 /// is installed, and never called at all off-cluster, so default/test
804 /// registries stay byte-identical.
805 #[must_use]
806 pub fn with_namespace_routing(mut self, routing: crate::namespace::NamespaceRouting) -> Self {
807 self.minter = self.minter.map(|minter| minter.with_routing(routing));
808 self
809 }
810
811 /// Authorize a worker registration and insert it into the connected-worker registry.
812 ///
813 /// # Errors
814 ///
815 /// Returns [`ServerError`] if namespace authorization fails or the registry lock is poisoned.
816 pub async fn accept_registration(
817 &self,
818 guard: &NamespaceGuard,
819 caller: &CallerIdentity,
820 registration: &ProtoRegisterWorker,
821 sender: WorkerTaskSender,
822 ) -> Result<WorkerRegistration, ServerError> {
823 self.admit_delivery(
824 guard,
825 caller,
826 registration,
827 WorkerDelivery::Grpc(sender),
828 InterventionCapabilities::none(),
829 )
830 .await
831 }
832
833 /// The ONE admission every transport's registration passes through, in
834 /// this order: the guard's worker-registration policy, then EACH
835 /// namespace in the worker's set authorized against the caller, then the
836 /// auth-scoped mint-or-gate, then the placement-admission gate against
837 /// the worker's advertised node, then the insert. The delivery leg is the
838 /// only thing a transport contributes.
839 ///
840 /// It exists because the liminal transport used to insert into the
841 /// registry directly — `register_delivery` from its
842 /// connection callback — so a worker dialling the liminal listener was
843 /// registered into any namespace it named with none of the gates above,
844 /// while the identical worker over gRPC was refused. Two entry points,
845 /// one set of rules: the rules live here, and a transport that bypasses
846 /// them has to be written to do so on purpose.
847 ///
848 /// # Errors
849 ///
850 /// Returns the guard's namespace denial, the mint-or-gate refusal, the
851 /// placement refusal, or the registry's own insert error, exactly as the
852 /// gRPC path always has.
853 pub async fn admit_delivery(
854 &self,
855 guard: &NamespaceGuard,
856 caller: &CallerIdentity,
857 registration: &ProtoRegisterWorker,
858 delivery: WorkerDelivery,
859 intervention_capabilities: InterventionCapabilities,
860 ) -> Result<WorkerRegistration, ServerError> {
861 // Verify the operation against the guard's worker-registration policy,
862 // then authorize EACH namespace in the worker's set: a worker serves a
863 // SET of correctness boundaries, so the registration is denied unless
864 // the caller is granted every one. The wire's empty `node` carries no
865 // locality affinity; a non-empty value is the worker's advertised node.
866 guard
867 .scope(caller, &NamespaceOperation::register_worker(registration))
868 .await?;
869 let namespaces = guard.scope_worker_namespaces(caller, ®istration.namespaces)?;
870 // MINT HOOK (Control-Plane Phase 1). This runs strictly AFTER the
871 // per-namespace authorization above (`scope` + `scope_worker_namespaces`),
872 // so it can only ever mint a namespace the caller is already authorized
873 // for — the mint is auth-scoped by construction (CVE-2025-14986: open
874 // minting and namespace isolation only coexist when minting is
875 // auth-gated). It runs BEFORE the worker is inserted, so a `closed`
876 // rejection never leaves a half-registered worker behind.
877 self.mint_or_gate_namespaces(&namespaces).await?;
878 let node = optional_node(®istration.node);
879 // PLACEMENT-ADMISSION GATE (Control-Plane Phase 2, P2-I1). Runs strictly
880 // AFTER the mint hook (so every authorized namespace has a durable record to
881 // read a placement from) and with BOTH the worker's advertised `node` and
882 // the full authorized namespace set in scope. It rejects the WHOLE
883 // registration (Open Decision 6) when the worker's node violates any
884 // `Pinned{L}` namespace it would serve, so only L-node workers ever enter a
885 // pinned namespace's pool.
886 self.enforce_pinned_placement(&namespaces, node.as_deref())
887 .await?;
888 // CAPACITY IS A DISPATCH PRECONDITION, never defaulted. Selection reads
889 // this number on every dispatch, so a worker the server has no number
890 // for cannot be served correctly: the server would have to invent how
891 // much work the process takes, which is how a worker ends up pushed past
892 // its own admission, silent, and then deregistered as lost while it is
893 // working.
894 //
895 // How the precondition is MET differs by transport, which is why this
896 // reads the delivery's own. A gRPC registration frame has the field, so
897 // an absent one is a worker declining to state it and is refused here —
898 // the precedent is `activities`, refused for the same reason one field
899 // over. A liminal registration frame has no such field to carry, so the
900 // worker registers with capacity unknown and states it on the
901 // capabilities channel a frame later; unknown is not a default, and
902 // selection passes such a worker over until it says.
903 let max_concurrency = advertised_capacity(registration, delivery.transport())?;
904 let instance = self
905 .resolve_instance_identity(registration.instance.as_ref())
906 .await?;
907 let options = match max_concurrency {
908 Some(advertised) => {
909 RegistrationOptions::identified(registration.identity.clone(), advertised)
910 }
911 None => RegistrationOptions::identified_pending_capacity(registration.identity.clone()),
912 };
913 self.register_delivery(
914 namespaces,
915 registration.task_queue.clone(),
916 node,
917 registration.activity_types.iter(),
918 delivery,
919 options
920 .with_instance(instance)
921 .with_intervention_capabilities(intervention_capabilities),
922 )
923 }
924
925 /// Resolve the wire's optional deployment/instance identity into the
926 /// registry's own, marking whether the named deployment is known to the
927 /// deployment store (or unchecked when no store is configured).
928 ///
929 /// # Errors
930 ///
931 /// Returns the deployment store's read error.
932 pub async fn resolve_instance_identity(
933 &self,
934 instance: Option<&aion_proto::ProtoWorkerInstanceIdentity>,
935 ) -> Result<Option<WorkerInstanceIdentity>, ServerError> {
936 let Some(instance) = instance else {
937 return Ok(None);
938 };
939 let association = match &self.deployment_store {
940 Some(store) => {
941 if store
942 .get_worker_deployment(&instance.deployment)
943 .await
944 .map_err(ServerError::from)?
945 .is_some()
946 {
947 DeploymentAssociation::Known
948 } else {
949 DeploymentAssociation::Absent
950 }
951 }
952 None => DeploymentAssociation::Unchecked,
953 };
954 Ok(Some(WorkerInstanceIdentity {
955 deployment: instance.deployment.clone(),
956 instance_id: instance.instance_id.clone(),
957 association,
958 }))
959 }
960
961 /// Apply the minted-on-use policy to an already-authorized namespace set.
962 ///
963 /// A no-op when no minter is installed (every default/test registry), so
964 /// registration stays byte-identical. With a minter, the work is delegated
965 /// to the shared [`NamespaceMinter::mint_or_gate`] — the single
966 /// transport-agnostic implementation reused by the workflow-start safety net
967 /// — with [`NamespaceOrigin::WorkerMint`] so a first mint is attributed to
968 /// worker registration. See that method for the open/closed policy, the
969 /// idempotent "namespace created" event, and the retryable `NotOwner`
970 /// surface.
971 ///
972 /// # Errors
973 ///
974 /// Returns [`ServerError::StoreBackend`] if a durable upsert/lookup fails
975 /// (including a retryable `NotOwner` fence), or [`ServerError::Namespace`]
976 /// when `closed` rejects an unknown namespace.
977 async fn mint_or_gate_namespaces(&self, namespaces: &[String]) -> Result<(), ServerError> {
978 let Some(minter) = &self.minter else {
979 return Ok(());
980 };
981 minter
982 .mint_or_gate(namespaces, NamespaceOrigin::WorkerMint)
983 .await
984 }
985
986 /// Reject the whole registration when the worker's advertised `node` violates
987 /// any `Pinned{L}` namespace it would serve (Control-Plane Phase 2, P2-I1).
988 ///
989 /// For each authorized namespace whose placement is [`NamespacePlacement::Pinned`],
990 /// the worker's advertised `node` must be `Some(n)` with `n ∈ L`; a `None` node
991 /// or an `n ∉ L` is a loud, whole-registration rejection naming the namespace,
992 /// the node, and the required set. This guarantees only L-node workers ever
993 /// serve a hard-pinned namespace's pool, which is exactly what lets the
994 /// `Some(N ∉ L)` composition case (§2.2) resolve to the correct isolation stall
995 /// at dispatch rather than needing a start-time enumeration of future nodes.
996 ///
997 /// Non-`Pinned` placements ([`NamespacePlacement::Unplaced`]/[`NamespacePlacement::Prefer`])
998 /// are UNAFFECTED — byte-identical registration. A no-op when no minter is
999 /// installed (every default/test registry), so those stay behaviour-identical:
1000 /// the gate reads placement from the SAME registry record the minter/placement
1001 /// endpoint writes, never a second source of truth.
1002 ///
1003 /// # Errors
1004 ///
1005 /// Returns [`ServerError::Namespace`] (placement-admission denial) when the
1006 /// worker's node violates a `Pinned` namespace, or [`ServerError::StoreBackend`]
1007 /// if a placement read fails at the backend.
1008 async fn enforce_pinned_placement(
1009 &self,
1010 namespaces: &[String],
1011 node: Option<&str>,
1012 ) -> Result<(), ServerError> {
1013 let Some(minter) = &self.minter else {
1014 return Ok(());
1015 };
1016 for namespace in namespaces {
1017 let NamespacePlacement::Pinned { nodes } = minter.placement_of(namespace).await? else {
1018 continue;
1019 };
1020 let admitted = node.is_some_and(|n| nodes.contains(n));
1021 if !admitted {
1022 return Err(ServerError::placement_admission_denied(
1023 namespace, node, &nodes,
1024 ));
1025 }
1026 }
1027 Ok(())
1028 }
1029
1030 /// Insert an already-authorized worker stream into the default task queue of
1031 /// a single `namespace`, with no node affinity.
1032 ///
1033 /// Convenience over [`Self::register_namespaces`] for callers that serve one
1034 /// namespace and do not select a task queue (notably tests of the default
1035 /// pool).
1036 ///
1037 /// # Errors
1038 ///
1039 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1040 pub fn register<'a>(
1041 &self,
1042 namespace: impl Into<String>,
1043 activity_types: impl IntoIterator<Item = &'a String>,
1044 sender: WorkerTaskSender,
1045 max_concurrency: u32,
1046 ) -> Result<WorkerRegistration, ServerError> {
1047 self.register_namespaces(
1048 [namespace.into()],
1049 String::from(DEFAULT_TASK_QUEUE),
1050 None,
1051 activity_types,
1052 sender,
1053 max_concurrency,
1054 )
1055 }
1056
1057 /// Insert an already-authorized worker stream into one explicit worker pool
1058 /// (single namespace + task queue), with no node affinity.
1059 ///
1060 /// # Errors
1061 ///
1062 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1063 pub fn register_pool<'a>(
1064 &self,
1065 pool: PoolAddress,
1066 activity_types: impl IntoIterator<Item = &'a String>,
1067 sender: WorkerTaskSender,
1068 max_concurrency: u32,
1069 ) -> Result<WorkerRegistration, ServerError> {
1070 let PoolAddress {
1071 namespace,
1072 task_queue,
1073 } = pool;
1074 self.register_namespaces(
1075 [namespace],
1076 task_queue,
1077 None,
1078 activity_types,
1079 sender,
1080 max_concurrency,
1081 )
1082 }
1083
1084 /// Insert an already-authorized worker stream serving a SET of namespaces
1085 /// under one `task_queue`, with an optional `node` locality affinity.
1086 ///
1087 /// The worker is indexed under one `(namespace, task_queue, activity_type)`
1088 /// key per namespace in its set, so a dispatch in any of those namespaces
1089 /// can reach it. `node` is recorded on the handle and used only as a
1090 /// within-pool filter at selection time — it is NOT part of [`PoolAddress`].
1091 ///
1092 /// # Errors
1093 ///
1094 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1095 pub fn register_namespaces<'a>(
1096 &self,
1097 namespaces: impl IntoIterator<Item = String>,
1098 task_queue: impl Into<String>,
1099 node: Option<String>,
1100 activity_types: impl IntoIterator<Item = &'a String>,
1101 sender: WorkerTaskSender,
1102 max_concurrency: u32,
1103 ) -> Result<WorkerRegistration, ServerError> {
1104 // A sender-only registration carries no identity: the callers of this
1105 // façade (built-in and test workers) never sent one, and a lease that
1106 // names such a worker says so with the empty string rather than a
1107 // name nobody registered. Capacity is NOT treated that way — it is
1108 // required here too, because selection acts on it and a stand-in value
1109 // would decide how much work these workers are handed.
1110 self.register_delivery(
1111 namespaces,
1112 task_queue,
1113 node,
1114 activity_types,
1115 WorkerDelivery::Grpc(sender),
1116 RegistrationOptions::identified(String::new(), max_concurrency),
1117 )
1118 }
1119
1120 /// Insert an already-authorized worker serving a SET of namespaces under one
1121 /// `task_queue` and optional `node`, delivered to through an explicit
1122 /// [`WorkerDelivery`] transport, carrying the [`RegistrationOptions`] it
1123 /// registered with.
1124 ///
1125 /// This is the one transport-agnostic registration: [`Self::register_namespaces`]
1126 /// is the gRPC façade over it (it wraps the stream sender in
1127 /// [`WorkerDelivery::Grpc`]). Selection (`select_worker`/`workers_for`) is
1128 /// identical across transports; only the held delivery differs.
1129 ///
1130 /// # Errors
1131 ///
1132 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1133 pub fn register_delivery<'a>(
1134 &self,
1135 namespaces: impl IntoIterator<Item = String>,
1136 task_queue: impl Into<String>,
1137 node: Option<String>,
1138 activity_types: impl IntoIterator<Item = &'a String>,
1139 delivery: WorkerDelivery,
1140 options: RegistrationOptions,
1141 ) -> Result<WorkerRegistration, ServerError> {
1142 // THE ONE ZERO-CAPACITY CHECK. Every transport's admission and every
1143 // in-process façade funnels here, so a worker advertising no capacity
1144 // is refused once, by name, instead of being registered into a pool
1145 // selection would then always skip.
1146 let max_concurrency = options
1147 .max_concurrency
1148 .map(|advertised| {
1149 NonZeroU32::new(advertised).ok_or_else(|| ServerError::Wire {
1150 wire: aion_proto::WireError::backend(
1151 "worker registration advertises max_concurrency 0; a worker that runs no \
1152 activities could never be selected for a dispatch",
1153 ),
1154 })
1155 })
1156 .transpose()?;
1157 let namespaces = namespaces.into_iter().collect::<BTreeSet<_>>();
1158 let task_queue = task_queue.into();
1159 let activity_types = activity_types.into_iter().cloned().collect::<BTreeSet<_>>();
1160 let mut state = self.state()?;
1161 let worker_id = WorkerId(state.next_worker_id);
1162 state.next_worker_id = state.next_worker_id.saturating_add(1);
1163
1164 // Capture the node affinity for the WS3 WorkerConnected delta before the
1165 // handle moves it.
1166 let node_for_event = node.clone();
1167 let instance_for_event = options.instance.clone();
1168 let handle = WorkerHandle {
1169 id: worker_id,
1170 identity: options.identity,
1171 namespaces: namespaces.clone(),
1172 task_queue: task_queue.clone(),
1173 node,
1174 activity_types: activity_types.clone(),
1175 instance: options.instance,
1176 max_concurrency,
1177 delivery,
1178 intervention_capabilities: options.intervention_capabilities,
1179 };
1180
1181 for namespace in &namespaces {
1182 let pool = PoolAddress::new(namespace.clone(), task_queue.clone());
1183 for activity_type in &activity_types {
1184 let key = ActivityKey::new(pool.clone(), activity_type.clone());
1185 // This address is served again, so its departure record has no
1186 // remaining meaning — drop it in lockstep with the insert. The
1187 // departure map is therefore bounded by the addresses real
1188 // workers have served and left, never by caller-supplied
1189 // dispatch strings (which never create an entry at all).
1190 if let Some(by_node) = state.last_departure.get_mut(&key) {
1191 by_node.remove(&handle.node);
1192 if by_node.is_empty() {
1193 state.last_departure.remove(&key);
1194 }
1195 }
1196 state
1197 .by_activity
1198 .entry(key)
1199 .or_default()
1200 .insert(worker_id, handle.clone());
1201 }
1202 }
1203 let transport = handle.delivery.transport();
1204 state.workers.insert(worker_id, handle);
1205 drop(state);
1206
1207 if let Some(metrics) = &self.metrics {
1208 for namespace in &namespaces {
1209 metrics.worker_connected(namespace);
1210 }
1211 }
1212
1213 // WS3: one WorkerConnected delta carrying the full namespace set (the
1214 // event is namespace-list-valued; the deploy-scoped cluster channel sees
1215 // it whole). Edge-triggered by the real insert, never a poll.
1216 if let Some(publisher) = &self.cluster_publisher {
1217 let namespaces_vec: Vec<String> = namespaces.iter().cloned().collect();
1218 let task_queue_owned = task_queue.clone();
1219 drop(publisher.emit(|meta| {
1220 ClusterEvent::WorkerConnected {
1221 meta,
1222 worker_id: worker_id.value().to_string(),
1223 namespaces: namespaces_vec,
1224 task_queue: task_queue_owned,
1225 transport,
1226 node: node_for_event,
1227 deployment: instance_for_event
1228 .as_ref()
1229 .map(|identity| identity.deployment.clone()),
1230 deployment_association: instance_for_event.map(|identity| identity.association),
1231 }
1232 }));
1233 }
1234
1235 self.worker_arrived.notify_waiters();
1236
1237 Ok(WorkerRegistration {
1238 registry: self.clone(),
1239 parts: Some(WorkerRegistrationParts {
1240 worker_id,
1241 namespaces,
1242 task_queue,
1243 activity_types,
1244 }),
1245 })
1246 }
1247
1248 /// Subscribe to the next change in what dispatch selection can see: a new
1249 /// worker registers, or a reachability verdict is published
1250 /// ([`Self::set_dispatch_ineligible`]). Both are ways a pool that had no
1251 /// selectable worker gains one, and a wait that only woke on the first would
1252 /// sleep through a pool whose workers are all excluded — those workers are
1253 /// already registered, so no registration is coming for them.
1254 ///
1255 /// **Take the subscription BEFORE you read the registry, and await it only
1256 /// after the read has missed.** That ordering is the whole contract; see
1257 /// [`WorkerArrival`] for why a subscription taken after the read loses the
1258 /// arrival that landed during it.
1259 ///
1260 /// Callers must re-check the registry after waking: the newly arrived worker
1261 /// may not serve the namespace or activity type the caller needs, and a
1262 /// republished verdict may have restored nobody.
1263 ///
1264 /// The returned value carries its own `#[must_use]` message: a subscription
1265 /// constructed and dropped is a subscription thrown away.
1266 pub fn worker_arrival(&self) -> WorkerArrival {
1267 let mut notified = Box::pin(Arc::clone(&self.worker_arrived).notified_owned());
1268 // `enable` reports whether the wake had ALREADY landed. There is nothing
1269 // to do with that answer here either way: the future is fused, so an
1270 // already-notified subscription simply returns `Ready` on its first
1271 // poll, which IS the retention this type exists to provide. What the
1272 // call is for is its other half — putting the waiter in the list now,
1273 // at a defined point, rather than at whenever the caller first polls.
1274 notified.as_mut().enable();
1275 WorkerArrival { notified }
1276 }
1277
1278 /// Return a snapshot of the DISPATCH-ELIGIBLE workers registered for the
1279 /// `(namespace, task_queue, activity_type)` pool, ordered by worker id and
1280 /// then rotated so each call starts from the next worker in the pool. The
1281 /// rotation cursor is per triple, so each pool round-robins independently.
1282 ///
1283 /// When `node` is `Some`, the result is filtered to workers whose advertised
1284 /// node equals it — a dispatch pinned to a node reaches only workers on that
1285 /// node (NODE affinity = require). When `node` is `None`, the behaviour is
1286 /// exactly the unpinned pool: every worker in the `(namespace, task_queue)`
1287 /// pool is a candidate regardless of locality. node is a within-pool filter,
1288 /// NOT part of the pool key, so the per-triple rotation cursor is shared
1289 /// across pinned and unpinned lookups of the same pool.
1290 ///
1291 /// Candidates, ordering, eligibility and the cursor all come from
1292 /// [`eligible_candidates_in_rotation`], which [`Self::select_and_reserve`]
1293 /// reads too: ONE derivation and ONE cursor, so the gRPC push dispatcher and every
1294 /// other dispatch path rotate over the same workers in the same order and
1295 /// cannot drift about who is dispatchable or whose turn it is.
1296 ///
1297 /// # Errors
1298 ///
1299 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1300 pub fn workers_for(
1301 &self,
1302 namespace: &str,
1303 task_queue: &str,
1304 activity_type: &str,
1305 node: Option<&str>,
1306 ) -> Result<Vec<WorkerHandle>, ServerError> {
1307 let mut state = self.state()?;
1308 let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
1309 Ok(eligible_candidates_in_rotation(&mut state, key, node))
1310 }
1311
1312 /// Return a snapshot of every connected worker stream.
1313 ///
1314 /// # Errors
1315 ///
1316 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1317 pub fn all_workers(&self) -> Result<Vec<WorkerHandle>, ServerError> {
1318 let state = self.state()?;
1319 Ok(state.workers.values().cloned().collect())
1320 }
1321
1322 /// Return the handle for a worker by id, or `None` when it is not registered.
1323 ///
1324 /// The intervention router resolves the owning worker of a target attempt by id
1325 /// (NOI-6): the attempt-owner back-index stores a [`WorkerId`], and the router
1326 /// reads back the live handle to gate on its advertised capabilities and select
1327 /// its delivery. A `None` result means the owner disconnected — the router
1328 /// treats that as the attempt-scoped no-op.
1329 ///
1330 /// # Errors
1331 ///
1332 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1333 pub fn worker_by_id(&self, worker_id: WorkerId) -> Result<Option<WorkerHandle>, ServerError> {
1334 Ok(self.state()?.workers.get(&worker_id).cloned())
1335 }
1336
1337 /// Replace the advertised intervention capabilities of a registered worker
1338 /// (NOI-6). The liminal registration frame cannot carry capabilities, so a
1339 /// liminal agent worker announces them on the reserved capabilities channel
1340 /// right after registering, and this applies the announcement to the live
1341 /// handle the intervention router gates on. Returns `false` when the worker
1342 /// is no longer registered (a disconnect racing the announcement — benign).
1343 ///
1344 /// # Errors
1345 ///
1346 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1347 pub fn set_intervention_capabilities(
1348 &self,
1349 worker_id: WorkerId,
1350 capabilities: &InterventionCapabilities,
1351 ) -> Result<bool, ServerError> {
1352 let mut state = self.state()?;
1353 if !state.workers.contains_key(&worker_id) {
1354 return Ok(false);
1355 }
1356 if let Some(handle) = state.workers.get_mut(&worker_id) {
1357 handle.intervention_capabilities = capabilities.clone();
1358 }
1359 // The selection index holds handle clones; keep them capability-consistent
1360 // even though capabilities are never a routing dimension.
1361 for workers in state.by_activity.values_mut() {
1362 if let Some(handle) = workers.get_mut(&worker_id) {
1363 handle.intervention_capabilities = capabilities.clone();
1364 }
1365 }
1366 Ok(true)
1367 }
1368
1369 /// Broadcast a graceful drain request to every connected worker stream.
1370 /// Workers are removed from routing before any transport signal is attempted.
1371 /// A liminal worker has no drain control frame, so it is force-fenced and
1372 /// deregistered with an error-level identity-bearing log instead of being
1373 /// silently skipped.
1374 ///
1375 /// # Errors
1376 ///
1377 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1378 pub fn broadcast_drain(&self) -> Result<usize, ServerError> {
1379 let workers = self.all_workers()?;
1380 let mut delivered = 0usize;
1381 for worker in workers {
1382 if self.drain_worker(worker.id())? {
1383 delivered = delivered.saturating_add(1);
1384 }
1385 }
1386 Ok(delivered)
1387 }
1388
1389 /// Stop assigning work to one worker and request a graceful transport drain.
1390 ///
1391 /// Returns `false` if the worker is not registered or if its transport has no
1392 /// drain channel. In the latter case the worker is deregistered immediately,
1393 /// after an error-level log names the worker and its transport limitation.
1394 ///
1395 /// # Errors
1396 ///
1397 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1398 pub fn drain_worker(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
1399 let worker = {
1400 let mut state = self.state()?;
1401 let Some(worker) = state.workers.get(&worker_id).cloned() else {
1402 return Ok(false);
1403 };
1404 Self::remove_worker_from_service(&mut state, &worker);
1405 worker
1406 };
1407 match worker.delivery() {
1408 WorkerDelivery::Grpc(sender) => {
1409 if sender.try_send(WorkerMessage::DrainRequest).is_ok() {
1410 tracing::info!(worker_id = worker_id.value(), "worker drain requested");
1411 Ok(true)
1412 } else {
1413 tracing::error!(
1414 worker_id = worker_id.value(),
1415 "worker drain signal failed; force-deregistering closed transport"
1416 );
1417 self.deregister(worker_id)?;
1418 Ok(false)
1419 }
1420 }
1421 #[cfg(feature = "liminal-transport")]
1422 WorkerDelivery::Liminal(delivery) => {
1423 tracing::error!(
1424 worker_id = worker_id.value(),
1425 connection_pid = delivery.pid(),
1426 "liminal transport has no drain control channel; worker fenced and \
1427 deregistered at drain start"
1428 );
1429 self.deregister(worker_id)?;
1430 Ok(false)
1431 }
1432 }
1433 }
1434
1435 /// Publish the liveness probe's reachability verdict: the set of workers the
1436 /// server cannot currently reach on the push leg, which
1437 /// [`eligible_candidates_in_rotation`] then skips for BOTH selectors.
1438 ///
1439 /// Replaces the whole set rather than toggling one worker, so the published
1440 /// verdict is always exactly one round's evidence and a worker can never be
1441 /// left excluded by a stale entry nobody cleared.
1442 ///
1443 /// Publishing WAKES the selection wait ([`WorkerArrival`]). A dispatch that
1444 /// found no eligible worker is blocked on this verdict every bit as squarely
1445 /// as on a registration, and the workers it needs are already registered —
1446 /// so a park that only woke on registrations would sleep through their
1447 /// recovery. A publication landing between a dispatch's census and its park
1448 /// is retained, because the dispatch holds a [`WorkerArrival`] taken before
1449 /// the census; this method owes nothing to that ordering beyond firing.
1450 ///
1451 /// The wake is unconditional rather than gated on the set having shrunk,
1452 /// because a change-gated wake would make correctness depend on this method
1453 /// judging what "changed" means for a caller it cannot see — a set that
1454 /// shrank for a worker in some other pool is no restoration for THIS
1455 /// dispatch, and a set republished identically may still coincide with the
1456 /// registration that serves it. Every round publishes, so waking on each one
1457 /// is self-healing at the probe's own cadence — it invents no clock of its
1458 /// own — and a publication with nobody parked costs one waiterless
1459 /// `notify_waiters`.
1460 ///
1461 /// # Errors
1462 ///
1463 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1464 pub fn set_dispatch_ineligible(
1465 &self,
1466 unreachable: BTreeMap<WorkerId, DispatchExclusion>,
1467 ) -> Result<(), ServerError> {
1468 self.state()?.dispatch_ineligible = unreachable;
1469 self.worker_arrived.notify_waiters();
1470 Ok(())
1471 }
1472
1473 /// The transport each named worker is delivered over, for the workers still
1474 /// registered (#25).
1475 ///
1476 /// A worker absent from the result has LEFT the registry — the caller must
1477 /// treat that as "no transport", never as a default one. That distinction is
1478 /// the whole reason this returns a map rather than a vector in the caller's
1479 /// order: the liveness probe scopes its verdict by transport coverage, and a
1480 /// departed worker whose transport was guessed would be judged by a probe
1481 /// that never had a wire to it.
1482 ///
1483 /// Read under ONE lock acquisition rather than one per worker, so the
1484 /// answer describes a single registry state instead of a smear across a
1485 /// round of registrations and disconnects.
1486 ///
1487 /// # Errors
1488 ///
1489 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1490 pub fn transports_of(
1491 &self,
1492 workers: impl IntoIterator<Item = WorkerId>,
1493 ) -> Result<BTreeMap<WorkerId, WorkerTransport>, ServerError> {
1494 let state = self.state()?;
1495 Ok(workers
1496 .into_iter()
1497 .filter_map(|worker_id| {
1498 state
1499 .workers
1500 .get(&worker_id)
1501 .map(|worker| (worker_id, worker.delivery.transport()))
1502 })
1503 .collect())
1504 }
1505
1506 /// Whether a worker is currently excluded from dispatch selection for
1507 /// unreachability.
1508 ///
1509 /// # Errors
1510 ///
1511 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1512 pub fn is_dispatch_ineligible(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
1513 Ok(self.state()?.dispatch_ineligible.contains_key(&worker_id))
1514 }
1515
1516 /// Every gRPC-delivered worker the liveness probe must ping this round,
1517 /// paired with the stream sender the ping rides (#197).
1518 ///
1519 /// The liminal half of the same census is
1520 /// [`LiminalConnectionNotifier::liveness_targets`](crate::worker::LiminalConnectionNotifier::liveness_targets),
1521 /// which enumerates CONNECTIONS. This one enumerates REGISTRATIONS, because
1522 /// a gRPC worker's only server-side identity is its registry handle: the
1523 /// stream is owned by a tonic task and reachable solely through the sender
1524 /// the registration carries.
1525 ///
1526 /// Deliberately not filtered by current eligibility. A worker excluded from
1527 /// dispatch is precisely the worker whose next answered ping restores it,
1528 /// so skipping the excluded set would make exclusion permanent — the exact
1529 /// defect this lane exists to remove.
1530 ///
1531 /// # Errors
1532 ///
1533 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1534 pub fn grpc_liveness_targets(
1535 &self,
1536 ) -> Result<Vec<super::grpc_liveness::GrpcLivenessTarget>, ServerError> {
1537 Ok(self
1538 .state()?
1539 .workers
1540 .values()
1541 .filter_map(|worker| match &worker.delivery {
1542 WorkerDelivery::Grpc(sender) => Some(super::grpc_liveness::GrpcLivenessTarget {
1543 worker_id: worker.id,
1544 sender: sender.clone(),
1545 }),
1546 #[cfg(feature = "liminal-transport")]
1547 WorkerDelivery::Liminal(_) => None,
1548 })
1549 .collect())
1550 }
1551
1552 /// The currently published exclusion set, read as a whole.
1553 ///
1554 /// The liveness probe reads this BEFORE publishing a round's verdict, so it
1555 /// can announce the workers that just LEFT the set. Without the whole
1556 /// previous set there is no way to name a recovery: per-worker queries can
1557 /// only be asked about workers the new verdict already mentions, and a
1558 /// recovered worker is precisely the one it does not.
1559 ///
1560 /// # Errors
1561 ///
1562 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1563 pub fn dispatch_ineligible(
1564 &self,
1565 ) -> Result<BTreeMap<WorkerId, DispatchExclusion>, ServerError> {
1566 Ok(self.state()?.dispatch_ineligible.clone())
1567 }
1568
1569 /// Return whether a worker stream is currently registered.
1570 ///
1571 /// The activity dispatch path uses this after queuing a task to detect a
1572 /// worker whose stream tore down concurrently: a sweep that ran before
1573 /// the dispatch tracked its task can never complete it, so the dispatch
1574 /// must fail the activity itself instead of waiting forever.
1575 ///
1576 /// # Errors
1577 ///
1578 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1579 pub fn is_registered(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
1580 Ok(self.state()?.workers.contains_key(&worker_id))
1581 }
1582
1583 /// Remove a worker by id from every namespace/activity index it advertised.
1584 ///
1585 /// Emits a WS3 [`WorkerDeathReason::Disconnect`] delta — the truthful default
1586 /// for a removed worker whose stream/registration went away. Callers that can
1587 /// PROVE a finer reason (a liveness-timeout sweep) call
1588 /// [`Self::deregister_with_reason`] instead, so the dashboard never sees a
1589 /// fabricated distinction.
1590 ///
1591 /// # Errors
1592 ///
1593 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1594 pub fn deregister(&self, worker_id: WorkerId) -> Result<(), ServerError> {
1595 self.deregister_with_reason(worker_id, WorkerDeathReason::Disconnect)
1596 }
1597
1598 /// Remove a worker by id, attributing the departure to an explicit
1599 /// [`WorkerDeathReason`] the caller can prove at its call site (for example a
1600 /// heartbeat sweep passes [`WorkerDeathReason::Timeout`]).
1601 ///
1602 /// # Errors
1603 ///
1604 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1605 pub fn deregister_with_reason(
1606 &self,
1607 worker_id: WorkerId,
1608 reason: WorkerDeathReason,
1609 ) -> Result<(), ServerError> {
1610 let mut state = self.state()?;
1611 let removed_namespaces = Self::remove_worker(&mut state, worker_id);
1612 drop(state);
1613
1614 let Some(namespaces) = removed_namespaces else {
1615 // Already gone: no metrics double-count, no duplicate delta.
1616 return Ok(());
1617 };
1618
1619 if let Some(metrics) = &self.metrics {
1620 for namespace in &namespaces {
1621 metrics.worker_disconnected(namespace);
1622 }
1623 }
1624 self.emit_worker_disconnected(worker_id, &namespaces, reason);
1625
1626 Ok(())
1627 }
1628
1629 /// Emit a WS3 `WorkerDisconnected` delta if a publisher is attached.
1630 fn emit_worker_disconnected(
1631 &self,
1632 worker_id: WorkerId,
1633 namespaces: &BTreeSet<String>,
1634 reason: WorkerDeathReason,
1635 ) {
1636 if let Some(publisher) = &self.cluster_publisher {
1637 let namespaces_vec: Vec<String> = namespaces.iter().cloned().collect();
1638 drop(publisher.emit(|meta| ClusterEvent::WorkerDisconnected {
1639 meta,
1640 worker_id: worker_id.value().to_string(),
1641 namespaces: namespaces_vec,
1642 reason,
1643 }));
1644 }
1645 }
1646
1647 /// Remove a worker from every `(namespace, task_queue, activity_type)` index
1648 /// it advertised. Returns the namespace set it served (for metrics), or
1649 /// `None` if the worker was already gone.
1650 fn remove_worker(state: &mut RegistryState, worker_id: WorkerId) -> Option<BTreeSet<String>> {
1651 let handle = state.workers.remove(&worker_id)?;
1652 Self::remove_worker_from_service(state, &handle);
1653 Some(handle.namespaces)
1654 }
1655
1656 /// Fence a worker from every routing index while retaining its connection
1657 /// handle so an in-progress graceful drain can finish and tear down normally.
1658 fn remove_worker_from_service(state: &mut RegistryState, handle: &WorkerHandle) {
1659 // The in-flight projection goes with the registration. A departing
1660 // worker's tasks are swept by the tracker on the same paths that call
1661 // this, and a count left behind would keep a worker id that no longer
1662 // exists occupying capacity nothing can free.
1663 state.in_flight.remove(&handle.id);
1664 let departed_at = Instant::now();
1665 for namespace in &handle.namespaces {
1666 let pool = PoolAddress::new(namespace.clone(), handle.task_queue.clone());
1667 for activity_type in &handle.activity_types {
1668 let key = ActivityKey::new(pool.clone(), activity_type.clone());
1669 // Departure time is recorded for EVERY address this worker
1670 // served, so a later census can age the last compatible poller
1671 // instead of reporting a bare "nobody" (R1).
1672 state
1673 .last_departure
1674 .entry(key.clone())
1675 .or_default()
1676 .insert(handle.node.clone(), departed_at);
1677 if let Some(workers) = state.by_activity.get_mut(&key) {
1678 workers.remove(&handle.id);
1679 if workers.is_empty() {
1680 state.by_activity.remove(&key);
1681 // Prune the round-robin cursor in lockstep: the cursor
1682 // map is keyed on arbitrary caller-supplied strings and
1683 // is lazily created by `workers_for`, so leaving stale
1684 // entries behind leaks memory unboundedly on a
1685 // never-dying server. When the last worker for a triple
1686 // leaves, its cursor has no remaining meaning.
1687 state.rotation.remove(&key);
1688 }
1689 }
1690 }
1691 }
1692 }
1693
1694 fn state(&self) -> Result<MutexGuard<'_, RegistryState>, ServerError> {
1695 self.inner
1696 .lock()
1697 .map_err(|_| ServerError::lock_poisoned("connected worker registry"))
1698 }
1699}
1700
1701/// Normalize a wire `node` string into an optional locality affinity: an empty
1702/// value (the proto3 default) carries no node, anything else is the worker's
1703/// advertised node id.
1704///
1705/// Shared with contract admission ([`super::contracts::validate_worker_contracts`])
1706/// rather than restated there: admission decides which of a package's actions a
1707/// connection owes from the same locality this registry then routes by, and two
1708/// independent normalizations of the wire default would be a place for that
1709/// agreement to drift silently.
1710pub(crate) fn optional_node(node: &str) -> Option<String> {
1711 if node.is_empty() {
1712 None
1713 } else {
1714 Some(node.to_owned())
1715 }
1716}
1717
1718#[derive(Clone, Debug)]
1719struct WorkerRegistrationParts {
1720 worker_id: WorkerId,
1721 namespaces: BTreeSet<String>,
1722 task_queue: String,
1723 activity_types: BTreeSet<String>,
1724}
1725
1726/// Registration token owned by the worker stream task.
1727///
1728/// Dropping the token performs best-effort cleanup for disconnect paths. Call
1729/// [`WorkerRegistration::deregister`] when the caller needs a typed poison error.
1730#[derive(Debug)]
1731pub struct WorkerRegistration {
1732 registry: ConnectedWorkerRegistry,
1733 parts: Option<WorkerRegistrationParts>,
1734}
1735
1736impl WorkerRegistration {
1737 /// Worker id assigned to this registration.
1738 #[must_use]
1739 pub fn worker_id(&self) -> Option<WorkerId> {
1740 self.parts.as_ref().map(|parts| parts.worker_id)
1741 }
1742
1743 /// Authorized namespace set for this registration.
1744 #[must_use]
1745 pub fn namespaces(&self) -> Option<&BTreeSet<String>> {
1746 self.parts.as_ref().map(|parts| &parts.namespaces)
1747 }
1748
1749 /// Task queue (pool/flavour) this registration serves within each namespace.
1750 #[must_use]
1751 pub fn task_queue(&self) -> Option<&str> {
1752 self.parts.as_ref().map(|parts| parts.task_queue.as_str())
1753 }
1754
1755 /// Activity types advertised by this registration.
1756 #[must_use]
1757 pub fn activity_types(&self) -> Option<&BTreeSet<String>> {
1758 self.parts.as_ref().map(|parts| &parts.activity_types)
1759 }
1760
1761 /// Explicitly remove this worker from the registry.
1762 ///
1763 /// # Errors
1764 ///
1765 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1766 pub fn deregister(mut self) -> Result<(), ServerError> {
1767 let Some(parts) = self.parts.take() else {
1768 return Ok(());
1769 };
1770 self.registry.deregister(parts.worker_id)
1771 }
1772}
1773
1774impl Drop for WorkerRegistration {
1775 fn drop(&mut self) {
1776 let Some(parts) = self.parts.take() else {
1777 return;
1778 };
1779 let removed_namespaces = self.registry.inner.lock().ok().and_then(|mut state| {
1780 ConnectedWorkerRegistry::remove_worker(&mut state, parts.worker_id)
1781 });
1782 if let Some(namespaces) = removed_namespaces {
1783 if let Some(metrics) = &self.registry.metrics {
1784 for namespace in &namespaces {
1785 metrics.worker_disconnected(namespace);
1786 }
1787 }
1788 // A dropped registration token means the worker's stream/connection
1789 // went away — the truthful reason is Disconnect, not a fabricated
1790 // timeout/deregister distinction this path cannot prove.
1791 self.registry.emit_worker_disconnected(
1792 parts.worker_id,
1793 &namespaces,
1794 WorkerDeathReason::Disconnect,
1795 );
1796 }
1797 }
1798}
1799
1800#[cfg(test)]
1801mod tests {
1802 use std::time::Duration;
1803
1804 use crate::config::NamespaceMode;
1805 use crate::namespace::{NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces};
1806 use crate::worker::heartbeat::{DISPATCH_PROBATION_PINGS, HeartbeatTracker};
1807
1808 use super::*;
1809
1810 fn guard() -> NamespaceGuard {
1811 NamespaceGuard::new(NamespaceResolver::authorization_only(
1812 NamespaceMode::SharedEngine,
1813 StaticWorkflowNamespaces::default(),
1814 StaticScheduleNamespaces::default(),
1815 ))
1816 }
1817
1818 fn caller(namespace: &str) -> CallerIdentity {
1819 CallerIdentity::new("worker", [namespace.to_owned()])
1820 }
1821
1822 /// A test-expectation failure as a `ServerError`, so `Result`-returning
1823 /// tests can fail on an unexpected `None` without `panic!`/`expect`.
1824 fn test_failure(message: &str) -> ServerError {
1825 ServerError::worker_dispatch("default".to_owned(), "test".to_owned(), message.to_owned())
1826 }
1827
1828 /// A release gate whose party size is fixed when the gate OPENS, not when
1829 /// it is built.
1830 ///
1831 /// A `Barrier` has to be sized before the first racer is spawned. If the
1832 /// operating system then refuses one of those spawns, the racers that did
1833 /// start wait forever on a party that can never be complete, and the join
1834 /// that would have reported the refusal never runs — the test hangs
1835 /// burning a core instead of failing by name. Counting arrivals instead
1836 /// keeps the release simultaneous (nobody proceeds until every racer that
1837 /// started has arrived) while letting the releasing thread decide the
1838 /// party from the spawns it actually got.
1839 #[derive(Default)]
1840 struct StartGate {
1841 /// Arrival count and the open flag, under one lock so a waiter's
1842 /// predicate and the release are read and written atomically.
1843 state: Mutex<StartGateState>,
1844 /// Signalled on every arrival and once on release.
1845 change: std::sync::Condvar,
1846 }
1847
1848 /// The state [`StartGate`] guards.
1849 #[derive(Default)]
1850 struct StartGateState {
1851 /// How many racers have announced themselves.
1852 arrived: usize,
1853 /// Whether the releasing thread has opened the gate.
1854 open: bool,
1855 }
1856
1857 impl StartGate {
1858 /// Announce arrival and block until the gate opens.
1859 ///
1860 /// The arrival is counted BEFORE any early return, the poisoned one
1861 /// included. A racer that panics poisons the mutex; a peer that then
1862 /// returned without counting itself would leave the releasing thread's
1863 /// party permanently short, and `thread::scope` joins every racer
1864 /// before that thread's error can reach the caller — so the test would
1865 /// hang exactly where it meant to fail by name. Recovering the guard
1866 /// and counting first means every racer that reached this function is
1867 /// accounted for, whatever it does next.
1868 fn arrive_and_wait(&self) -> Result<(), ServerError> {
1869 let poisoned = self.state.is_poisoned();
1870 let mut state = self
1871 .state
1872 .lock()
1873 .unwrap_or_else(std::sync::PoisonError::into_inner);
1874 state.arrived += 1;
1875 self.change.notify_all();
1876 if poisoned {
1877 return Err(poisoned_start_gate());
1878 }
1879 while !state.open {
1880 match self.change.wait(state) {
1881 Ok(next) => state = next,
1882 Err(poison) => {
1883 // Release the lock before leaving, so the thread that
1884 // opens the gate is not blocked behind this racer.
1885 drop(poison.into_inner());
1886 return Err(poisoned_start_gate());
1887 }
1888 }
1889 }
1890 Ok(())
1891 }
1892
1893 /// Wait until `party` racers have arrived, then release them together.
1894 ///
1895 /// Every exit opens the gate FIRST. Reporting a poisoned lock without
1896 /// releasing would leave the racers that did arrive parked in `wait`
1897 /// with `open` still false, and the scope's join would never return —
1898 /// the same hang, wearing the other hat. A poisoned peer also ends the
1899 /// wait: a racer that panicked before announcing itself is never going
1900 /// to arrive, so a party that counts it can never complete.
1901 fn open_for(&self, party: usize) -> Result<(), ServerError> {
1902 let mut poisoned = self.state.is_poisoned();
1903 let mut state = self
1904 .state
1905 .lock()
1906 .unwrap_or_else(std::sync::PoisonError::into_inner);
1907 while !poisoned && state.arrived < party {
1908 match self.change.wait(state) {
1909 Ok(next) => state = next,
1910 Err(poison) => {
1911 state = poison.into_inner();
1912 poisoned = true;
1913 }
1914 }
1915 }
1916 state.open = true;
1917 self.change.notify_all();
1918 drop(state);
1919 if poisoned {
1920 return Err(poisoned_start_gate());
1921 }
1922 Ok(())
1923 }
1924 }
1925
1926 /// The failure a poisoned start gate is reported as: a racer panicked
1927 /// while holding the lock, which is a test failure in its own right and
1928 /// must not be collapsed into "no reservation won".
1929 fn poisoned_start_gate() -> ServerError {
1930 test_failure("the racing selection start gate was poisoned by a panicking racer")
1931 }
1932
1933 fn registration(namespace: &str, activity_types: &[&str]) -> ProtoRegisterWorker {
1934 registration_with_queue(namespace, "", activity_types)
1935 }
1936
1937 fn registration_with_queue(
1938 namespace: &str,
1939 task_queue: &str,
1940 activity_types: &[&str],
1941 ) -> ProtoRegisterWorker {
1942 registration_full(&[namespace], task_queue, "", activity_types)
1943 }
1944
1945 fn registration_full(
1946 namespaces: &[&str],
1947 task_queue: &str,
1948 node: &str,
1949 activity_types: &[&str],
1950 ) -> ProtoRegisterWorker {
1951 ProtoRegisterWorker {
1952 namespaces: namespaces.iter().map(|value| (*value).to_owned()).collect(),
1953 activity_types: activity_types
1954 .iter()
1955 .map(|value| (*value).to_owned())
1956 .collect(),
1957 task_queue: task_queue.to_owned(),
1958 node: node.to_owned(),
1959 activities: Vec::new(),
1960 identity: WIRE_IDENTITY.to_owned(),
1961 instance: None,
1962 max_concurrency: Some(4),
1963 }
1964 }
1965
1966 /// A registration that says nothing about its capacity is REFUSED, and the
1967 /// refusal names the field.
1968 ///
1969 /// There is no lenient path and no default here on purpose. The server
1970 /// counts a worker's in-flight dispatches against this number on every
1971 /// selection, so a registration without it could only be served by the
1972 /// server inventing how much work the process takes — which is how a worker
1973 /// ends up pushed past its own admission, silent, and then deregistered as
1974 /// lost while it works. The in-tree precedent is one field over:
1975 /// `activities` empty "predates contract commitment and is refused".
1976 #[tokio::test]
1977 async fn a_registration_without_capacity_is_refused_by_name() -> Result<(), ServerError> {
1978 let registry = ConnectedWorkerRegistry::default();
1979 let (sender, _receiver) = mpsc::channel(1);
1980 let mut frame = registration("tenant-a", &["charge"]);
1981 frame.max_concurrency = None;
1982
1983 let refusal = registry
1984 .accept_registration(&guard(), &caller("tenant-a"), &frame, sender)
1985 .await
1986 .err()
1987 .ok_or_else(|| test_failure("a capacity-less registration must be refused"))?;
1988 assert!(
1989 refusal.to_string().contains("max_concurrency"),
1990 "the refusal must NAME the field a worker has to fix: {refusal}"
1991 );
1992 assert!(
1993 registry
1994 .workers_for("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
1995 .is_empty(),
1996 "a refused registration must leave nothing behind in the pool"
1997 );
1998 Ok(())
1999 }
2000
2001 /// A registration advertising ZERO capacity is refused too, and with its own
2002 /// words: a worker that runs nothing could never be selected, so accepting
2003 /// it would register a worker that exists only to be skipped.
2004 #[tokio::test]
2005 async fn a_registration_advertising_zero_capacity_is_refused() -> Result<(), ServerError> {
2006 let registry = ConnectedWorkerRegistry::default();
2007 let (sender, _receiver) = mpsc::channel(1);
2008 let mut frame = registration("tenant-a", &["charge"]);
2009 frame.max_concurrency = Some(0);
2010
2011 let refusal = registry
2012 .accept_registration(&guard(), &caller("tenant-a"), &frame, sender)
2013 .await
2014 .err()
2015 .ok_or_else(|| test_failure("a zero-capacity registration must be refused"))?;
2016 assert!(
2017 refusal.to_string().contains("max_concurrency 0"),
2018 "the refusal must say what was WRONG with the value, not merely that one was \
2019 required: {refusal}"
2020 );
2021 Ok(())
2022 }
2023
2024 /// SELECTION NEVER EXCEEDS ADVERTISED CAPACITY, and the worker comes back
2025 /// the moment a slot frees.
2026 ///
2027 /// The pool here is one worker advertising two slots. With both held it is
2028 /// not a candidate — not deregistered, not ineligible, simply not selected —
2029 /// which is the resting state a busy worker should have had all along. The
2030 /// restore half is the vacuity control: without it, a filter that excluded
2031 /// the worker permanently (or a registry that had lost it) would pass.
2032 #[tokio::test]
2033 async fn selection_skips_a_worker_that_has_reached_its_advertised_capacity()
2034 -> Result<(), ServerError> {
2035 let registry = ConnectedWorkerRegistry::default();
2036 let (sender, _receiver) = mpsc::channel(4);
2037 let types = ["charge".to_owned()];
2038 let _registration = registry.register_namespaces(
2039 ["tenant-a".to_owned()],
2040 DEFAULT_TASK_QUEUE,
2041 None,
2042 types.iter(),
2043 sender,
2044 2,
2045 )?;
2046 let Some(worker) = registry
2047 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2048 .map(|(worker, _reservation)| worker)
2049 else {
2050 return Err(test_failure("an idle worker must be selectable"));
2051 };
2052 let worker_id = worker.id();
2053
2054 registry.record_dispatch_started(worker_id)?;
2055 assert!(
2056 registry
2057 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2058 .map(|(worker, _reservation)| worker)
2059 .is_some(),
2060 "one dispatch against a capacity of two leaves a slot"
2061 );
2062
2063 registry.record_dispatch_started(worker_id)?;
2064 assert_eq!(registry.in_flight_for_worker(worker_id)?, 2);
2065 assert!(
2066 registry
2067 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2068 .map(|(worker, _reservation)| worker)
2069 .is_none(),
2070 "a worker holding every slot it advertised must not be selected again: pushing past \
2071 its own admission is what made a working worker look silent"
2072 );
2073 // It is FULL, not gone: the census still counts it as a compatible
2074 // worker, so the taxonomy can say the pool is busy rather than empty.
2075 let census = registry.pool_census("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?;
2076 assert_eq!(census.compatible_workers, 1);
2077 assert_eq!(census.compatible_workers_at_capacity, 1);
2078 assert_eq!(census.eligible_compatible_workers, 0);
2079
2080 registry.record_dispatch_finished(worker_id)?;
2081 assert!(
2082 registry
2083 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2084 .map(|(worker, _reservation)| worker)
2085 .is_some(),
2086 "the freed slot must make the worker selectable again — without this the test above \
2087 would pass for a worker that had simply been lost"
2088 );
2089 Ok(())
2090 }
2091
2092 /// A worker registered with capacity UNKNOWN is not dispatched to, and
2093 /// becomes dispatchable the moment it announces.
2094 ///
2095 /// This is the liminal transport's whole shape at the registry level. Its
2096 /// published registration frame has no capacity field, so it registers
2097 /// pending and states its configured number one frame later on the
2098 /// capabilities channel. Both halves are asserted because each alone is a
2099 /// trap: without the first, the server invents a number for a process that
2100 /// is about to state its own; without the second, a liminal worker
2101 /// registers, looks healthy in every listing, and is never sent any work.
2102 ///
2103 /// Driven through `register_delivery` rather than a liminal connection
2104 /// because the invariant is the REGISTRY's, not the transport's — a handle
2105 /// with no advertised capacity is unselectable whoever produced it.
2106 #[tokio::test]
2107 async fn a_worker_with_no_advertised_capacity_is_unselectable_until_it_announces()
2108 -> Result<(), ServerError> {
2109 let registry = ConnectedWorkerRegistry::default();
2110 let (sender, _receiver) = mpsc::channel(4);
2111 let types = ["charge".to_owned()];
2112 let registration = registry.register_delivery(
2113 ["tenant-a".to_owned()],
2114 DEFAULT_TASK_QUEUE,
2115 None,
2116 types.iter(),
2117 WorkerDelivery::Grpc(sender),
2118 RegistrationOptions::identified_pending_capacity("pending-1"),
2119 )?;
2120
2121 // REGISTERED — it is in the pool and the census can see it...
2122 let census = registry.pool_census("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?;
2123 assert_eq!(
2124 census.compatible_workers, 1,
2125 "a worker awaiting its capacity announcement is registered, not absent"
2126 );
2127 // ...but NOT dispatchable, because the server does not know what it runs.
2128 assert!(
2129 registry
2130 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2131 .map(|(worker, _reservation)| worker)
2132 .is_none(),
2133 "a worker whose capacity is unknown must not be selected: the only way to dispatch \
2134 to it is to invent a number on its behalf, which is the defect this replaces"
2135 );
2136
2137 // From the REGISTRATION guard, not from `workers_for`: that list is the
2138 // dispatch-eligible one, and the whole point of this test is that this
2139 // worker is registered and not yet dispatchable. Asking the eligibility
2140 // list for a worker it is designed to omit is how the first draft of
2141 // this test failed.
2142 let worker_id = registration
2143 .worker_id()
2144 .ok_or_else(|| test_failure("the pending worker must have a registration id"))?;
2145 assert!(
2146 registry
2147 .worker_by_id(worker_id)?
2148 .is_some_and(|handle| handle.max_concurrency().is_none()),
2149 "the handle must report capacity UNKNOWN rather than a stand-in number"
2150 );
2151 // The census must call it what it is. Selection excludes an unannounced
2152 // worker by the same test it excludes a full one, so this is exactly
2153 // where the two would get confused — and the operator sentence attached
2154 // to "at capacity" is wrong for a worker that is idle and mute.
2155 assert_eq!(
2156 census.compatible_workers_capacity_unannounced, 1,
2157 "an unannounced worker must be counted as unannounced"
2158 );
2159 assert_eq!(
2160 census.compatible_workers_at_capacity, 0,
2161 "an unannounced worker must NOT be counted as busy: the two conditions have \
2162 different remedies and the census is what the operator sentence is derived from"
2163 );
2164 assert_eq!(census.eligible_compatible_workers, 0);
2165
2166 // THE ANNOUNCEMENT. Two, not one: a value the registry could also have
2167 // reached by defaulting would not prove the announcement was read.
2168 assert!(
2169 registry.set_advertised_capacity(worker_id, 2)?,
2170 "announcing capacity for a registered worker must apply"
2171 );
2172 let announced = registry.pool_census("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?;
2173 assert_eq!(
2174 announced.compatible_workers_capacity_unannounced, 0,
2175 "the announcement must clear the unannounced count"
2176 );
2177 assert_eq!(announced.eligible_compatible_workers, 1);
2178 assert!(
2179 registry
2180 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2181 .map(|(worker, _reservation)| worker)
2182 .is_some(),
2183 "the announced capacity must make the worker dispatchable"
2184 );
2185 registry.record_dispatch_started(worker_id)?;
2186 assert!(
2187 registry
2188 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2189 .map(|(worker, _reservation)| worker)
2190 .is_some(),
2191 "one dispatch against an announced capacity of two leaves a slot: the announced \
2192 number is what selection now counts against"
2193 );
2194 registry.record_dispatch_started(worker_id)?;
2195 assert!(
2196 registry
2197 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2198 .map(|(worker, _reservation)| worker)
2199 .is_none(),
2200 "the announced capacity must bound selection exactly as a registered one does"
2201 );
2202
2203 // A zero announcement is refused by name, so the later channel cannot
2204 // admit what the registration funnel rejects.
2205 let refusal = registry
2206 .set_advertised_capacity(worker_id, 0)
2207 .err()
2208 .ok_or_else(|| test_failure("a zero-capacity announcement must be refused"))?;
2209 assert!(
2210 refusal.to_string().contains("max_concurrency 0"),
2211 "the refusal must say what was wrong with the value: {refusal}"
2212 );
2213 Ok(())
2214 }
2215
2216 /// A SIMULTANEOUS fan never over-subscribes the worker it lands on.
2217 ///
2218 /// This is the arm the sequential test above cannot cover. Selection has
2219 /// always filtered on the in-flight count; what it did not do was CLAIM the
2220 /// slot it selected, so the claim landed later, in the caller, after the
2221 /// pending entry and the lease record had been written. Every dispatch that
2222 /// started in that window read the same pre-dispatch count. A sequential
2223 /// test passes against that defect — one caller, one window, nobody to race
2224 /// — and the real fan of a fan-out does not.
2225 ///
2226 /// So: far more racers than slots, released together on a barrier, each
2227 /// HOLDING what it wins. The count that matters is how many got a
2228 /// reservation, and it must be exactly the advertised concurrency. A
2229 /// late-claiming selector gives every racer a worker and fails here loudly.
2230 ///
2231 /// The release half at the end is the vacuity control: a registry that had
2232 /// simply lost the worker, or a filter that excluded it permanently, would
2233 /// also hand out no surplus.
2234 ///
2235 /// What this test does and does not discriminate, measured rather than
2236 /// assumed: moving the claim a few instructions outside the selection lock
2237 /// SURVIVES here — 32 racers on this host never landed in a window that
2238 /// narrow. Moving it behind any real work does not: with the claim deferred
2239 /// the width of the pending insert and the lease record, this fails 3 runs
2240 /// out of 3, reporting all 32 racers claiming 4 slots. It is a pin on the
2241 /// defect at its real width, not a proof that no narrower one exists.
2242 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2243 async fn simultaneous_selections_claim_no_more_slots_than_advertised() -> Result<(), ServerError>
2244 {
2245 /// Advertised concurrency of the single worker every racer targets.
2246 const CAPACITY: u32 = 4;
2247 /// Racers released at once — deliberately far more than `CAPACITY`, so
2248 /// a selector that claims late has many chances to over-subscribe.
2249 const RACERS: usize = 32;
2250
2251 let registry = ConnectedWorkerRegistry::default();
2252 let (sender, _receiver) = mpsc::channel(RACERS);
2253 let types = ["charge".to_owned()];
2254 let _registration = registry.register_namespaces(
2255 ["tenant-a".to_owned()],
2256 DEFAULT_TASK_QUEUE,
2257 None,
2258 types.iter(),
2259 sender,
2260 CAPACITY,
2261 )?;
2262
2263 let gate = StartGate::default();
2264 let (won, refused): (
2265 Vec<Option<(WorkerHandle, DispatchReservation)>>,
2266 Option<String>,
2267 ) = std::thread::scope(|scope| {
2268 // Spawn through the fallible builder, not `Scope::spawn`: the
2269 // latter panics when the operating system refuses a thread,
2270 // and that panic is raised on THIS thread while the racers
2271 // that did start are already parked on a party that can never
2272 // complete — the scope's join then never runs and the test
2273 // hangs instead of naming the refusal.
2274 let mut handles = Vec::with_capacity(RACERS);
2275 let mut refused = None;
2276 for index in 0..RACERS {
2277 let registry = registry.clone();
2278 let gate = &gate;
2279 let spawned = std::thread::Builder::new()
2280 .name(format!("registry-racer-{index}"))
2281 .spawn_scoped(scope, move || {
2282 gate.arrive_and_wait()?;
2283 registry.select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)
2284 });
2285 match spawned {
2286 Ok(handle) => handles.push(handle),
2287 Err(error) => {
2288 refused = Some(format!(
2289 "the operating system refused racing selection thread {index}: \
2290 {error}"
2291 ));
2292 break;
2293 }
2294 }
2295 }
2296 // Release exactly the racers that started. Sizing the release
2297 // here rather than at construction is what makes a refused
2298 // spawn reportable: the survivors are freed, joined, and the
2299 // refusal is returned by name.
2300 gate.open_for(handles.len())?;
2301 let won = handles
2302 .into_iter()
2303 .map(|handle| {
2304 // A racer thread that panicked would otherwise be
2305 // reported as "no reservation won", which reads exactly
2306 // like the correct answer. Surface it as its own
2307 // failure instead.
2308 handle
2309 .join()
2310 .map_err(|_| test_failure("a racing selection thread panicked"))?
2311 })
2312 .collect::<Result<Vec<_>, ServerError>>()?;
2313 Ok::<_, ServerError>((won, refused))
2314 })?;
2315 if let Some(refusal) = refused {
2316 return Err(test_failure(&refusal));
2317 }
2318
2319 let granted = won.iter().filter(|slot| slot.is_some()).count();
2320 assert_eq!(
2321 granted, CAPACITY as usize,
2322 "{RACERS} simultaneous selections claimed {granted} slots on a worker advertising {CAPACITY}: the choice and the claim are not one critical section, so every racer read the same pre-dispatch count"
2323 );
2324 assert_eq!(
2325 registry.in_flight_for_worker(
2326 won.iter()
2327 .flatten()
2328 .next()
2329 .ok_or_else(|| test_failure("no racer won a slot"))?
2330 .0
2331 .id()
2332 )?,
2333 CAPACITY,
2334 "the projection must agree with the number of reservations actually held"
2335 );
2336
2337 drop(won);
2338 assert!(
2339 registry
2340 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2341 .map(|(worker, _reservation)| worker)
2342 .is_some(),
2343 "released reservations must make the worker selectable again — without this the count above would also pass for a worker that had been lost"
2344 );
2345 assert_eq!(
2346 registry.in_flight_for_worker(
2347 registry
2348 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2349 .map(|(worker, _reservation)| worker)
2350 .ok_or_else(|| test_failure("the worker must still be registered"))?
2351 .id()
2352 )?,
2353 0,
2354 "every reservation must have given its slot back on drop; a leak here shrinks the pool silently for the life of the registration"
2355 );
2356 Ok(())
2357 }
2358
2359 /// A freed slot WAKES a parked selection.
2360 ///
2361 /// A dispatch parked because every compatible worker is full is blocked on
2362 /// exactly one event and on nothing else: no worker is going to register,
2363 /// and no reachability verdict is going to change. A completion that
2364 /// corrected the count without waking would leave that dispatch asleep
2365 /// until something unrelated happened to fire.
2366 #[tokio::test]
2367 async fn a_freed_dispatch_slot_wakes_a_parked_selection() -> Result<(), ServerError> {
2368 let registry = ConnectedWorkerRegistry::default();
2369 let (sender, _receiver) = mpsc::channel(1);
2370 let types = ["charge".to_owned()];
2371 let _registration = registry.register_namespaces(
2372 ["tenant-a".to_owned()],
2373 DEFAULT_TASK_QUEUE,
2374 None,
2375 types.iter(),
2376 sender,
2377 1,
2378 )?;
2379 let Some(worker) = registry
2380 .select_and_reserve("tenant-a", DEFAULT_TASK_QUEUE, "charge", None)?
2381 .map(|(worker, _reservation)| worker)
2382 else {
2383 return Err(test_failure("an idle worker must be selectable"));
2384 };
2385 registry.record_dispatch_started(worker.id())?;
2386
2387 // Subscribe BEFORE the release, exactly as a parking dispatch does.
2388 let arrival = registry.worker_arrival();
2389 registry.record_dispatch_finished(worker.id())?;
2390 tokio::time::timeout(std::time::Duration::from_secs(1), arrival)
2391 .await
2392 .map_err(|_| {
2393 test_failure(
2394 "a freed dispatch slot did not wake the selection wait; a dispatch parked on \
2395 a full pool would sleep through the very completion that serves it",
2396 )
2397 })?;
2398 Ok(())
2399 }
2400
2401 /// The identity the wire fixture registers under, so a test can assert it
2402 /// reached the handle rather than being dropped at admission (WA-010 R3).
2403 const WIRE_IDENTITY: &str = "wire-worker-7";
2404
2405 fn multi_caller(namespaces: &[&str]) -> CallerIdentity {
2406 CallerIdentity::new("worker", namespaces.iter().map(|value| (*value).to_owned()))
2407 }
2408
2409 /// `set_intervention_capabilities` replaces a live worker's advertised set —
2410 /// the announcement path a liminal agent worker takes after its in-band
2411 /// registration (the registration frame cannot carry capabilities) — and
2412 /// reports an unknown worker as `false` (an announcement racing a
2413 /// disconnect is benign, never an error).
2414 #[tokio::test]
2415 async fn set_intervention_capabilities_updates_live_worker() -> Result<(), ServerError> {
2416 let registry = ConnectedWorkerRegistry::default();
2417 let (sender, _receiver) = mpsc::channel(1);
2418 let types = ["scout".to_owned()];
2419 let guard = registry.register_delivery(
2420 ["default".to_owned()],
2421 "default",
2422 None,
2423 types.iter(),
2424 WorkerDelivery::Grpc(sender),
2425 RegistrationOptions::identified(
2426 "scout-1",
2427 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
2428 )
2429 .with_intervention_capabilities(InterventionCapabilities::none()),
2430 )?;
2431 let Some(worker_id) = guard.worker_id() else {
2432 return Err(test_failure("registration carries an id"));
2433 };
2434
2435 let announced = InterventionCapabilities {
2436 supported: vec![aion_core::InterventionPrimitive::InjectMessage],
2437 };
2438 assert!(
2439 registry.set_intervention_capabilities(worker_id, &announced)?,
2440 "a live worker's capabilities must be updatable"
2441 );
2442 let Some(handle) = registry.worker_by_id(worker_id)? else {
2443 return Err(test_failure("worker stays registered"));
2444 };
2445 assert_eq!(handle.intervention_capabilities(), &announced);
2446
2447 assert!(
2448 !registry.set_intervention_capabilities(WorkerId(u64::MAX), &announced)?,
2449 "an unknown worker reports false, never an error"
2450 );
2451 Ok(())
2452 }
2453
2454 /// WA-010 R3: the wire's `identity` reaches the handle. Before this pin it
2455 /// was dropped at the registry insert, so a lease could never name
2456 /// the process that took the attempt.
2457 #[tokio::test]
2458 async fn admission_carries_the_wire_identity_onto_the_handle() -> Result<(), ServerError> {
2459 let registry = ConnectedWorkerRegistry::default();
2460 let (sender, _receiver) = mpsc::channel(1);
2461 let admitted = registry
2462 .accept_registration(
2463 &guard(),
2464 &caller("tenant-a"),
2465 ®istration("tenant-a", &["charge"]),
2466 sender,
2467 )
2468 .await?;
2469 let Some(worker_id) = admitted.worker_id() else {
2470 return Err(test_failure("registration carries an id"));
2471 };
2472 let Some(handle) = registry.worker_by_id(worker_id)? else {
2473 return Err(test_failure("the admitted worker is readable"));
2474 };
2475 assert_eq!(handle.identity(), WIRE_IDENTITY);
2476
2477 let (plain_sender, _plain_receiver) = mpsc::channel(1);
2478 let types = ["charge".to_owned()];
2479 let plain = registry.register_namespaces(
2480 ["tenant-a".to_owned()],
2481 DEFAULT_TASK_QUEUE,
2482 None,
2483 types.iter(),
2484 plain_sender,
2485 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
2486 )?;
2487 let Some(plain_id) = plain.worker_id() else {
2488 return Err(test_failure("registration carries an id"));
2489 };
2490 let Some(plain_handle) = registry.worker_by_id(plain_id)? else {
2491 return Err(test_failure("the plain worker is readable"));
2492 };
2493 assert_eq!(
2494 plain_handle.identity(),
2495 "",
2496 "a sender-only registration never sent an identity and must not invent one"
2497 );
2498 Ok(())
2499 }
2500
2501 #[tokio::test]
2502 async fn register_and_deregister_are_namespace_isolated() -> Result<(), ServerError> {
2503 let registry = ConnectedWorkerRegistry::default();
2504 let (tenant_a_tx, _tenant_a_rx) = mpsc::channel(1);
2505 let (tenant_b_tx, _tenant_b_rx) = mpsc::channel(1);
2506
2507 let tenant_a = registry
2508 .accept_registration(
2509 &guard(),
2510 &caller("tenant-a"),
2511 ®istration("tenant-a", &["charge", "charge"]),
2512 tenant_a_tx,
2513 )
2514 .await?;
2515 let tenant_b = registry
2516 .accept_registration(
2517 &guard(),
2518 &caller("tenant-b"),
2519 ®istration("tenant-b", &["charge"]),
2520 tenant_b_tx,
2521 )
2522 .await?;
2523
2524 let tq = DEFAULT_TASK_QUEUE;
2525 assert_eq!(
2526 registry.workers_for("tenant-a", tq, "charge", None)?.len(),
2527 1
2528 );
2529 assert_eq!(
2530 registry.workers_for("tenant-b", tq, "charge", None)?.len(),
2531 1
2532 );
2533 assert!(
2534 registry
2535 .workers_for("tenant-a", tq, "missing", None)?
2536 .is_empty()
2537 );
2538
2539 let tenant_a_id = tenant_a.worker_id();
2540 tenant_a.deregister()?;
2541
2542 assert!(
2543 registry
2544 .workers_for("tenant-a", tq, "charge", None)?
2545 .is_empty()
2546 );
2547 assert_eq!(
2548 registry.workers_for("tenant-b", tq, "charge", None)?.len(),
2549 1
2550 );
2551 assert_ne!(tenant_a_id, tenant_b.worker_id());
2552
2553 tenant_b.deregister()?;
2554 assert!(
2555 registry
2556 .workers_for("tenant-b", tq, "charge", None)?
2557 .is_empty()
2558 );
2559 Ok(())
2560 }
2561
2562 #[tokio::test]
2563 async fn denied_namespace_is_not_registered() -> Result<(), ServerError> {
2564 let registry = ConnectedWorkerRegistry::default();
2565 let (tx, _rx) = mpsc::channel(1);
2566 let denied = registry
2567 .accept_registration(
2568 &guard(),
2569 &caller("tenant-a"),
2570 ®istration("tenant-b", &["charge"]),
2571 tx,
2572 )
2573 .await;
2574
2575 assert!(denied.is_err());
2576 assert!(
2577 registry
2578 .workers_for("tenant-b", DEFAULT_TASK_QUEUE, "charge", None)?
2579 .is_empty()
2580 );
2581 Ok(())
2582 }
2583
2584 #[tokio::test]
2585 async fn task_queues_partition_disjoint_pools_within_one_namespace() -> Result<(), ServerError>
2586 {
2587 // Same namespace + same activity_type, two DIFFERENT task queues: the
2588 // pools are disjoint, a lookup for one queue never returns the other's
2589 // worker, and round-robin holds independently per (ns, tq, type) triple.
2590 let registry = ConnectedWorkerRegistry::default();
2591 let (norn_tx, _norn_rx) = mpsc::channel(1);
2592 let (claude_a_tx, _claude_a_rx) = mpsc::channel(1);
2593 let (claude_b_tx, _claude_b_rx) = mpsc::channel(1);
2594
2595 let norn = registry
2596 .accept_registration(
2597 &guard(),
2598 &caller("local"),
2599 ®istration_with_queue("local", "norn", &["dev"]),
2600 norn_tx,
2601 )
2602 .await?;
2603 // Two workers on the SAME (local, claude) pool to exercise round-robin.
2604 let claude_a = registry
2605 .accept_registration(
2606 &guard(),
2607 &caller("local"),
2608 ®istration_with_queue("local", "claude", &["dev"]),
2609 claude_a_tx,
2610 )
2611 .await?;
2612 let claude_b = registry
2613 .accept_registration(
2614 &guard(),
2615 &caller("local"),
2616 ®istration_with_queue("local", "claude", &["dev"]),
2617 claude_b_tx,
2618 )
2619 .await?;
2620
2621 let norn_pool = registry.workers_for("local", "norn", "dev", None)?;
2622 assert_eq!(norn_pool.len(), 1, "norn pool has exactly its one worker");
2623 let norn_id = norn.worker_id().ok_or_else(missing_id)?;
2624 assert_eq!(norn_pool[0].id(), norn_id);
2625
2626 let claude_pool = registry.workers_for("local", "claude", "dev", None)?;
2627 assert_eq!(
2628 claude_pool.len(),
2629 2,
2630 "claude pool sees only its two workers"
2631 );
2632 let claude_ids: BTreeSet<WorkerId> = claude_pool.iter().map(WorkerHandle::id).collect();
2633 assert!(
2634 !claude_ids.contains(&norn_id),
2635 "the norn worker must never appear in the claude pool"
2636 );
2637
2638 // A dispatch targeting `norn` never reaches a `claude` worker, and vice
2639 // versa: the disjoint key is the boundary.
2640 assert!(
2641 !registry
2642 .workers_for("local", "norn", "dev", None)?
2643 .iter()
2644 .any(|worker| claude_ids.contains(&worker.id()))
2645 );
2646
2647 // Round-robin per triple: the (local, claude, dev) cursor advances
2648 // independently and cycles through both claude workers, while the
2649 // (local, norn, dev) cursor keeps returning its single worker.
2650 let first = registry.workers_for("local", "claude", "dev", None)?[0].id();
2651 let second = registry.workers_for("local", "claude", "dev", None)?[0].id();
2652 assert_ne!(
2653 first, second,
2654 "claude pool round-robins across both workers"
2655 );
2656 assert_eq!(
2657 registry.workers_for("local", "norn", "dev", None)?[0].id(),
2658 norn_id,
2659 "the norn pool rotation is unaffected by claude traffic"
2660 );
2661
2662 norn.deregister()?;
2663 claude_a.deregister()?;
2664 claude_b.deregister()?;
2665 Ok(())
2666 }
2667
2668 #[tokio::test]
2669 async fn same_task_queue_in_different_namespaces_is_isolated() -> Result<(), ServerError> {
2670 // Same task_queue string, two DIFFERENT namespaces: namespace is the
2671 // correctness boundary, so the pools are isolated.
2672 let registry = ConnectedWorkerRegistry::default();
2673 let (local_tx, _local_rx) = mpsc::channel(1);
2674 let (remote_tx, _remote_rx) = mpsc::channel(1);
2675
2676 let local = registry
2677 .accept_registration(
2678 &guard(),
2679 &caller("local"),
2680 ®istration_with_queue("local", "gpu", &["render"]),
2681 local_tx,
2682 )
2683 .await?;
2684 let remote = registry
2685 .accept_registration(
2686 &guard(),
2687 &caller("remote"),
2688 ®istration_with_queue("remote", "gpu", &["render"]),
2689 remote_tx,
2690 )
2691 .await?;
2692
2693 let local_pool = registry.workers_for("local", "gpu", "render", None)?;
2694 let remote_pool = registry.workers_for("remote", "gpu", "render", None)?;
2695 assert_eq!(local_pool.len(), 1);
2696 assert_eq!(remote_pool.len(), 1);
2697 assert_ne!(
2698 local_pool[0].id(),
2699 remote_pool[0].id(),
2700 "a shared task_queue string does not merge two namespaces"
2701 );
2702
2703 local.deregister()?;
2704 assert!(
2705 registry
2706 .workers_for("local", "gpu", "render", None)?
2707 .is_empty(),
2708 "deregistering the local worker leaves the remote namespace untouched"
2709 );
2710 assert_eq!(
2711 registry.workers_for("remote", "gpu", "render", None)?.len(),
2712 1
2713 );
2714
2715 remote.deregister()?;
2716 Ok(())
2717 }
2718
2719 #[tokio::test]
2720 async fn worker_serving_a_namespace_set_is_reachable_in_each() -> Result<(), ServerError> {
2721 // A worker advertising {a, b} is reachable for dispatch in BOTH a and b;
2722 // a worker in {a} is NOT reachable in b.
2723 let registry = ConnectedWorkerRegistry::default();
2724 let (ab_tx, _ab_rx) = mpsc::channel(1);
2725 let (a_tx, _a_rx) = mpsc::channel(1);
2726
2727 let worker_ab = registry
2728 .accept_registration(
2729 &guard(),
2730 &multi_caller(&["a", "b"]),
2731 ®istration_full(&["a", "b"], "default", "", &["dev"]),
2732 ab_tx,
2733 )
2734 .await?;
2735 let worker_a = registry
2736 .accept_registration(
2737 &guard(),
2738 &caller("a"),
2739 ®istration_full(&["a"], "default", "", &["dev"]),
2740 a_tx,
2741 )
2742 .await?;
2743
2744 let in_a = registry.workers_for("a", "default", "dev", None)?;
2745 let in_b = registry.workers_for("b", "default", "dev", None)?;
2746 let both_id = worker_ab.worker_id().ok_or_else(missing_id)?;
2747 let only_a_id = worker_a.worker_id().ok_or_else(missing_id)?;
2748
2749 // Namespace a sees BOTH workers; namespace b sees ONLY the {a, b} worker.
2750 let a_ids: BTreeSet<WorkerId> = in_a.iter().map(WorkerHandle::id).collect();
2751 assert_eq!(a_ids, BTreeSet::from([both_id, only_a_id]));
2752 assert_eq!(in_b.len(), 1, "only the {{a, b}} worker is reachable in b");
2753 assert_eq!(in_b[0].id(), both_id);
2754 assert!(
2755 !in_b.iter().any(|worker| worker.id() == only_a_id),
2756 "the {{a}}-only worker must not be reachable in b"
2757 );
2758
2759 // Deregistering the {a, b} worker removes it from BOTH buckets.
2760 worker_ab.deregister()?;
2761 assert!(
2762 registry
2763 .workers_for("b", "default", "dev", None)?
2764 .is_empty()
2765 );
2766 assert_eq!(registry.workers_for("a", "default", "dev", None)?.len(), 1);
2767
2768 worker_a.deregister()?;
2769 Ok(())
2770 }
2771
2772 #[tokio::test]
2773 async fn node_pin_filters_within_pool() -> Result<(), ServerError> {
2774 // Two workers in the same (namespace, task_queue) pool on different
2775 // nodes: unpinned round-robins across both; pinned to node N reaches
2776 // ONLY the worker(s) on N; pinned to a node with no worker finds none.
2777 let registry = ConnectedWorkerRegistry::default();
2778 let (n1_tx, _n1_rx) = mpsc::channel(1);
2779 let (n2_tx, _n2_rx) = mpsc::channel(1);
2780
2781 let on_n1 = registry
2782 .accept_registration(
2783 &guard(),
2784 &caller("ns"),
2785 ®istration_full(&["ns"], "tq", "n1", &["dev"]),
2786 n1_tx,
2787 )
2788 .await?;
2789 let on_n2 = registry
2790 .accept_registration(
2791 &guard(),
2792 &caller("ns"),
2793 ®istration_full(&["ns"], "tq", "n2", &["dev"]),
2794 n2_tx,
2795 )
2796 .await?;
2797 let n1_id = on_n1.worker_id().ok_or_else(missing_id)?;
2798 let n2_id = on_n2.worker_id().ok_or_else(missing_id)?;
2799
2800 // Unpinned: both workers are candidates and round-robin advances.
2801 let unpinned = registry.workers_for("ns", "tq", "dev", None)?;
2802 assert_eq!(unpinned.len(), 2, "unpinned reaches the whole pool");
2803 let first = registry.workers_for("ns", "tq", "dev", None)?[0].id();
2804 let second = registry.workers_for("ns", "tq", "dev", None)?[0].id();
2805 assert_ne!(first, second, "unpinned round-robins across both nodes");
2806
2807 // Pinned to n1: only the n1 worker; pinned to n2: only the n2 worker.
2808 let pinned_n1 = registry.workers_for("ns", "tq", "dev", Some("n1"))?;
2809 assert_eq!(pinned_n1.len(), 1);
2810 assert_eq!(pinned_n1[0].id(), n1_id);
2811 let pinned_n2 = registry.workers_for("ns", "tq", "dev", Some("n2"))?;
2812 assert_eq!(pinned_n2.len(), 1);
2813 assert_eq!(pinned_n2[0].id(), n2_id);
2814
2815 // select_worker honours the same filter.
2816 assert_eq!(
2817 registry
2818 .select_and_reserve("ns", "tq", "dev", Some("n1"))?
2819 .map(|(worker, _reservation)| worker)
2820 .map(|worker| worker.id()),
2821 Some(n1_id)
2822 );
2823
2824 // Pinned to a node with no worker finds no candidate (the dispatcher
2825 // then waits via the same no-worker path the existing test exercises).
2826 assert!(
2827 registry
2828 .workers_for("ns", "tq", "dev", Some("absent"))?
2829 .is_empty(),
2830 "a pin to a node with no worker yields no candidate"
2831 );
2832 assert!(
2833 registry
2834 .select_and_reserve("ns", "tq", "dev", Some("absent"))?
2835 .map(|(worker, _reservation)| worker)
2836 .is_none()
2837 );
2838
2839 on_n1.deregister()?;
2840 on_n2.deregister()?;
2841 Ok(())
2842 }
2843
2844 #[tokio::test]
2845 async fn shared_node_id_round_robins_across_workers() -> Result<(), ServerError> {
2846 // Two workers SHARING a node id in the same pool: a dispatch pinned to
2847 // that node round-robins across BOTH (node is locality, not process).
2848 let registry = ConnectedWorkerRegistry::default();
2849 let (a_tx, _a_rx) = mpsc::channel(1);
2850 let (b_tx, _b_rx) = mpsc::channel(1);
2851
2852 let worker_a = registry
2853 .accept_registration(
2854 &guard(),
2855 &caller("ns"),
2856 ®istration_full(&["ns"], "tq", "shared", &["dev"]),
2857 a_tx,
2858 )
2859 .await?;
2860 let worker_b = registry
2861 .accept_registration(
2862 &guard(),
2863 &caller("ns"),
2864 ®istration_full(&["ns"], "tq", "shared", &["dev"]),
2865 b_tx,
2866 )
2867 .await?;
2868 let a_id = worker_a.worker_id().ok_or_else(missing_id)?;
2869 let b_id = worker_b.worker_id().ok_or_else(missing_id)?;
2870
2871 let pinned = registry.workers_for("ns", "tq", "dev", Some("shared"))?;
2872 assert_eq!(
2873 pinned.len(),
2874 2,
2875 "both workers on the shared node are candidates"
2876 );
2877 let pinned_ids: BTreeSet<WorkerId> = pinned.iter().map(WorkerHandle::id).collect();
2878 assert_eq!(pinned_ids, BTreeSet::from([a_id, b_id]));
2879
2880 let first = registry.workers_for("ns", "tq", "dev", Some("shared"))?[0].id();
2881 let second = registry.workers_for("ns", "tq", "dev", Some("shared"))?[0].id();
2882 assert_ne!(
2883 first, second,
2884 "a pin to a shared node round-robins across both workers on it"
2885 );
2886
2887 worker_a.deregister()?;
2888 worker_b.deregister()?;
2889 Ok(())
2890 }
2891
2892 /// T1 — the finding, as a test. `select_worker` took the LOWEST matching
2893 /// worker id, so a workflow whose activities are dispatched over anything
2894 /// but the gRPC push leg sent every one of them to the first-registered
2895 /// worker and left every other node idle. Selection is the candidate at the
2896 /// pool's rotation cursor, so four consecutive unpinned selections walk the
2897 /// two nodes twice.
2898 ///
2899 /// The sequence is asserted, not merely "they differ": a selector that
2900 /// alternated by luck of hash order would satisfy a difference assertion and
2901 /// still not be a round-robin.
2902 #[tokio::test]
2903 async fn unpinned_selection_rotates_across_nodes() -> Result<(), ServerError> {
2904 let registry = ConnectedWorkerRegistry::default();
2905 let (n1_tx, _n1_rx) = mpsc::channel(1);
2906 let (n2_tx, _n2_rx) = mpsc::channel(1);
2907
2908 let on_n1 = registry
2909 .accept_registration(
2910 &guard(),
2911 &caller("ns"),
2912 ®istration_full(&["ns"], "tq", "n1", &["dev"]),
2913 n1_tx,
2914 )
2915 .await?;
2916 let on_n2 = registry
2917 .accept_registration(
2918 &guard(),
2919 &caller("ns"),
2920 ®istration_full(&["ns"], "tq", "n2", &["dev"]),
2921 n2_tx,
2922 )
2923 .await?;
2924
2925 let mut visited = Vec::new();
2926 for _ in 0..4 {
2927 let selected = registry
2928 .select_and_reserve("ns", "tq", "dev", None)?
2929 .map(|(worker, _reservation)| worker)
2930 .ok_or_else(|| test_failure("an unpinned selection must find a worker"))?;
2931 visited.push(selected.node().map(str::to_owned));
2932 }
2933 assert_eq!(
2934 visited,
2935 vec![
2936 Some(String::from("n1")),
2937 Some(String::from("n2")),
2938 Some(String::from("n1")),
2939 Some(String::from("n2")),
2940 ],
2941 "unpinned selection must rotate across the nodes rather than pin itself to the \
2942 lowest worker id"
2943 );
2944
2945 on_n1.deregister()?;
2946 on_n2.deregister()?;
2947 Ok(())
2948 }
2949
2950 /// T2 — one eligible-candidate derivation means the exclusion a liveness
2951 /// verdict publishes binds BOTH selectors.
2952 ///
2953 /// `workers_for` ignored it entirely and handed the push dispatcher a worker
2954 /// the server had already found unreachable; `select_worker` honoured it. The
2955 /// excluded worker here is the LOWEST id on purpose — the one the old
2956 /// `min_by_key` selector would have taken every time — so a rotation that
2957 /// merely skipped position zero could not pass this.
2958 #[test]
2959 fn an_ineligible_worker_is_never_selected_by_either_selector() -> Result<(), ServerError> {
2960 let registry = ConnectedWorkerRegistry::default();
2961 let types = [String::from("dev")];
2962 let mut receivers = Vec::new();
2963 let mut registrations = Vec::new();
2964 for _ in 0..3 {
2965 let (tx, rx) = mpsc::channel(1);
2966 receivers.push(rx);
2967 registrations.push(registry.register_namespaces(
2968 [String::from("ns")],
2969 "tq",
2970 None,
2971 types.iter(),
2972 tx,
2973 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
2974 )?);
2975 }
2976 let mut ids = Vec::new();
2977 for registration in ®istrations {
2978 ids.push(registration.worker_id().ok_or_else(missing_id)?);
2979 }
2980 ids.sort_unstable();
2981 let excluded = *ids.first().ok_or_else(missing_id)?;
2982 registry.set_dispatch_ineligible(
2983 [(excluded, DispatchExclusion::ReachabilityLost)]
2984 .into_iter()
2985 .collect(),
2986 )?;
2987
2988 // Four calls: two full rotations of the two-worker eligible set, through
2989 // each selector in turn, so an exclusion honoured only at one cursor
2990 // position could not hide.
2991 for _ in 0..4 {
2992 let selected = registry
2993 .select_and_reserve("ns", "tq", "dev", None)?
2994 .map(|(worker, _reservation)| worker)
2995 .ok_or_else(|| test_failure("two eligible workers remain in the pool"))?;
2996 assert_ne!(
2997 selected.id(),
2998 excluded,
2999 "select_worker must never return the worker the liveness verdict excluded"
3000 );
3001 let candidates = registry.workers_for("ns", "tq", "dev", None)?;
3002 assert_eq!(
3003 candidates.len(),
3004 2,
3005 "workers_for must offer the two ELIGIBLE workers, not all three registered ones"
3006 );
3007 assert!(
3008 candidates.iter().all(|worker| worker.id() != excluded),
3009 "workers_for must not list the excluded worker in ANY position: the push \
3010 dispatcher walks the whole list"
3011 );
3012 }
3013
3014 for registration in registrations {
3015 registration.deregister()?;
3016 }
3017 Ok(())
3018 }
3019
3020 /// The hazard the eligible-set derivation introduces on the push dispatcher,
3021 /// demonstrated at the bytes rather than argued.
3022 ///
3023 /// `pool_census` deliberately counts REGISTERED node-matched workers with no
3024 /// eligibility filter (#197 R3: `classify` must be able to tell an empty pool
3025 /// from an excluded one), so an all-ineligible pool reports itself SERVED
3026 /// while selection finds nobody. `dispatch_to_node` reads exactly this pair,
3027 /// and treating the disagreement as the registration race it used to be —
3028 /// re-selecting at once — would spin that loop hot with no park and no log
3029 /// for as long as the exclusion lasts. It parks instead; the proof is
3030 /// `a_dispatch_to_an_all_ineligible_pool_parks_until_eligibility_returns` in
3031 /// `dispatch.rs`, and the park's wake-up is proven directly below.
3032 #[test]
3033 fn an_all_ineligible_pool_selects_nobody_while_the_census_still_reads_served()
3034 -> Result<(), ServerError> {
3035 let registry = ConnectedWorkerRegistry::default();
3036 let types = [String::from("dev")];
3037 let (tx, _rx) = mpsc::channel(1);
3038 let worker = registry.register_namespaces(
3039 [String::from("ns")],
3040 "tq",
3041 None,
3042 types.iter(),
3043 tx,
3044 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3045 )?;
3046 let worker_id = worker.worker_id().ok_or_else(missing_id)?;
3047 // 🔴 An OPENING PROBATION specifically, and the choice is load-bearing.
3048 // This test's subject is the census/selection disagreement that
3049 // `dispatch_to_node` must park on rather than spin through, and that
3050 // disagreement survives exactly for the exclusion that clears itself.
3051 // A `ReachabilityLost` pool no longer reaches this state: it now
3052 // classifies as `POLLERS_UNREACHABLE` and publishes a reason instead of
3053 // parking silently, which is the whole of the withdrawn-pool fix. Using
3054 // it here would make this test assert the behaviour that was replaced.
3055 registry.set_dispatch_ineligible(
3056 [(
3057 worker_id,
3058 DispatchExclusion::OpeningProbation { answers: 1 },
3059 )]
3060 .into_iter()
3061 .collect(),
3062 )?;
3063
3064 assert!(
3065 registry.workers_for("ns", "tq", "dev", None)?.is_empty(),
3066 "the push dispatcher's candidate list must exclude an unreachable worker"
3067 );
3068 assert!(
3069 registry
3070 .select_and_reserve("ns", "tq", "dev", None)?
3071 .map(|(worker, _reservation)| worker)
3072 .is_none(),
3073 "and so must the single selector"
3074 );
3075 let census = registry.pool_census("ns", "tq", "dev", None)?;
3076 assert_eq!(
3077 census.compatible_workers, 1,
3078 "the census counts the REGISTERED worker: #197 R3 needs the count to separate an \
3079 empty pool from an excluded one"
3080 );
3081 assert!(
3082 census.is_served(),
3083 "so `classify` reads this address as served and returns None while selection has \
3084 nobody — the disagreement dispatch_to_node must park on rather than spin through"
3085 );
3086 assert!(
3087 census.will_be_served(),
3088 "and the park is the RIGHT outcome here: a probation clears itself within seconds, \
3089 so nobody should be warned and nothing should be published. The pool that has LOST \
3090 reachability is the one that must not reach this state — see \
3091 `the_exclusion_cause_is_what_decides`"
3092 );
3093 assert_eq!(
3094 census.compatible_workers_reachability_lost, 0,
3095 "precondition for the assertion above: this fixture's exclusion is a probation"
3096 );
3097
3098 worker.deregister()?;
3099 Ok(())
3100 }
3101
3102 /// T8 — the arrival subscription RETAINS a wake that lands before it is
3103 /// awaited, and a subscription taken after that wake does not.
3104 ///
3105 /// This is the flight-1 judge's finding reproduced at the bytes and then
3106 /// closed, both arms against the same registry so the pair is read together
3107 /// and neither can rot alone.
3108 ///
3109 /// Both registry wake sources are `Notify::notify_waiters`, which stores NO
3110 /// permit. A dispatcher that read the registry, missed, and only then
3111 /// constructed its wait therefore parked past a registration that had
3112 /// already happened — holding positive census evidence of a live worker,
3113 /// with no second event on the way. On `OutboxTransport::Grpc` no liveness
3114 /// probe runs and no reachability verdict is ever published, so "the next
3115 /// unrelated registration anywhere in the registry" was the only wake left.
3116 ///
3117 /// Polled by hand with a no-op waker rather than raced against a runtime, so
3118 /// retention is PROVEN rather than timed: no clock, no timeout, no runtime.
3119 #[test]
3120 fn an_arrival_subscription_retains_a_wake_taken_before_it() -> Result<(), ServerError> {
3121 use std::future::Future;
3122 use std::task::Waker;
3123
3124 let registry = ConnectedWorkerRegistry::default();
3125 let mut context = Context::from_waker(Waker::noop());
3126
3127 // CONTROL ARM — the base's sequence, subscribe AFTER the look.
3128 // The registration fires `notify_waiters` into an empty waiter list.
3129 let (first_tx, _first_rx) = mpsc::channel(1);
3130 let first = registry.register(
3131 "ns",
3132 [String::from("dev")].iter(),
3133 first_tx,
3134 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3135 )?;
3136 let mut too_late = std::pin::pin!(registry.worker_arrived.notified());
3137 assert!(
3138 matches!(too_late.as_mut().poll(&mut context), Poll::Pending),
3139 "a wait constructed AFTER the registration cannot have retained it: \
3140 `notify_waiters` stored no permit and there was no waiter in the list to \
3141 broadcast to. This is the finding, reproduced."
3142 );
3143 assert!(
3144 matches!(too_late.as_mut().poll(&mut context), Poll::Pending),
3145 "and it stays parked — without this control the arm above would pass on a wait \
3146 that merely reports Pending on its first poll for registration reasons"
3147 );
3148
3149 // SUBJECT ARM — the fix's sequence, subscribe BEFORE the look.
3150 let arrival = registry.worker_arrival();
3151 let (second_tx, _second_rx) = mpsc::channel(1);
3152 let second = registry.register(
3153 "ns",
3154 [String::from("dev")].iter(),
3155 second_tx,
3156 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3157 )?;
3158 let mut arrival = std::pin::pin!(arrival);
3159 assert!(
3160 matches!(arrival.as_mut().poll(&mut context), Poll::Ready(())),
3161 "a subscription taken BEFORE the registry read must retain the registration that \
3162 landed during it: the dispatch that missed by a microsecond must not park"
3163 );
3164 assert!(
3165 matches!(too_late.as_mut().poll(&mut context), Poll::Ready(())),
3166 "the control's own wait was in the list by now, so the SECOND registration wakes \
3167 it — proving the control arm above was parked on the lost wake and not on a \
3168 registry that never notified at all"
3169 );
3170
3171 first.deregister()?;
3172 second.deregister()?;
3173 Ok(())
3174 }
3175
3176 /// The other half of that resolution: publishing a reachability verdict WAKES
3177 /// the selection wait.
3178 ///
3179 /// A dispatch parked because every compatible worker is ineligible is waiting
3180 /// on the verdict, not on a registration — those workers are already
3181 /// registered, so waiting for a registration sleeps through their recovery.
3182 ///
3183 /// Polled by hand with a no-op waker rather than raced against a runtime, so
3184 /// the wake is proven rather than timed. The middle poll is THE CONTROL: a
3185 /// wait that completed on its second poll for any reason at all would satisfy
3186 /// the final assertion and prove nothing about the publication.
3187 #[test]
3188 fn publishing_a_reachability_verdict_wakes_the_selection_wait() -> Result<(), ServerError> {
3189 use std::future::Future;
3190 use std::task::Waker;
3191
3192 let registry = ConnectedWorkerRegistry::default();
3193 let mut waiting = std::pin::pin!(registry.worker_arrival());
3194 let mut context = Context::from_waker(Waker::noop());
3195
3196 assert!(
3197 matches!(waiting.as_mut().poll(&mut context), Poll::Pending),
3198 "the wait parks until something changes what selection can see"
3199 );
3200 assert!(
3201 matches!(waiting.as_mut().poll(&mut context), Poll::Pending),
3202 "and stays parked while nothing has been published: without this control the \
3203 assertion below would pass on a wait that simply completes on a second poll"
3204 );
3205
3206 // Exactly what a probe round does once a worker has served its probation.
3207 registry.set_dispatch_ineligible(BTreeMap::new())?;
3208 assert!(
3209 matches!(waiting.as_mut().poll(&mut context), Poll::Ready(())),
3210 "a published verdict must wake a dispatch parked on eligibility; no registration is \
3211 coming for a worker that never left the registry"
3212 );
3213 Ok(())
3214 }
3215
3216 #[tokio::test]
3217 async fn rotation_cursor_is_pruned_when_last_worker_leaves() -> Result<(), ServerError> {
3218 // The round-robin cursor is keyed on arbitrary caller-supplied strings;
3219 // it must not outlive the pool it rotates, or a never-dying server leaks
3220 // memory. After the last worker for a triple deregisters, no cursor for
3221 // that triple may remain.
3222 let registry = ConnectedWorkerRegistry::default();
3223 let (tx, _rx) = mpsc::channel(1);
3224 let worker = registry
3225 .accept_registration(
3226 &guard(),
3227 &caller("ns"),
3228 ®istration_full(&["ns"], "tq", "", &["dev"]),
3229 tx,
3230 )
3231 .await?;
3232
3233 // Drive the lazy cursor insert.
3234 let _ = registry.workers_for("ns", "tq", "dev", None)?;
3235 let key = ActivityKey::new(PoolAddress::new("ns", "tq"), "dev");
3236 assert!(
3237 registry.state()?.rotation.contains_key(&key),
3238 "a lookup must have created the rotation cursor"
3239 );
3240
3241 worker.deregister()?;
3242 let state = registry.state()?;
3243 assert!(
3244 !state.rotation.contains_key(&key),
3245 "the rotation cursor must be pruned once the last worker leaves"
3246 );
3247 assert!(
3248 !state.by_activity.contains_key(&key),
3249 "the activity bucket must also be gone"
3250 );
3251 Ok(())
3252 }
3253
3254 fn missing_id() -> ServerError {
3255 ServerError::lock_poisoned("registration unexpectedly missing a worker id")
3256 }
3257
3258 // ---- Minted-on-use (Control-Plane Phase 1) -----------------------------
3259
3260 fn namespace_store() -> Arc<dyn NamespaceStore> {
3261 Arc::new(aion_store::InMemoryStore::default())
3262 }
3263
3264 fn minting_registry(
3265 store: &Arc<dyn NamespaceStore>,
3266 policy: AutoCreate,
3267 ) -> ConnectedWorkerRegistry {
3268 ConnectedWorkerRegistry::default().with_namespace_minting(Arc::clone(store), policy)
3269 }
3270
3271 #[tokio::test]
3272 async fn open_register_mints_durable_record_and_is_idempotent() -> Result<(), ServerError> {
3273 let store = namespace_store();
3274 let registry = minting_registry(&store, AutoCreate::Open);
3275
3276 // First registration mints the namespace.
3277 let (tx_one, _rx_one) = mpsc::channel(1);
3278 let first = registry
3279 .accept_registration(
3280 &guard(),
3281 &caller("orders"),
3282 ®istration("orders", &["charge"]),
3283 tx_one,
3284 )
3285 .await?;
3286 let record = store
3287 .get_namespace("orders")
3288 .await?
3289 .ok_or_else(|| ServerError::namespace_denied("expected a minted record"))?;
3290 assert_eq!(record.name, "orders");
3291 assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
3292
3293 // Re-registering the same namespace is idempotent: no duplicate row,
3294 // and the prior worker is unaffected.
3295 let (tx_two, _rx_two) = mpsc::channel(1);
3296 registry
3297 .accept_registration(
3298 &guard(),
3299 &caller("orders"),
3300 ®istration("orders", &["refund"]),
3301 tx_two,
3302 )
3303 .await?;
3304 let all = store.list_namespaces().await?;
3305 assert_eq!(
3306 all.iter().filter(|r| r.name == "orders").count(),
3307 1,
3308 "re-register must not create a duplicate namespace row"
3309 );
3310 drop(first);
3311 Ok(())
3312 }
3313
3314 #[tokio::test]
3315 async fn open_register_mints_each_namespace_in_a_multi_namespace_worker()
3316 -> Result<(), ServerError> {
3317 let store = namespace_store();
3318 let registry = minting_registry(&store, AutoCreate::Open);
3319 let (tx, _rx) = mpsc::channel(1);
3320
3321 registry
3322 .accept_registration(
3323 &guard(),
3324 &multi_caller(&["alpha", "beta"]),
3325 ®istration_full(&["alpha", "beta"], "", "", &["charge"]),
3326 tx,
3327 )
3328 .await?;
3329
3330 assert!(store.get_namespace("alpha").await?.is_some());
3331 assert!(store.get_namespace("beta").await?.is_some());
3332 Ok(())
3333 }
3334
3335 // ---- Placement admission (Control-Plane Phase 2, P2-I1) -----------------
3336
3337 /// Pre-mint `namespace` and set its placement to `Pinned{nodes}`, returning a
3338 /// minting registry over the same store so `accept_registration` reads the
3339 /// placement from the SAME durable record.
3340 async fn pinned_registry(
3341 store: &Arc<dyn NamespaceStore>,
3342 namespace: &str,
3343 nodes: &[&str],
3344 ) -> Result<ConnectedWorkerRegistry, ServerError> {
3345 store
3346 .register_namespace(namespace, NamespaceOrigin::Explicit)
3347 .await?;
3348 store
3349 .set_namespace_placement(
3350 namespace,
3351 NamespacePlacement::Pinned {
3352 nodes: nodes.iter().map(|n| (*n).to_owned()).collect(),
3353 },
3354 )
3355 .await?;
3356 Ok(minting_registry(store, AutoCreate::Open))
3357 }
3358
3359 /// A worker on a node IN the required set registers successfully into a
3360 /// `Pinned{n1}` namespace, and is reachable in the pool.
3361 #[tokio::test]
3362 async fn pinned_admits_a_worker_on_a_required_node() -> Result<(), ServerError> {
3363 let store = namespace_store();
3364 let registry = pinned_registry(&store, "iso", &["n1"]).await?;
3365 let (tx, _rx) = mpsc::channel(1);
3366
3367 let _registration = registry
3368 .accept_registration(
3369 &guard(),
3370 &caller("iso"),
3371 ®istration_full(&["iso"], "", "n1", &["charge"]),
3372 tx,
3373 )
3374 .await?;
3375
3376 assert_eq!(
3377 registry
3378 .workers_for("iso", DEFAULT_TASK_QUEUE, "charge", Some("n1"))?
3379 .len(),
3380 1,
3381 "an n1 worker must be admitted into the Pinned{{n1}} namespace's pool"
3382 );
3383 Ok(())
3384 }
3385
3386 /// A worker on a node NOT in the required set is rejected — the WHOLE
3387 /// registration fails (loud) and no worker is inserted. This would FAIL under
3388 /// no admission gate (the worker would join and steal Pinned dispatches).
3389 #[tokio::test]
3390 async fn pinned_rejects_a_wrong_node_worker() -> Result<(), ServerError> {
3391 let store = namespace_store();
3392 let registry = pinned_registry(&store, "iso", &["n1"]).await?;
3393 let (tx, _rx) = mpsc::channel(1);
3394
3395 let denied = registry
3396 .accept_registration(
3397 &guard(),
3398 &caller("iso"),
3399 ®istration_full(&["iso"], "", "n2", &["charge"]),
3400 tx,
3401 )
3402 .await;
3403 assert!(
3404 matches!(denied, Err(ServerError::Namespace { .. })),
3405 "a wrong-node (n2) worker must be rejected from a Pinned{{n1}} namespace"
3406 );
3407 assert!(
3408 registry
3409 .workers_for("iso", DEFAULT_TASK_QUEUE, "charge", None)?
3410 .is_empty(),
3411 "a rejected registration must not insert a worker on any node"
3412 );
3413 Ok(())
3414 }
3415
3416 /// A worker advertising NO node (`node == ""` → `None`) is rejected from a
3417 /// `Pinned{n1}` namespace: an unlabelled worker can never satisfy a hard pin.
3418 #[tokio::test]
3419 async fn pinned_rejects_a_node_less_worker() -> Result<(), ServerError> {
3420 let store = namespace_store();
3421 let registry = pinned_registry(&store, "iso", &["n1"]).await?;
3422 let (tx, _rx) = mpsc::channel(1);
3423
3424 let denied = registry
3425 .accept_registration(
3426 &guard(),
3427 &caller("iso"),
3428 ®istration_full(&["iso"], "", "", &["charge"]),
3429 tx,
3430 )
3431 .await;
3432 assert!(
3433 matches!(denied, Err(ServerError::Namespace { .. })),
3434 "a node-less worker must be rejected from a Pinned{{n1}} namespace"
3435 );
3436 assert!(
3437 registry
3438 .workers_for("iso", DEFAULT_TASK_QUEUE, "charge", None)?
3439 .is_empty(),
3440 "a rejected node-less registration must not insert a worker"
3441 );
3442 Ok(())
3443 }
3444
3445 /// Reject-WHOLE-registration (Open Decision 6): a worker serving BOTH a
3446 /// non-isolated namespace and a `Pinned{n1}` namespace on a wrong node is
3447 /// rejected entirely — the compliant namespace does NOT get a partial admit.
3448 #[tokio::test]
3449 async fn pinned_violation_rejects_the_whole_multi_namespace_registration()
3450 -> Result<(), ServerError> {
3451 let store = namespace_store();
3452 let registry = pinned_registry(&store, "iso", &["n1"]).await?;
3453 let (tx, _rx) = mpsc::channel(1);
3454
3455 let denied = registry
3456 .accept_registration(
3457 &guard(),
3458 &multi_caller(&["free", "iso"]),
3459 ®istration_full(&["free", "iso"], "", "n2", &["charge"]),
3460 tx,
3461 )
3462 .await;
3463 assert!(
3464 matches!(denied, Err(ServerError::Namespace { .. })),
3465 "a wrong-node worker serving a Pinned namespace fails the WHOLE registration"
3466 );
3467 assert!(
3468 registry
3469 .workers_for("free", DEFAULT_TASK_QUEUE, "charge", None)?
3470 .is_empty(),
3471 "the compliant namespace must NOT be partially admitted"
3472 );
3473 Ok(())
3474 }
3475
3476 /// Unplaced and Prefer namespaces are UNAFFECTED: a node-less worker registers
3477 /// normally (byte-identical to the pre-P2-I1 behaviour). Only Pinned gates.
3478 #[tokio::test]
3479 async fn unplaced_and_prefer_admission_is_unaffected_by_the_pinned_gate()
3480 -> Result<(), ServerError> {
3481 let store = namespace_store();
3482 // `unpl` is left Unplaced (default); `pref` is Prefer{n1}. A node-less
3483 // worker must be admitted into BOTH.
3484 store
3485 .register_namespace("pref", NamespaceOrigin::Explicit)
3486 .await?;
3487 store
3488 .set_namespace_placement(
3489 "pref",
3490 NamespacePlacement::Prefer {
3491 nodes: ["n1".to_owned()].into_iter().collect(),
3492 },
3493 )
3494 .await?;
3495 let registry = minting_registry(&store, AutoCreate::Open);
3496
3497 let (tx_a, _rx_a) = mpsc::channel(1);
3498 let _reg_a = registry
3499 .accept_registration(
3500 &guard(),
3501 &caller("unpl"),
3502 ®istration_full(&["unpl"], "", "", &["charge"]),
3503 tx_a,
3504 )
3505 .await?;
3506 let (tx_b, _rx_b) = mpsc::channel(1);
3507 let _reg_b = registry
3508 .accept_registration(
3509 &guard(),
3510 &caller("pref"),
3511 ®istration_full(&["pref"], "", "", &["charge"]),
3512 tx_b,
3513 )
3514 .await?;
3515
3516 assert_eq!(
3517 registry
3518 .workers_for("unpl", DEFAULT_TASK_QUEUE, "charge", None)?
3519 .len(),
3520 1,
3521 "an Unplaced namespace admits a node-less worker unchanged"
3522 );
3523 assert_eq!(
3524 registry
3525 .workers_for("pref", DEFAULT_TASK_QUEUE, "charge", None)?
3526 .len(),
3527 1,
3528 "a Prefer namespace admits a node-less worker unchanged (only Pinned gates)"
3529 );
3530 Ok(())
3531 }
3532
3533 /// A default (no-minter) registry is byte-identical: the placement gate is a
3534 /// no-op with no minter installed, so a node-less worker registers freely even
3535 /// though there is no way to have set a placement in the first place.
3536 #[tokio::test]
3537 async fn no_minter_registry_skips_the_placement_gate() -> Result<(), ServerError> {
3538 let registry = ConnectedWorkerRegistry::default();
3539 let (tx, _rx) = mpsc::channel(1);
3540 let _registration = registry
3541 .accept_registration(
3542 &guard(),
3543 &caller("plain"),
3544 ®istration_full(&["plain"], "", "", &["charge"]),
3545 tx,
3546 )
3547 .await?;
3548 assert_eq!(
3549 registry
3550 .workers_for("plain", DEFAULT_TASK_QUEUE, "charge", None)?
3551 .len(),
3552 1,
3553 "with no minter the placement gate is a no-op — registration is unchanged"
3554 );
3555 Ok(())
3556 }
3557
3558 #[tokio::test]
3559 async fn concurrent_registrations_for_a_new_namespace_create_exactly_one_record()
3560 -> Result<(), ServerError> {
3561 let store = namespace_store();
3562 let registry = minting_registry(&store, AutoCreate::Open);
3563
3564 let mut handles = Vec::new();
3565 for _ in 0..8 {
3566 let registry = registry.clone();
3567 handles.push(tokio::spawn(async move {
3568 let (tx, rx) = mpsc::channel(1);
3569 let outcome = registry
3570 .accept_registration(
3571 &guard(),
3572 &caller("rush"),
3573 ®istration("rush", &["charge"]),
3574 tx,
3575 )
3576 .await;
3577 // Keep the receiver alive for the duration of the registration.
3578 drop(rx);
3579 outcome.map(|registration| registration.worker_id())
3580 }));
3581 }
3582 for handle in handles {
3583 handle
3584 .await
3585 .map_err(|_| ServerError::lock_poisoned("registration task panicked"))??;
3586 }
3587
3588 let all = store.list_namespaces().await?;
3589 assert_eq!(
3590 all.iter().filter(|r| r.name == "rush").count(),
3591 1,
3592 "racing registrations must converge on exactly one durable record"
3593 );
3594 Ok(())
3595 }
3596
3597 #[tokio::test]
3598 async fn closed_rejects_unknown_namespace_and_does_not_create_it() -> Result<(), ServerError> {
3599 let store = namespace_store();
3600 let registry = minting_registry(&store, AutoCreate::Closed);
3601 let (tx, _rx) = mpsc::channel(1);
3602
3603 let denied = registry
3604 .accept_registration(
3605 &guard(),
3606 &caller("ghost"),
3607 ®istration("ghost", &["charge"]),
3608 tx,
3609 )
3610 .await;
3611 assert!(
3612 matches!(denied, Err(ServerError::Namespace { .. })),
3613 "closed policy must reject an unknown namespace"
3614 );
3615 assert!(
3616 store.get_namespace("ghost").await?.is_none(),
3617 "closed policy must NOT create the namespace it rejected"
3618 );
3619 let tq = DEFAULT_TASK_QUEUE;
3620 assert!(
3621 registry
3622 .workers_for("ghost", tq, "charge", None)?
3623 .is_empty(),
3624 "a rejected registration must not insert a worker"
3625 );
3626 Ok(())
3627 }
3628
3629 #[tokio::test]
3630 async fn closed_admits_a_known_namespace() -> Result<(), ServerError> {
3631 let store = namespace_store();
3632 // Pre-mint the namespace (the POST /namespaces escape hatch's effect).
3633 store
3634 .register_namespace("known", NamespaceOrigin::Explicit)
3635 .await?;
3636 let registry = minting_registry(&store, AutoCreate::Closed);
3637 let (tx, _rx) = mpsc::channel(1);
3638
3639 // Bind the registration token: dropping it deregisters the worker.
3640 let _registration = registry
3641 .accept_registration(
3642 &guard(),
3643 &caller("known"),
3644 ®istration("known", &["charge"]),
3645 tx,
3646 )
3647 .await?;
3648 let tq = DEFAULT_TASK_QUEUE;
3649 assert_eq!(
3650 registry.workers_for("known", tq, "charge", None)?.len(),
3651 1,
3652 "a known namespace must register under closed policy"
3653 );
3654 Ok(())
3655 }
3656
3657 #[tokio::test]
3658 async fn no_minter_leaves_registration_untouched() -> Result<(), ServerError> {
3659 // The default registry installs no minter: registration succeeds and
3660 // never touches any namespace registry (byte-identical legacy path).
3661 let registry = ConnectedWorkerRegistry::default();
3662 let (tx, _rx) = mpsc::channel(1);
3663 let _registration = registry
3664 .accept_registration(
3665 &guard(),
3666 &caller("orders"),
3667 ®istration("orders", &["charge"]),
3668 tx,
3669 )
3670 .await?;
3671 let tq = DEFAULT_TASK_QUEUE;
3672 assert_eq!(registry.workers_for("orders", tq, "charge", None)?.len(), 1);
3673 Ok(())
3674 }
3675
3676 /// R1 census: an address no worker has ever served reports an empty fleet
3677 /// and no poller age at all — "never seen" is not "seen long ago".
3678 #[test]
3679 fn a_never_served_address_censuses_empty_with_no_poller_age() -> Result<(), ServerError> {
3680 let registry = ConnectedWorkerRegistry::default();
3681 let census = registry.pool_census("default", "general", "greet", None)?;
3682 assert_eq!(census.workers_in_pool, 0);
3683 assert_eq!(census.workers_serving_activity, 0);
3684 assert_eq!(census.compatible_workers, 0);
3685 assert_eq!(census.last_compatible_poller_age, None);
3686 assert!(!census.is_served());
3687 Ok(())
3688 }
3689
3690 /// R1 census: pool membership, activity coverage, and node coverage are
3691 /// three separate counts — that separation is what tells `NO_LIVE_POLLERS`
3692 /// apart from `POLLERS_INCOMPATIBLE`.
3693 #[test]
3694 fn the_census_separates_pool_activity_and_node_coverage() -> Result<(), ServerError> {
3695 let registry = ConnectedWorkerRegistry::default();
3696 let (tx, _rx) = mpsc::channel(1);
3697 let _worker = registry.register_namespaces(
3698 [String::from("default")],
3699 "general",
3700 Some(String::from("n1")),
3701 [String::from("greet")].iter(),
3702 tx,
3703 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3704 )?;
3705
3706 let unpinned = registry.pool_census("default", "general", "greet", None)?;
3707 assert_eq!(unpinned.workers_in_pool, 1);
3708 assert_eq!(unpinned.workers_serving_activity, 1);
3709 assert_eq!(unpinned.compatible_workers, 1);
3710 assert_eq!(unpinned.last_compatible_poller_age, Some(Duration::ZERO));
3711
3712 // Same pool, activity nobody advertises: pollers are live but
3713 // incompatible.
3714 let other_activity = registry.pool_census("default", "general", "settle", None)?;
3715 assert_eq!(other_activity.workers_in_pool, 1);
3716 assert_eq!(other_activity.workers_serving_activity, 0);
3717 assert_eq!(other_activity.compatible_workers, 0);
3718
3719 // Same activity, wrong node: coverage exists in the pool but not for
3720 // this dispatch.
3721 let wrong_node = registry.pool_census("default", "general", "greet", Some("n2"))?;
3722 assert_eq!(wrong_node.workers_serving_activity, 1);
3723 assert_eq!(wrong_node.compatible_workers, 0);
3724 assert_eq!(wrong_node.last_compatible_poller_age, None);
3725 Ok(())
3726 }
3727
3728 /// ACCEPTANCE (b) FOR #58, wiring half: a worker whose PROCESS looks alive
3729 /// but which the server cannot REACH on the push leg must lose dispatch
3730 /// eligibility — and losing it must actually remove it from selection.
3731 ///
3732 /// This is the composition the lease split exists for. The tracker half is
3733 /// pinned in `heartbeat::reachability_tests`; this pins the half that turns
3734 /// that verdict into a dispatch decision, using the SAME two types the
3735 /// liveness probe wires together — a real [`HeartbeatTracker`] census
3736 /// feeding a real [`ConnectedWorkerRegistry`].
3737 ///
3738 /// The staging is Finding B exactly: the worker's own liveness pump keeps
3739 /// beating (`record_connection_activity`) well past the window, so every
3740 /// signal that says "this process is alive" says so — and NO ping is ever
3741 /// answered, so the one signal that says "the server can reach it" is
3742 /// absent. Before the split those were one lease and the pump's weaker
3743 /// positive evidence buried the ping's negative evidence.
3744 ///
3745 /// The control is the second half of the test: the same worker, reachable,
3746 /// must still be selected. Without it a registry that selected NOBODY would
3747 /// satisfy the first assertion and prove nothing.
3748 #[test]
3749 fn a_pump_alive_worker_the_server_cannot_reach_is_not_selected() -> Result<(), ServerError> {
3750 const WINDOW: Duration = Duration::from_secs(30);
3751
3752 let registry = ConnectedWorkerRegistry::default();
3753 let tracker = HeartbeatTracker::new(WINDOW);
3754 let (tx, _rx) = mpsc::channel(1);
3755 let worker = registry.register_namespaces(
3756 [String::from("default")],
3757 "general",
3758 None,
3759 [String::from("greet")].iter(),
3760 tx,
3761 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3762 )?;
3763 let Some(worker_id) = worker.worker_id() else {
3764 return Err(test_failure("registration carries an id"));
3765 };
3766 let start = Instant::now();
3767 tracker.register_connection(worker_id, start)?;
3768
3769 // Publishing the tracker's verdict is one motion the liveness probe
3770 // performs at the end of every round, and this test performs it four
3771 // times. Written once so the four publications cannot drift apart and
3772 // quietly stop testing the same thing.
3773 let publish = |now: Instant| -> Result<(), ServerError> {
3774 registry.set_dispatch_ineligible(
3775 tracker
3776 .unreachable_workers(now)?
3777 .into_iter()
3778 .map(|excluded| (excluded.worker_id, excluded.exclusion))
3779 .collect(),
3780 )
3781 };
3782
3783 // Baseline: the worker SERVES ITS PROBATION and is therefore selectable.
3784 // Registration alone grants nothing — the handshake ack is sent, never
3785 // confirmed received — so eligibility here is earned by answered pings.
3786 // If this did not hold, no worker could ever be dispatched to.
3787 for _ in 0..DISPATCH_PROBATION_PINGS {
3788 assert!(
3789 tracker.record_dispatch_reachability(worker_id, start)?,
3790 "the worker is tracked while it serves its probation"
3791 );
3792 }
3793 publish(start)?;
3794 assert!(
3795 registry
3796 .select_and_reserve("default", "general", "greet", None)?
3797 .map(|(worker, _reservation)| worker)
3798 .is_some(),
3799 "a worker that has answered a full run of pings must be selectable: this is the \
3800 control, and without it a registry selecting NOBODY would satisfy every assertion \
3801 below"
3802 );
3803
3804 // Now the poisoned-connection shape: the pump beats past the window and
3805 // not one ping is answered. Each unanswered probe is recorded exactly as
3806 // the liveness probe records it, because that is the trajectory a real
3807 // poisoned connection takes — staleness alone would be a shape only a
3808 // stopped probe could produce.
3809 let much_later = start + WINDOW * 4;
3810 assert!(
3811 tracker.record_connection_activity(worker_id, much_later)?,
3812 "the worker is still tracked; its process is plainly alive"
3813 );
3814 assert!(
3815 tracker.record_dispatch_unreachable(worker_id)?,
3816 "the probe fired and went unanswered"
3817 );
3818 publish(much_later)?;
3819
3820 assert!(
3821 registry.is_dispatch_ineligible(worker_id)?,
3822 "a worker the server cannot reach must be marked ineligible however alive its \
3823 process looks"
3824 );
3825 assert!(
3826 registry
3827 .select_and_reserve("default", "general", "greet", None)?
3828 .map(|(worker, _reservation)| worker)
3829 .is_none(),
3830 "an ineligible worker must not be SELECTED: selecting one produces a dispatch that \
3831 can only fail, and on the liminal transport it fails by consuming connection \
3832 capacity — making the unreachability worse"
3833 );
3834
3835 // The recovery path: exclusion must be WITHDRAWABLE, or a single bad
3836 // round would strand a healthy worker forever. It is withdrawn by a
3837 // served probation, not by one answer — and the intermediate assertion
3838 // below pins that distinction rather than assuming it.
3839 assert!(
3840 tracker.record_dispatch_reachability(worker_id, much_later)?,
3841 "the worker is still tracked"
3842 );
3843 publish(much_later)?;
3844 assert!(
3845 registry.is_dispatch_ineligible(worker_id)?,
3846 "one answer part-way through a fresh probation must NOT restore eligibility: a link \
3847 answering one probe in three would otherwise flap in and out of selection"
3848 );
3849
3850 for _ in 1..DISPATCH_PROBATION_PINGS {
3851 assert!(
3852 tracker.record_dispatch_reachability(worker_id, much_later)?,
3853 "the worker is still tracked"
3854 );
3855 }
3856 publish(much_later)?;
3857 assert!(
3858 !registry.is_dispatch_ineligible(worker_id)?,
3859 "an answered ping must clear the exclusion"
3860 );
3861 assert!(
3862 registry
3863 .select_and_reserve("default", "general", "greet", None)?
3864 .map(|(worker, _reservation)| worker)
3865 .is_some(),
3866 "and the worker must be selectable again"
3867 );
3868 Ok(())
3869 }
3870
3871 /// R1 census: after the last compatible worker leaves, the address reports
3872 /// how long ago it was served — the age an operator sees on the parked
3873 /// dispatch's WARN.
3874 #[test]
3875 fn a_departed_worker_leaves_a_last_compatible_poller_age() -> Result<(), ServerError> {
3876 let registry = ConnectedWorkerRegistry::default();
3877 let (tx, _rx) = mpsc::channel(1);
3878 let worker = registry.register_namespaces(
3879 [String::from("default")],
3880 "general",
3881 None,
3882 [String::from("greet")].iter(),
3883 tx,
3884 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3885 )?;
3886 worker.deregister()?;
3887
3888 let census = registry.pool_census("default", "general", "greet", None)?;
3889 assert_eq!(census.workers_in_pool, 0);
3890 assert_eq!(census.compatible_workers, 0);
3891 let age = census
3892 .last_compatible_poller_age
3893 .ok_or_else(|| test_failure("a departed worker must leave an age behind"))?;
3894 assert!(
3895 age < Duration::from_secs(60),
3896 "the recorded departure is implausibly old: {age:?}"
3897 );
3898
3899 // A worker returning to the address clears the departure record: the
3900 // map that answers "how long ago" never accumulates live addresses.
3901 let (tx, _rx) = mpsc::channel(1);
3902 let _back = registry.register_namespaces(
3903 [String::from("default")],
3904 "general",
3905 None,
3906 [String::from("greet")].iter(),
3907 tx,
3908 crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
3909 )?;
3910 let served = registry.pool_census("default", "general", "greet", None)?;
3911 assert_eq!(served.last_compatible_poller_age, Some(Duration::ZERO));
3912 assert!(served.is_served());
3913 Ok(())
3914 }
3915}