1use 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
26pub type WorkerTx = mpsc::Sender<Result<pb::ServerMessage, tonic::Status>>;
28
29pub 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 labels: Labels,
38 name: String,
39 tx: WorkerTx,
40 slots: u32,
41 busy: u32,
42 cut: CancellationToken,
44 connected: SystemTime,
45}
46
47#[derive(Clone, Debug)]
49pub struct WorkerSummary {
50 pub id: u64,
52 pub name: String,
54 pub pool: Arc<str>,
56 pub labels: Labels,
58 pub slots: u32,
60 pub busy: u32,
62 pub connected: SystemTime,
64}
65
66struct PendingLease {
67 worker: u64,
68 task: TaskId,
69 done: oneshot::Sender<Result<Bytes, TaskError>>,
70 ctx: TaskContext,
71 msg: pb::Lease,
73 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 cursor: FxHashMap<Arc<str>, u64>,
84 closed: bool,
85}
86
87type CapacityTx = OnceLock<mpsc::UnboundedSender<(Arc<str>, Labels, u32)>>;
89
90#[derive(Default)]
92pub struct WorkerRegistry {
93 state: Mutex<State>,
94 capacity_tx: CapacityTx,
96 stale_reports: std::sync::atomic::AtomicU64,
98}
99
100enum Offer {
102 Sent(u64),
103 NoWorker,
104}
105
106impl WorkerRegistry {
107 pub fn new() -> Arc<Self> {
109 Arc::new(WorkerRegistry::default())
110 }
111
112 pub fn stale_reports(&self) -> u64 {
114 self.stale_reports
115 .load(std::sync::atomic::Ordering::Relaxed)
116 }
117
118 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 }
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 pub fn worker_count(&self) -> usize {
518 self.state.lock().unwrap().workers.len()
519 }
520
521 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 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
552pub struct RemoteExecutor {
554 pool: Arc<str>,
555 lease_ttl: Duration,
556 registry: Arc<WorkerRegistry>,
557 outputs: Arc<OutputCache>,
558}
559
560impl RemoteExecutor {
561 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
577struct 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 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 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
653pub 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 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 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 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}