Skip to main content

cronoq_server/
remote.rs

1//! Remote execution: workers connect over gRPC streams and receive leases.
2//!
3//! Each pool's capacity is the sum of the slots its connected workers registered. The
4//! [`RemoteExecutor`] turns an engine dispatch into a `Lease` message for a worker with a free
5//! slot and resolves when that worker reports an outcome, disconnects, or the attempt is aborted
6//! by the engine (timeout, lease expiry, cancellation).
7//!
8//! Every worker gets a bounded outbound channel sized for its slots (at most one lease and one
9//! abort per slot, plus control messages). A worker whose channel is full is not reading its
10//! stream: it is disconnected rather than allowed to swallow leases or miss aborts.
11
12use std::collections::{BTreeMap, VecDeque};
13use std::sync::{Arc, Mutex, OnceLock};
14use std::time::{Duration, SystemTime};
15
16use bytes::Bytes;
17use cronoq::{BoxFuture, Executor, Labels, Scheduler, TaskContext, TaskError, TaskId};
18use cronoq_proto::v1 as pb;
19use rustc_hash::FxHashMap;
20use tokio::sync::mpsc::error::TrySendError;
21use tokio::sync::{mpsc, oneshot};
22use tokio_util::sync::CancellationToken;
23
24use crate::convert::{CONTENT_TYPE_TAG, unix_ns};
25
26/// Outbound message channel of one worker stream.
27pub type WorkerTx = mpsc::Sender<Result<pb::ServerMessage, tonic::Status>>;
28
29/// Channel capacity for a worker allowed `slot_cap` slots.
30pub fn channel_capacity(slot_cap: u32) -> usize {
31    (slot_cap as usize).saturating_mul(2).saturating_add(16)
32}
33
34struct WorkerConn {
35    pool: Arc<str>,
36    /// Sorted labels (the worker's group).
37    labels: Labels,
38    name: String,
39    tx: WorkerTx,
40    slots: u32,
41    busy: u32,
42    /// Cancelled to cut the stream immediately (unhealthy worker, forced stop).
43    cut: CancellationToken,
44    connected: SystemTime,
45}
46
47/// A connected worker, as [`WorkerRegistry::list`] reports it.
48#[derive(Clone, Debug)]
49pub struct WorkerSummary {
50    /// Connection id.
51    pub id: u64,
52    /// Name the worker registered with.
53    pub name: String,
54    /// Pool it serves.
55    pub pool: Arc<str>,
56    /// Its labels, sorted.
57    pub labels: Labels,
58    /// Slots it offers.
59    pub slots: u32,
60    /// Slots in use.
61    pub busy: u32,
62    /// When it registered.
63    pub connected: SystemTime,
64}
65
66struct PendingLease {
67    worker: u64,
68    task: TaskId,
69    done: oneshot::Sender<Result<Bytes, TaskError>>,
70    ctx: TaskContext,
71    /// Kept to offer the lease to another worker after a Nack.
72    msg: pb::Lease,
73    /// Workers that refused this lease.
74    refused: Vec<u64>,
75}
76
77#[derive(Default)]
78struct State {
79    workers: BTreeMap<u64, WorkerConn>,
80    pending: FxHashMap<u64, PendingLease>,
81    next_worker: u64,
82    /// Round-robin cursor per pool.
83    cursor: FxHashMap<Arc<str>, u64>,
84    closed: bool,
85}
86
87/// Where capacity changes go: `(pool, group labels, slots)`.
88type CapacityTx = OnceLock<mpsc::UnboundedSender<(Arc<str>, Labels, u32)>>;
89
90/// Connected workers and their leases, shared by all remote pools.
91#[derive(Default)]
92pub struct WorkerRegistry {
93    state: Mutex<State>,
94    /// Capacity totals, applied to the scheduler in the order they were computed.
95    capacity_tx: CapacityTx,
96    /// Reports for leases the reporting worker does not hold (late, duplicate, aborted).
97    stale_reports: std::sync::atomic::AtomicU64,
98}
99
100/// Outcome of trying to send a lease to one worker.
101enum Offer {
102    Sent(u64),
103    NoWorker,
104}
105
106impl WorkerRegistry {
107    /// An empty registry.
108    pub fn new() -> Arc<Self> {
109        Arc::new(WorkerRegistry::default())
110    }
111
112    /// Number of reports for leases the reporting worker did not hold, which were ignored.
113    pub fn stale_reports(&self) -> u64 {
114        self.stale_reports
115            .load(std::sync::atomic::Ordering::Relaxed)
116    }
117
118    /// Start publishing capacity changes to `sched`.
119    pub fn attach(&self, sched: Scheduler<Bytes, Bytes>) {
120        let (tx, mut rx) = mpsc::unbounded_channel::<(Arc<str>, Labels, u32)>();
121        if self.capacity_tx.set(tx).is_err() {
122            return;
123        }
124        tokio::spawn(async move {
125            while let Some((pool, labels, total)) = rx.recv().await {
126                let labels: Vec<(&str, &str)> = labels.iter().map(|(k, v)| (&**k, &**v)).collect();
127                let _ = sched.set_group_capacity(&pool, &labels, total).await;
128            }
129        });
130    }
131
132    /// Reserve a worker id. Send `Registered` before calling [`Self::register`], so the worker
133    /// never receives a lease before its registration ack.
134    pub fn next_worker_id(&self) -> u64 {
135        let mut st = self.state.lock().unwrap();
136        st.next_worker += 1;
137        st.next_worker
138    }
139
140    /// Add a worker. Returns `false` if the registry is already closed (shutting down).
141    #[allow(clippy::too_many_arguments)]
142    pub fn register(
143        &self,
144        id: u64,
145        pool: Arc<str>,
146        labels: Labels,
147        name: String,
148        slots: u32,
149        tx: WorkerTx,
150        cut: CancellationToken,
151    ) -> bool {
152        let mut st = self.state.lock().unwrap();
153        if st.closed {
154            return false;
155        }
156        st.workers.insert(
157            id,
158            WorkerConn {
159                pool: pool.clone(),
160                labels: labels.clone(),
161                name,
162                tx,
163                slots,
164                busy: 0,
165                cut,
166                connected: SystemTime::now(),
167            },
168        );
169        Self::publish_capacity(&self.capacity_tx, &st, &pool, &labels);
170        true
171    }
172
173    /// Change a worker's slots (already capped by the caller).
174    pub fn set_slots(&self, worker: u64, slots: u32) {
175        let mut st = self.state.lock().unwrap();
176        let Some(w) = st.workers.get_mut(&worker) else {
177            return;
178        };
179        w.slots = slots;
180        let (pool, labels) = (w.pool.clone(), w.labels.clone());
181        Self::publish_capacity(&self.capacity_tx, &st, &pool, &labels);
182    }
183
184    /// Remove a worker; its in-flight leases fail as retryable.
185    pub fn unregister(&self, worker: u64) {
186        let lost = {
187            let mut st = self.state.lock().unwrap();
188            Self::remove_locked(&self.capacity_tx, &mut st, worker)
189        };
190        for p in lost {
191            let _ = p
192                .done
193                .send(Err(TaskError::retryable("worker disconnected")));
194        }
195    }
196
197    /// Cut a worker's stream with `status` (e.g. a protocol violation) and remove it.
198    pub fn reject(&self, worker: u64, status: tonic::Status) {
199        let lost = {
200            let mut st = self.state.lock().unwrap();
201            if let Some(w) = st.workers.get(&worker) {
202                // Best effort: the stream ends with this status if the worker is reading.
203                let _ = w.tx.try_send(Err(status));
204            }
205            Self::remove_locked(&self.capacity_tx, &mut st, worker)
206        };
207        for p in lost {
208            let _ = p
209                .done
210                .send(Err(TaskError::retryable("worker disconnected")));
211        }
212    }
213
214    fn remove_locked(capacity_tx: &CapacityTx, st: &mut State, worker: u64) -> Vec<PendingLease> {
215        let Some(w) = st.workers.remove(&worker) else {
216            return Vec::new();
217        };
218        let leases: Vec<u64> = st
219            .pending
220            .iter()
221            .filter(|(_, p)| p.worker == worker)
222            .map(|(l, _)| *l)
223            .collect();
224        let lost = leases
225            .into_iter()
226            .filter_map(|l| st.pending.remove(&l))
227            .collect();
228        Self::publish_capacity(capacity_tx, st, &w.pool, &w.labels);
229        lost
230    }
231
232    /// Disconnect a worker that stopped reading its stream.
233    fn evict_locked(
234        capacity_tx: &CapacityTx,
235        st: &mut State,
236        worker: u64,
237        lost: &mut Vec<PendingLease>,
238    ) {
239        if let Some(w) = st.workers.get(&worker) {
240            tracing::warn!(worker, name = ?w.name, pool = %w.pool, "disconnecting worker: its stream is not being read");
241            w.cut.cancel();
242        }
243        lost.extend(Self::remove_locked(capacity_tx, st, worker));
244    }
245
246    /// Queue the new total of the pool's group with `labels`. Called with the state lock held,
247    /// so updates are applied in the same order as the membership changes that caused them.
248    fn publish_capacity(capacity_tx: &CapacityTx, st: &State, pool: &Arc<str>, labels: &Labels) {
249        let total: u64 = st
250            .workers
251            .values()
252            .filter(|w| &w.pool == pool && &w.labels == labels)
253            .map(|w| w.slots as u64)
254            .sum();
255        if let Some(tx) = capacity_tx.get() {
256            let total = total.min(u32::MAX as u64) as u32;
257            let _ = tx.send((pool.clone(), labels.clone(), total));
258        }
259    }
260
261    /// Extend a lease owned by `worker`.
262    pub fn heartbeat(&self, worker: u64, lease: u64) {
263        let st = self.state.lock().unwrap();
264        if let Some(p) = st.pending.get(&lease)
265            && p.worker == worker
266        {
267            p.ctx.heartbeat();
268        }
269    }
270
271    /// Resolve a lease reported by `worker`. Reports for leases the worker does not hold are
272    /// ignored.
273    pub fn finish(
274        &self,
275        worker: u64,
276        lease: u64,
277        result: Result<Bytes, TaskError>,
278    ) -> Option<TaskId> {
279        let p = {
280            let mut st = self.state.lock().unwrap();
281            match st.pending.get(&lease) {
282                Some(p) if p.worker == worker => {}
283                _ => {
284                    self.stale_reports
285                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
286                    return None;
287                }
288            }
289            let p = st.pending.remove(&lease).unwrap();
290            if let Some(w) = st.workers.get_mut(&worker) {
291                w.busy = w.busy.saturating_sub(1);
292            }
293            p
294        };
295        let task = p.task;
296        let _ = p.done.send(result);
297        Some(task)
298    }
299
300    /// The worker refused a lease: offer it to another worker of the pool. If none can take it,
301    /// the attempt fails as retryable.
302    pub fn nack(&self, worker: u64, lease: u64, reason: &str) {
303        let mut lost = Vec::new();
304        let failed = {
305            let mut st = self.state.lock().unwrap();
306            match st.pending.get(&lease) {
307                Some(p) if p.worker == worker => {}
308                _ => return,
309            }
310            let mut p = st.pending.remove(&lease).unwrap();
311            if let Some(w) = st.workers.get_mut(&worker) {
312                w.busy = w.busy.saturating_sub(1);
313            }
314            p.refused.push(worker);
315            let pool = st
316                .workers
317                .get(&worker)
318                .map(|w| w.pool.clone())
319                .unwrap_or_else(|| p.ctx.pool.clone());
320            let group = p.ctx.group.clone();
321            match Self::offer_locked(
322                &self.capacity_tx,
323                &mut st,
324                &pool,
325                &group,
326                &p.msg,
327                &p.refused,
328                &mut lost,
329            ) {
330                Offer::Sent(w) => {
331                    p.worker = w;
332                    st.pending.insert(lease, p);
333                    None
334                }
335                Offer::NoWorker => Some(p),
336            }
337        };
338        if let Some(p) = failed {
339            let _ = p.done.send(Err(TaskError::retryable(format!(
340                "lease refused by workers: {reason}"
341            ))));
342        }
343        for p in lost {
344            let _ = p
345                .done
346                .send(Err(TaskError::retryable("worker disconnected")));
347        }
348    }
349
350    /// Send `msg` to a worker of `pool` with a free slot, skipping `exclude`. Workers whose
351    /// channel is full are disconnected and the next worker is tried.
352    fn offer_locked(
353        capacity_tx: &CapacityTx,
354        st: &mut State,
355        pool: &Arc<str>,
356        group: &Labels,
357        msg: &pb::Lease,
358        exclude: &[u64],
359        lost: &mut Vec<PendingLease>,
360    ) -> Offer {
361        loop {
362            let start = st.cursor.get(pool).copied().unwrap_or(0);
363            let pick = st
364                .workers
365                .range(start + 1..)
366                .chain(st.workers.range(..=start))
367                .find(|(id, w)| {
368                    &w.pool == pool
369                        && &w.labels == group
370                        && w.busy < w.slots
371                        && !exclude.contains(id)
372                })
373                .map(|(id, w)| (*id, w.tx.clone()));
374            let Some((worker, tx)) = pick else {
375                return Offer::NoWorker;
376            };
377            let out = pb::ServerMessage {
378                msg: Some(pb::server_message::Msg::Lease(msg.clone())),
379            };
380            match tx.try_send(Ok(out)) {
381                Ok(()) => {
382                    st.cursor.insert(pool.clone(), worker);
383                    st.workers.get_mut(&worker).unwrap().busy += 1;
384                    return Offer::Sent(worker);
385                }
386                Err(TrySendError::Full(_)) => Self::evict_locked(capacity_tx, st, worker, lost),
387                Err(TrySendError::Closed(_)) => {
388                    lost.extend(Self::remove_locked(capacity_tx, st, worker))
389                }
390            }
391        }
392    }
393
394    /// Pick a worker with a free slot and send it the lease.
395    fn assign(
396        &self,
397        pool: &Arc<str>,
398        msg: pb::Lease,
399        ctx: TaskContext,
400        done: oneshot::Sender<Result<Bytes, TaskError>>,
401    ) -> Result<(), TaskError> {
402        let lease = msg.lease;
403        let mut lost = Vec::new();
404        let r = {
405            let mut st = self.state.lock().unwrap();
406            if st.closed {
407                return Err(TaskError::retryable("server is shutting down"));
408            }
409            let group = ctx.group.clone();
410            match Self::offer_locked(
411                &self.capacity_tx,
412                &mut st,
413                pool,
414                &group,
415                &msg,
416                &[],
417                &mut lost,
418            ) {
419                Offer::Sent(worker) => {
420                    st.pending.insert(
421                        lease,
422                        PendingLease {
423                            worker,
424                            task: ctx.id,
425                            done,
426                            ctx,
427                            msg,
428                            refused: Vec::new(),
429                        },
430                    );
431                    Ok(())
432                }
433                Offer::NoWorker => Err(TaskError::retryable("no worker with a free slot")),
434            }
435        };
436        for p in lost {
437            let _ = p
438                .done
439                .send(Err(TaskError::retryable("worker disconnected")));
440        }
441        r
442    }
443
444    /// Tell the worker to stop an attempt the engine no longer wants, freeing its slot. The
445    /// Abort is never dropped: a worker that cannot receive it is disconnected.
446    pub fn abort(&self, lease: u64, reason: &str) {
447        let mut lost = Vec::new();
448        {
449            let mut st = self.state.lock().unwrap();
450            let Some(p) = st.pending.remove(&lease) else {
451                return;
452            };
453            let worker = p.worker;
454            let Some(w) = st.workers.get_mut(&worker) else {
455                return;
456            };
457            w.busy = w.busy.saturating_sub(1);
458            let msg = pb::ServerMessage {
459                msg: Some(pb::server_message::Msg::Abort(pb::Abort {
460                    lease,
461                    reason: reason.into(),
462                })),
463            };
464            match w.tx.try_send(Ok(msg)) {
465                Ok(()) => {}
466                Err(TrySendError::Full(_)) => {
467                    Self::evict_locked(&self.capacity_tx, &mut st, worker, &mut lost)
468                }
469                Err(TrySendError::Closed(_)) => {
470                    lost.extend(Self::remove_locked(&self.capacity_tx, &mut st, worker))
471                }
472            }
473        }
474        for p in lost {
475            let _ = p
476                .done
477                .send(Err(TaskError::retryable("worker disconnected")));
478        }
479    }
480
481    /// Stop accepting workers, tell every worker to go away and drop their streams. Streams that
482    /// are not drained within `grace` are cut.
483    pub fn close_all(&self, reason: &str, not_leader: bool, grace: Duration) {
484        let (conns, lost) = {
485            let mut st = self.state.lock().unwrap();
486            st.closed = true;
487            let conns: Vec<WorkerConn> = std::mem::take(&mut st.workers).into_values().collect();
488            let lost: Vec<PendingLease> = st.pending.drain().map(|(_, p)| p).collect();
489            (conns, lost)
490        };
491        for p in lost {
492            let _ = p.done.send(Err(TaskError::retryable(reason.to_string())));
493        }
494        let mut cuts = Vec::new();
495        for c in conns {
496            let msg = pb::ServerMessage {
497                msg: Some(pb::server_message::Msg::Shutdown(pb::Shutdown {
498                    reason: reason.into(),
499                    not_leader,
500                })),
501            };
502            let _ = c.tx.try_send(Ok(msg));
503            cuts.push(c.cut);
504            // Dropping the sender ends the stream once the queued messages are read.
505        }
506        if !cuts.is_empty() {
507            tokio::spawn(async move {
508                tokio::time::sleep(grace).await;
509                for c in cuts {
510                    c.cancel();
511                }
512            });
513        }
514    }
515
516    /// Connected workers.
517    pub fn worker_count(&self) -> usize {
518        self.state.lock().unwrap().workers.len()
519    }
520
521    /// Connected workers in connection order.
522    pub fn list(&self) -> Vec<WorkerSummary> {
523        self.state
524            .lock()
525            .unwrap()
526            .workers
527            .iter()
528            .map(|(id, w)| WorkerSummary {
529                id: *id,
530                name: w.name.clone(),
531                pool: w.pool.clone(),
532                labels: w.labels.clone(),
533                slots: w.slots,
534                busy: w.busy,
535                connected: w.connected,
536            })
537            .collect()
538    }
539
540    /// Connected workers of `pool`.
541    pub fn pool_worker_count(&self, pool: &str) -> usize {
542        self.state
543            .lock()
544            .unwrap()
545            .workers
546            .values()
547            .filter(|w| &*w.pool == pool)
548            .count()
549    }
550}
551
552/// Executor for pools served by remote workers.
553pub struct RemoteExecutor {
554    pool: Arc<str>,
555    lease_ttl: Duration,
556    registry: Arc<WorkerRegistry>,
557    outputs: Arc<OutputCache>,
558}
559
560impl RemoteExecutor {
561    /// Executor for `pool`.
562    pub fn new(
563        pool: Arc<str>,
564        lease_ttl: Duration,
565        registry: Arc<WorkerRegistry>,
566        outputs: Arc<OutputCache>,
567    ) -> Self {
568        RemoteExecutor {
569            pool,
570            lease_ttl,
571            registry,
572            outputs,
573        }
574    }
575}
576
577/// Aborts the remote attempt if the executor future is dropped before the worker finished.
578struct LeaseGuard {
579    registry: Arc<WorkerRegistry>,
580    lease: u64,
581    armed: bool,
582}
583
584impl Drop for LeaseGuard {
585    fn drop(&mut self) {
586        if self.armed {
587            self.registry.abort(self.lease, "aborted");
588        }
589    }
590}
591
592impl Executor<Bytes, Bytes> for RemoteExecutor {
593    fn abort(&self, lease: cronoq::LeaseId) {
594        // Free the worker slot now: the engine may dispatch into it in the same batch.
595        self.registry.abort(lease.0, "aborted");
596    }
597
598    fn execute(
599        &self,
600        ctx: TaskContext,
601        payload: Arc<Bytes>,
602    ) -> BoxFuture<Result<Bytes, TaskError>> {
603        let registry = self.registry.clone();
604        let outputs = self.outputs.clone();
605        let pool = self.pool.clone();
606        let ttl = self.lease_ttl;
607        Box::pin(async move {
608            let msg = pb::Lease {
609                lease: ctx.lease.0,
610                task_id: ctx.id.0,
611                attempt: ctx.attempt,
612                payload: payload.to_vec(),
613                content_type: ctx.tag(CONTENT_TYPE_TAG).unwrap_or_default().to_string(),
614                kind: ctx.kind.to_string(),
615                key: ctx.key.as_deref().unwrap_or_default().to_string(),
616                tags: ctx
617                    .tags
618                    .iter()
619                    .filter(|(k, _)| &**k != CONTENT_TYPE_TAG)
620                    .map(|(k, v)| (k.to_string(), v.to_string()))
621                    .collect(),
622                timeout_s: ctx.timeout.map(|d| d.as_secs_f64()),
623                deadline_unix_ns: ctx.deadline.map(unix_ns),
624                pool: pool.to_string(),
625                // The runtime does not expose the task's priority to executors yet.
626                priority: pb::Priority::Unspecified as i32,
627                lease_ttl_s: Some(ttl.as_secs_f64()),
628            };
629            let (tx, rx) = oneshot::channel();
630            let id = ctx.id;
631            let cancel = ctx.cancellation_token();
632            registry.assign(&pool, msg, ctx.clone(), tx)?;
633            let mut guard = LeaseGuard {
634                registry: registry.clone(),
635                lease: ctx.lease.0,
636                armed: true,
637            };
638            let result = tokio::select! {
639                r = rx => {
640                    guard.armed = false;
641                    r.unwrap_or_else(|_| Err(TaskError::retryable("worker lost")))
642                }
643                _ = cancel.cancelled() => Err(TaskError::Cancelled),
644            };
645            if let Ok(out) = &result {
646                outputs.put(id, out.clone());
647            }
648            result
649        })
650    }
651}
652
653/// Bounded cache of recent outputs (by count and bytes), for `Wait`/`Get` after completion.
654pub struct OutputCache {
655    inner: Mutex<CacheState>,
656    max_entries: usize,
657    max_bytes: usize,
658}
659
660#[derive(Default)]
661struct CacheState {
662    map: FxHashMap<TaskId, Bytes>,
663    order: VecDeque<TaskId>,
664    bytes: usize,
665}
666
667impl OutputCache {
668    /// Cache bounded by entries and bytes.
669    pub fn new(max_entries: usize, max_bytes: usize) -> Arc<Self> {
670        Arc::new(OutputCache {
671            inner: Mutex::new(CacheState::default()),
672            max_entries,
673            max_bytes,
674        })
675    }
676
677    /// Remember an output (outputs larger than the byte budget are not cached).
678    pub fn put(&self, id: TaskId, out: Bytes) {
679        if self.max_entries == 0 || out.len() > self.max_bytes {
680            return;
681        }
682        let mut st = self.inner.lock().unwrap();
683        st.bytes += out.len();
684        if let Some(old) = st.map.insert(id, out) {
685            st.bytes -= old.len();
686        } else {
687            st.order.push_back(id);
688        }
689        while st.map.len() > self.max_entries || st.bytes > self.max_bytes {
690            let Some(victim) = st.order.pop_front() else {
691                break;
692            };
693            if let Some(v) = st.map.remove(&victim) {
694                st.bytes -= v.len();
695            }
696        }
697    }
698
699    /// A cached output.
700    pub fn get(&self, id: TaskId) -> Option<Bytes> {
701        self.inner.lock().unwrap().map.get(&id).cloned()
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn output_cache_is_bounded() {
711        let c = OutputCache::new(2, 10);
712        c.put(TaskId(1), Bytes::from_static(b"aaaa"));
713        c.put(TaskId(2), Bytes::from_static(b"bbbb"));
714        c.put(TaskId(3), Bytes::from_static(b"cc"));
715        assert!(c.get(TaskId(1)).is_none());
716        assert_eq!(c.get(TaskId(3)).unwrap(), Bytes::from_static(b"cc"));
717        c.put(TaskId(4), Bytes::from_static(b"dddddddd"));
718        assert!(c.get(TaskId(2)).is_none(), "byte budget evicts oldest");
719        c.put(TaskId(5), Bytes::from(vec![0u8; 11]));
720        assert!(
721            c.get(TaskId(5)).is_none(),
722            "oversized outputs are not cached"
723        );
724    }
725}