Skip to main content

cronoq_server/
grpc.rs

1//! gRPC services (`cronoq.v1.Tasks`, `cronoq.v1.Workers`, `cronoq.v1.Admin`) and the request
2//! logic shared with the HTTP gateway.
3//!
4//! Every call authenticates first, then checks that this instance is the serving leader
5//! (`UNAVAILABLE` otherwise). Callers whose credentials are restricted to some pools only see
6//! tasks of those pools: other tasks are reported as `NOT_FOUND`.
7
8use std::pin::Pin;
9use std::sync::Arc;
10use std::time::{Duration, SystemTime};
11
12use bytes::Bytes;
13use cronoq::{
14    OverlapPolicy, Recurring, TaskError, TaskFailure, TaskId, TaskRecord, TaskRef, TaskStatus,
15};
16use cronoq_proto::v1 as pb;
17use futures_util::Stream;
18use futures_util::StreamExt;
19use tokio::sync::{broadcast, mpsc};
20use tokio_stream::wrappers::ReceiverStream;
21use tokio_util::sync::CancellationToken;
22use tonic::{Request, Response, Status, Streaming};
23
24use crate::auth::{Principal, check_grpc};
25use crate::config::{LimitsConfig, Role};
26use crate::convert;
27use crate::info::TaskMeta;
28use crate::remote::channel_capacity;
29use crate::state::{AppState, Shared};
30
31/// How long a worker may take to send `Register` after opening its stream.
32const REGISTER_TIMEOUT: Duration = Duration::from_secs(10);
33
34/// Longest recurring-schedule name or cron expression.
35const MAX_SCHEDULE_FIELD: usize = 256;
36
37/// The effective wait bound: the requested timeout capped by `limits.max_wait_s`.
38pub fn wait_bound(requested_s: Option<f64>, limits: &LimitsConfig) -> Result<Duration, Status> {
39    let max = limits.max_wait();
40    Ok(match requested_s {
41        Some(s) => convert::secs(s)?.min(max),
42        None => max,
43    })
44}
45
46fn not_found() -> Status {
47    Status::not_found("unknown task")
48}
49
50/// Refuse a pool the caller may not use.
51pub fn check_pool(principal: &Principal, pool: &str) -> Result<(), Status> {
52    if principal.may_use_pool(pool) {
53        Ok(())
54    } else {
55        Err(Status::permission_denied(format!(
56            "credentials do not allow pool '{pool}'"
57        )))
58    }
59}
60
61/// Refuse a pool that another shard serves, naming that shard.
62pub fn check_pool_shard(app: &AppState, pool: &str) -> Result<(), Status> {
63    match app.shards.as_ref().and_then(|m| m.other_for_pool(pool)) {
64        Some(s) => Err(crate::shard::elsewhere(s, &format!("pool '{pool}'"))),
65        None => Ok(()),
66    }
67}
68
69/// Refuse a task id that another shard issued, naming that shard.
70fn check_id_shard(app: &AppState, target: &TaskRef) -> Result<(), Status> {
71    let TaskRef::Id(id) = target else {
72        return Ok(());
73    };
74    match app.shards.as_ref().and_then(|m| m.other_for_id(*id)) {
75        Some(s) => Err(crate::shard::elsewhere(s, &format!("task {id}"))),
76        None => Ok(()),
77    }
78}
79
80/// Refuse a task for another shard's pool, or one that depends on another shard's task:
81/// dependencies across shards are not supported.
82fn check_spec_shard(app: &AppState, spec: &pb::TaskSpec) -> Result<(), Status> {
83    let Some(map) = &app.shards else {
84        return Ok(());
85    };
86    check_pool_shard(app, convert::spec_pool(spec))?;
87    for r in spec.resources.keys() {
88        if let Some(s) = map.other_for_resource(r) {
89            return Err(Status::invalid_argument(format!(
90                "resource '{r}' belongs to shard '{}': a task can only use its own shard's \
91                 resources",
92                s.name
93            )));
94        }
95    }
96    for d in &spec.deps {
97        if let Some(pb::task_ref::Target::Id(id)) =
98            d.target.as_ref().and_then(|t| t.target.as_ref())
99            && let Some(s) = map.other_for_id(TaskId(*id))
100        {
101            return Err(Status::invalid_argument(format!(
102                "task {id} is in shard '{}': dependencies across shards are not supported",
103                s.name
104            )));
105        }
106    }
107    Ok(())
108}
109
110/// Resolve a reference to a task id (live in the engine, cached, or persisted).
111pub(crate) async fn resolve_id(app: &AppState, target: &TaskRef) -> Result<Option<TaskId>, Status> {
112    match target {
113        TaskRef::Id(id) => Ok(Some(*id)),
114        TaskRef::Key(k) => {
115            if let Some(h) = app
116                .sched
117                .handle(target.clone())
118                .await
119                .map_err(convert::error)?
120            {
121                return Ok(Some(h.id()));
122            }
123            if let Some(id) = app.info.id_for_key(k) {
124                return Ok(Some(TaskId(id)));
125            }
126            match &app.queries {
127                Some(q) => Ok(q.get_task(target).await.map_err(store_error)?.map(|r| r.id)),
128                None => Ok(None),
129            }
130        }
131    }
132}
133
134fn store_error(e: cronoq::StoreError) -> Status {
135    convert::error(cronoq::Error::Store(e))
136}
137
138/// Check that `principal` may see the task `target` refers to, and return the reference to act
139/// on. Unrestricted principals pass without a lookup; restricted ones get `NOT_FOUND` for tasks
140/// of other (or unknown) pools, and the id that was checked, so a key cannot resolve to a
141/// different task between the check and the action.
142pub async fn authorize_task(
143    app: &AppState,
144    principal: &Principal,
145    target: TaskRef,
146) -> Result<TaskRef, Status> {
147    check_id_shard(app, &target)?;
148    if principal.pools.is_none() {
149        return Ok(target);
150    }
151    let id = resolve_id(app, &target).await?.ok_or_else(not_found)?;
152    match app.pool_of(id).await {
153        Some(pool) if principal.may_use_pool(&pool) => Ok(TaskRef::Id(id)),
154        _ => Err(not_found()),
155    }
156}
157
158/// Wait for a task and build its result. Waits at most `timeout` (`DEADLINE_EXCEEDED`), and
159/// ends with `UNAVAILABLE` if the server stops serving first.
160pub async fn wait_result(
161    app: &AppState,
162    target: TaskRef,
163    timeout: Duration,
164    stop: &CancellationToken,
165) -> Result<pb::TaskResult, Status> {
166    let handle = app
167        .sched
168        .handle(target.clone())
169        .await
170        .map_err(convert::error)?;
171    match handle {
172        Some(h) => await_handle(app, h, timeout, stop).await,
173        // Not live in the engine any more: answer from the persisted record if it finished.
174        None => finished_result(app, &target).await?.ok_or_else(not_found),
175    }
176}
177
178async fn finished_result(
179    app: &AppState,
180    target: &TaskRef,
181) -> Result<Option<pb::TaskResult>, Status> {
182    let Some(q) = &app.queries else {
183        return Ok(None);
184    };
185    let Some(r) = q.get_task(target).await.map_err(store_error)? else {
186        return Ok(None);
187    };
188    let Some(status) = r.status.filter(|s| s.is_terminal()) else {
189        return Err(Status::unavailable(
190            "task is queued but not loaded by this leader; retry",
191        ));
192    };
193    let output_available = status == TaskStatus::Succeeded && r.output.is_some();
194    Ok(Some(pb::TaskResult {
195        id: r.id.0,
196        status: convert::status(status) as i32,
197        output: r.output.map(|b| b.to_vec()),
198        message: r.message.unwrap_or_default(),
199        output_available,
200    }))
201}
202
203/// Await a task handle and build its result (see [`wait_result`]).
204pub async fn await_handle(
205    app: &AppState,
206    handle: cronoq::TaskHandle<Bytes>,
207    timeout: Duration,
208    stop: &CancellationToken,
209) -> Result<pb::TaskResult, Status> {
210    let id = handle.id();
211    let outcome = tokio::select! {
212        r = handle => r,
213        _ = tokio::time::sleep(timeout) => {
214            return Err(Status::deadline_exceeded("task is still running; wait again or poll"));
215        }
216        _ = stop.cancelled() => {
217            return Err(Status::unavailable("server is shutting down; wait again on the new leader"));
218        }
219    };
220    Ok(match outcome {
221        Ok(out) => pb::TaskResult {
222            id: id.0,
223            status: pb::TaskStatus::Succeeded as i32,
224            output: Some(out.to_vec()),
225            message: String::new(),
226            output_available: true,
227        },
228        Err(TaskFailure::Ended {
229            status: TaskStatus::Succeeded,
230            ..
231        }) => {
232            let output = app.output(id).await;
233            pb::TaskResult {
234                id: id.0,
235                status: pb::TaskStatus::Succeeded as i32,
236                output_available: output.is_some(),
237                output: output.map(|b| b.to_vec()),
238                message: String::new(),
239            }
240        }
241        Err(TaskFailure::Ended { status, message }) => {
242            // Attaching to an already finished task yields a generic message; the cache keeps
243            // the real one from the terminal event.
244            let cached = app
245                .info
246                .get(id.0)
247                .filter(|m| m.status.is_some_and(|s| s.is_terminal()))
248                .and_then(|m| m.message)
249                .filter(|m| !m.is_empty());
250            let message = cached
251                .map(|m| m.to_string())
252                .unwrap_or_else(|| message.to_string());
253            pb::TaskResult {
254                id: id.0,
255                status: convert::status(status) as i32,
256                output: None,
257                message,
258                output_available: false,
259            }
260        }
261        // The task succeeded earlier but its output is no longer held by the runtime; fall
262        // back to the server's output cache and the store.
263        Err(TaskFailure::OutputUnavailable { .. }) => {
264            let output = app.output(id).await;
265            pb::TaskResult {
266                id: id.0,
267                status: pb::TaskStatus::Succeeded as i32,
268                output_available: output.is_some(),
269                output: output.map(|b| b.to_vec()),
270                message: String::new(),
271            }
272        }
273        Err(TaskFailure::Shutdown) => {
274            return Err(Status::unavailable(
275                "scheduler shut down before the task finished; wait again on the new leader",
276            ));
277        }
278        Err(e) => return Err(Status::internal(e.to_string())),
279    })
280}
281
282fn record_submit(app: &AppState, id: TaskId, spec: &pb::TaskSpec) {
283    let pool = convert::spec_pool(spec);
284    let max_attempts = match &spec.retry {
285        Some(r) => r.max_attempts.max(1),
286        None => app.pool(pool).map(|p| p.max_attempts).unwrap_or(0),
287    };
288    app.info.on_submit(
289        id.0,
290        TaskMeta {
291            pool: Arc::from(pool),
292            key: (!spec.key.is_empty()).then(|| Arc::from(spec.key.as_str())),
293            kind: Some(Arc::from(if spec.kind.is_empty() {
294                "default"
295            } else {
296                spec.kind.as_str()
297            })),
298            max_attempts,
299            created_ns: Some(convert::unix_ns(SystemTime::now())),
300            tenant: (!spec.tenant.is_empty()).then(|| Arc::from(spec.tenant.as_str())),
301            priority: convert::priority(spec.priority).ok(),
302            ..Default::default()
303        },
304    );
305}
306
307/// For pool-restricted callers, refuse task keys and dependencies that belong to tasks of
308/// other pools: a duplicate key would otherwise hand back that task's id and result, and a
309/// dependency would expose its outcome. Keys the same batch introduces (`batch_keys`, which
310/// excludes keys of existing tasks) and dependencies on keys that do not exist yet are allowed.
311async fn check_cross_pool(
312    app: &AppState,
313    principal: &Principal,
314    spec: &pb::TaskSpec,
315    batch_keys: &[&str],
316) -> Result<(), Status> {
317    if principal.pools.is_none() {
318        return Ok(());
319    }
320    if !spec.key.is_empty() && !batch_keys.contains(&spec.key.as_str()) {
321        let key = TaskRef::Key(spec.key.as_str().into());
322        if let Some(id) = resolve_id(app, &key).await? {
323            check_duplicate(app, principal, id).await?;
324        }
325    }
326    for d in &spec.deps {
327        let target = convert::task_ref(d.target.clone(), &app.limits)?;
328        if let TaskRef::Key(k) = &target
329            && batch_keys.contains(&&**k)
330        {
331            continue;
332        }
333        if let Some(id) = resolve_id(app, &target).await? {
334            match app.pool_of(id).await {
335                Some(pool) if principal.may_use_pool(&pool) => {}
336                _ => return Err(Status::not_found("unknown dependency")),
337            }
338        }
339    }
340    Ok(())
341}
342
343/// Credentials with a tenant submit for that tenant only: fill it in, or refuse another one.
344fn pin_tenant(principal: &Principal, spec: &mut pb::TaskSpec) -> Result<(), Status> {
345    let Some(t) = &principal.tenant else {
346        return Ok(());
347    };
348    if spec.tenant.is_empty() {
349        spec.tenant = t.to_string();
350        Ok(())
351    } else if spec.tenant == **t {
352        Ok(())
353    } else {
354        Err(Status::permission_denied(format!(
355            "credentials do not allow tenant '{}'",
356            spec.tenant
357        )))
358    }
359}
360
361/// A duplicate submission must not reveal a task of a pool the caller may not use.
362async fn check_duplicate(app: &AppState, principal: &Principal, id: TaskId) -> Result<(), Status> {
363    if principal.pools.is_none() {
364        return Ok(());
365    }
366    match app.pool_of(id).await {
367        Some(pool) if principal.may_use_pool(&pool) => Ok(()),
368        _ => Err(Status::already_exists("task key is already in use")),
369    }
370}
371
372/// Validate and submit one task, optionally waiting (bounded by `wait_timeout`) for its result.
373pub async fn submit_one(
374    app: &AppState,
375    principal: &Principal,
376    mut spec: pb::TaskSpec,
377    wait: Option<Duration>,
378    stop: &CancellationToken,
379) -> Result<pb::SubmitResponse, Status> {
380    pin_tenant(principal, &mut spec)?;
381    check_pool(principal, convert::spec_pool(&spec))?;
382    check_spec_shard(app, &spec)?;
383    check_cross_pool(app, principal, &spec, &[]).await?;
384    let meta = pb::TaskSpec {
385        payload: Vec::new(),
386        ..spec.clone()
387    };
388    let task = convert::task(spec, &app.limits)?;
389    match wait {
390        Some(timeout) => {
391            let h = app.sched.submit(task).await.map_err(convert::error)?;
392            let (id, duplicate) = (h.id(), h.is_duplicate());
393            if duplicate {
394                // The key may have been taken in another pool since the check above.
395                check_duplicate(app, principal, id).await?;
396            } else {
397                record_submit(app, id, &meta);
398            }
399            let result = await_handle(app, h, timeout, stop).await?;
400            Ok(pb::SubmitResponse {
401                id: id.0,
402                duplicate,
403                result: Some(result),
404            })
405        }
406        None => {
407            let s = app.sched.enqueue(task).await.map_err(convert::error)?;
408            if s.duplicate {
409                check_duplicate(app, principal, s.id).await?;
410            } else {
411                record_submit(app, s.id, &meta);
412            }
413            Ok(pb::SubmitResponse {
414                id: s.id.0,
415                duplicate: s.duplicate,
416                result: None,
417            })
418        }
419    }
420}
421
422/// Validate and submit tasks atomically.
423pub async fn submit_batch(
424    app: &AppState,
425    principal: &Principal,
426    mut specs: Vec<pb::TaskSpec>,
427) -> Result<pb::SubmitBatchResponse, Status> {
428    let max = app.limits.max_tasks_per_batch;
429    if specs.len() > max {
430        return Err(Status::invalid_argument(format!(
431            "at most {max} tasks per batch"
432        )));
433    }
434    for s in &mut specs {
435        pin_tenant(principal, s)?;
436        check_pool(principal, convert::spec_pool(s))?;
437        // A batch is atomic in one shard's store, so every task must be this shard's.
438        check_spec_shard(app, s)?;
439    }
440    // Only keys the batch introduces count as batch-local: a key that already names a task
441    // (possibly of another pool) is checked like any other duplicate or dependency, before
442    // anything is submitted.
443    let mut batch_keys: Vec<&str> = Vec::new();
444    if principal.pools.is_some() {
445        for s in specs.iter().filter(|s| !s.key.is_empty()) {
446            let key = TaskRef::Key(s.key.as_str().into());
447            if resolve_id(app, &key).await?.is_none() {
448                batch_keys.push(&s.key);
449            }
450        }
451    }
452    for spec in &specs {
453        check_pool(principal, convert::spec_pool(spec))?;
454        check_cross_pool(app, principal, spec, &batch_keys).await?;
455    }
456    let mut metas = Vec::with_capacity(specs.len());
457    let mut tasks = Vec::with_capacity(specs.len());
458    for spec in specs {
459        metas.push(pb::TaskSpec {
460            payload: Vec::new(),
461            ..spec.clone()
462        });
463        tasks.push(convert::task(spec, &app.limits)?);
464    }
465    let hs = app
466        .sched
467        .submit_batch(tasks)
468        .await
469        .map_err(convert::error)?;
470    for (h, m) in hs.iter().zip(&metas) {
471        if h.is_duplicate() {
472            check_duplicate(app, principal, h.id()).await?;
473        } else {
474            record_submit(app, h.id(), m);
475        }
476    }
477    Ok(pb::SubmitBatchResponse {
478        tasks: hs
479            .iter()
480            .map(|h| pb::SubmitResponse {
481                id: h.id().0,
482                duplicate: h.is_duplicate(),
483                result: None,
484            })
485            .collect(),
486    })
487}
488
489/// Details of a task (`NOT_FOUND` if it is unknown).
490pub async fn task_info(app: &AppState, target: TaskRef) -> Result<pb::TaskInfo, Status> {
491    let id = resolve_id(app, &target).await?.ok_or_else(not_found)?;
492    let live = app
493        .sched
494        .status(TaskRef::Id(id))
495        .await
496        .map_err(convert::error)?;
497    let meta = app.info.get(id.0);
498    let rec = match (&app.queries, live.is_some() && meta.is_some()) {
499        (Some(q), false) => q.get_task(&TaskRef::Id(id)).await.map_err(store_error)?,
500        _ => None,
501    };
502    if live.is_none() && meta.as_ref().and_then(|m| m.status).is_none() && rec.is_none() {
503        return Err(not_found());
504    }
505    let status = live
506        .or_else(|| meta.as_ref().and_then(|m| m.status))
507        .or_else(|| rec.as_ref().and_then(|r| r.status))
508        // Persisted without a status = queued.
509        .unwrap_or(TaskStatus::Ready);
510    Ok(info_message(app, id, status, meta.unwrap_or_default(), rec))
511}
512
513/// Build a `TaskInfo` from what the cache (`meta`) and the store (`rec`) know; the cache wins.
514fn info_message(
515    app: &AppState,
516    id: TaskId,
517    status: TaskStatus,
518    meta: TaskMeta,
519    rec: Option<TaskRecord<Bytes>>,
520) -> pb::TaskInfo {
521    let output_available = status == TaskStatus::Succeeded
522        && (app.outputs.get(id).is_some() || rec.as_ref().is_some_and(|r| r.output.is_some()));
523    let pool = if meta.pool.is_empty() {
524        rec.as_ref().map(|r| r.pool.clone()).unwrap_or_default()
525    } else {
526        meta.pool.to_string()
527    };
528    let text = |m: Option<Arc<str>>, r: Option<String>| {
529        m.as_deref().map(str::to_string).or(r).unwrap_or_default()
530    };
531    let rec_key = rec.as_ref().and_then(|r| r.key.clone());
532    let rec_kind = rec.as_ref().map(|r| r.kind.clone());
533    let rec_tenant = rec.as_ref().and_then(|r| r.tenant.clone());
534    let priority = meta.priority.or_else(|| rec.as_ref().map(|r| r.priority));
535    let attempt = meta
536        .attempt
537        .max(rec.as_ref().map(|r| r.attempt).unwrap_or(0));
538    pb::TaskInfo {
539        id: id.0,
540        status: convert::status(status) as i32,
541        pool,
542        key: text(meta.key, rec_key),
543        kind: text(meta.kind, rec_kind),
544        attempt,
545        max_attempts: meta.max_attempts,
546        created_unix_ns: meta.created_ns,
547        started_unix_ns: meta.started_ns,
548        finished_unix_ns: meta.finished_ns,
549        message: text(meta.message, rec.and_then(|r| r.message)),
550        output_available,
551        tenant: text(meta.tenant, rec_tenant),
552        priority: priority.map_or(pb::Priority::Unspecified, convert::priority_to_pb) as i32,
553    }
554}
555
556/// Default `ListTasks` page size.
557const DEFAULT_LIST: usize = 100;
558
559/// The page size for a requested `limit`: 0 means the default, and `limits.max_list` caps it.
560pub(crate) fn list_limit(app: &AppState, requested: u32) -> usize {
561    match requested {
562        0 => DEFAULT_LIST,
563        n => n as usize,
564    }
565    .min(app.limits.max_list)
566}
567
568/// The pools a listing covers: the requested ones (each checked), else all the caller may see.
569/// `None` means every pool.
570fn listing_pools(
571    app: &AppState,
572    principal: &Principal,
573    requested: Vec<String>,
574) -> Result<Option<Vec<String>>, Status> {
575    if requested.len() > app.limits.max_tasks_per_batch {
576        return Err(Status::invalid_argument("too many pools in the filter"));
577    }
578    for p in &requested {
579        convert::check_str("pool", p, 64)?;
580        if app.pool(p).is_none() {
581            check_pool(principal, p)?;
582            check_pool_shard(app, p)?;
583            return Err(Status::not_found(format!("unknown pool '{p}'")));
584        }
585        check_pool(principal, p)?;
586    }
587    Ok(match (&principal.pools, requested.is_empty()) {
588        (_, false) => Some(requested),
589        (Some(ps), true) => Some(ps.iter().map(|p| p.to_string()).collect()),
590        (None, true) => None,
591    })
592}
593
594/// One page of tasks, live and finished, in id order.
595pub async fn list_tasks(
596    app: &AppState,
597    principal: &Principal,
598    r: pb::ListTasksRequest,
599) -> Result<pb::ListTasksResponse, Status> {
600    let pools = listing_pools(app, principal, r.pools)?;
601    let pools: Vec<&str> = match &pools {
602        // Credentials allowing no pool see nothing (an empty list would mean every pool).
603        Some(v) if v.is_empty() => return Ok(pb::ListTasksResponse::default()),
604        Some(v) => v.iter().map(String::as_str).collect(),
605        None => Vec::new(),
606    };
607    let mut statuses = Vec::new();
608    for s in r.statuses {
609        let s = convert::status_from_pb(s)?;
610        if !statuses.contains(&s) {
611            statuses.push(s);
612        }
613    }
614    let (finished, live): (Vec<TaskStatus>, Vec<TaskStatus>) =
615        statuses.iter().partition(|s| s.is_terminal());
616    let limit = list_limit(app, r.limit);
617    let after = (r.after > 0).then_some(TaskId(r.after));
618    // One more than a page from each source tells whether another page follows.
619    let mut page: std::collections::BTreeMap<u64, pb::TaskInfo> = Default::default();
620    if statuses.is_empty() || !live.is_empty() {
621        for t in app
622            .sched
623            .list_tasks(&pools, &live, after, limit + 1)
624            .await
625            .map_err(convert::error)?
626        {
627            let meta = app.info.get(t.id.0).unwrap_or_default();
628            let rec = TaskRecord {
629                id: t.id,
630                key: t.key.as_deref().map(str::to_string),
631                pool: t.pool.to_string(),
632                kind: t.kind.to_string(),
633                priority: t.priority,
634                tenant: t.tenant.as_deref().map(str::to_string),
635                status: Some(t.status),
636                attempt: t.attempt,
637                message: None,
638                output: None,
639                updated: SystemTime::UNIX_EPOCH,
640            };
641            page.insert(t.id.0, info_message(app, t.id, t.status, meta, Some(rec)));
642        }
643    }
644    if statuses.is_empty() || !finished.is_empty() {
645        // A task that finished while the live page was read is listed as finished.
646        match &app.queries {
647            Some(q) => {
648                for rec in q
649                    .list_finished(&pools, &finished, after, limit + 1)
650                    .await
651                    .map_err(store_error)?
652                {
653                    let (id, status) = (rec.id, rec.status.unwrap_or(TaskStatus::Ready));
654                    let meta = app.info.get(id.0).unwrap_or_default();
655                    page.insert(id.0, info_message(app, id, status, meta, Some(rec)));
656                }
657            }
658            None => {
659                for (id, meta) in app
660                    .info
661                    .list_finished(&pools, &finished, r.after, limit + 1)
662                {
663                    let status = meta.status.unwrap_or(TaskStatus::Ready);
664                    page.insert(id, info_message(app, TaskId(id), status, meta, None));
665                }
666            }
667        }
668    }
669    let more = page.len() > limit;
670    let tasks: Vec<pb::TaskInfo> = page.into_values().take(limit).collect();
671    let next_after = if more {
672        tasks.last().map_or(0, |t| t.id)
673    } else {
674        0
675    };
676    Ok(pb::ListTasksResponse {
677        tasks,
678        next_after,
679        unavailable_shards: Vec::new(),
680    })
681}
682
683/// Connected workers of the pools the caller may see.
684pub fn list_workers(app: &AppState, principal: &Principal) -> pb::ListWorkersResponse {
685    let workers = app
686        .registry
687        .list()
688        .into_iter()
689        .filter(|w| principal.may_use_pool(&w.pool))
690        .map(|w| pb::WorkerInfo {
691            id: w.id,
692            name: w.name,
693            pool: w.pool.to_string(),
694            labels: w
695                .labels
696                .iter()
697                .map(|(k, v)| (k.to_string(), v.to_string()))
698                .collect(),
699            slots: w.slots,
700            busy: w.busy,
701            connected_unix_ns: convert::unix_ns(w.connected),
702        })
703        .collect();
704    pb::ListWorkersResponse {
705        workers,
706        unavailable_shards: Vec::new(),
707    }
708}
709
710/// Recurring schedules of the pools the caller may see, by name.
711pub async fn list_recurring(
712    app: &AppState,
713    principal: &Principal,
714) -> Result<pb::ListRecurringResponse, Status> {
715    let clock = *app.sched.clock();
716    let mut schedules: Vec<pb::RecurringInfo> = app
717        .sched
718        .list_recurring()
719        .await
720        .map_err(convert::error)?
721        .into_iter()
722        .filter(|r| principal.may_use_pool(&r.pool))
723        .map(|r| pb::RecurringInfo {
724            name: r.name.to_string(),
725            cron: r.cron.to_string(),
726            tz: r.tz.as_deref().unwrap_or_default().to_string(),
727            pool: r.pool.to_string(),
728            kind: r.kind.to_string(),
729            overlap: convert::overlap_to_pb(r.overlap) as i32,
730            next_unix_ns: r.next.map(|t| convert::unix_ns(clock.to_system(t))),
731            last_task_id: r.last_task.map(|t| t.0),
732        })
733        .collect();
734    schedules.sort_by(|a, b| a.name.cmp(&b.name));
735    Ok(pb::ListRecurringResponse {
736        schedules,
737        unavailable_shards: Vec::new(),
738    })
739}
740
741/// Cancel a task (`false` if it already finished).
742pub async fn cancel(app: &AppState, target: TaskRef) -> Result<bool, Status> {
743    app.sched.cancel(target).await.map_err(convert::error)
744}
745
746/// Which status events a Watch stream delivers.
747#[derive(Clone, Debug)]
748pub struct WatchFilter {
749    /// Pools (empty = all the caller may see).
750    pub pools: Vec<String>,
751    /// Task ids (empty = any).
752    pub ids: Vec<u64>,
753    /// Task keys (empty = any).
754    pub keys: Vec<String>,
755    /// Deliver non-terminal transitions too.
756    pub all_transitions: bool,
757}
758
759/// Start streaming status events. The stream ends with `UNAVAILABLE` when the server stops
760/// serving and with `DATA_LOSS` if the client falls behind.
761pub fn watch(
762    app: &Arc<AppState>,
763    principal: &Principal,
764    filter: WatchFilter,
765    stop: CancellationToken,
766) -> Result<mpsc::Receiver<Result<pb::StatusEvent, Status>>, Status> {
767    for p in &filter.pools {
768        convert::check_str("pool", p, 64)?;
769        if app.pool(p).is_none() {
770            check_pool(principal, p)?;
771            check_pool_shard(app, p)?;
772            return Err(Status::not_found(format!("unknown pool '{p}'")));
773        }
774        check_pool(principal, p)?;
775    }
776    if filter.ids.len() + filter.keys.len() > app.limits.max_tasks_per_batch {
777        return Err(Status::invalid_argument("too many ids/keys in the filter"));
778    }
779    for k in &filter.keys {
780        convert::check_str("key", k, app.limits.max_key_len)?;
781    }
782    let allowed: Option<Vec<String>> = match (&principal.pools, filter.pools.is_empty()) {
783        (_, false) => Some(filter.pools.clone()),
784        (Some(ps), true) => Some(ps.iter().map(|p| p.to_string()).collect()),
785        (None, true) => None,
786    };
787    let names: Arc<[Arc<str>]> = app.sched.pools().into();
788    let clock = *app.sched.clock();
789    let mut events = app.sched.events();
790    let (tx, rx) = mpsc::channel(256);
791    tokio::spawn(async move {
792        loop {
793            let ev = tokio::select! {
794                e = events.recv() => e,
795                _ = stop.cancelled() => {
796                    let _ = tx.send(Err(Status::unavailable("server is shutting down; reconnect to the leader"))).await;
797                    return;
798                }
799                _ = tx.closed() => return,
800            };
801            let e = match ev {
802                Ok(e) => e,
803                Err(broadcast::error::RecvError::Lagged(n)) => {
804                    let _ = tx
805                        .send(Err(Status::data_loss(format!(
806                            "watch fell behind; {n} events dropped"
807                        ))))
808                        .await;
809                    return;
810                }
811                Err(broadcast::error::RecvError::Closed) => {
812                    let _ = tx.send(Err(Status::unavailable("scheduler stopped"))).await;
813                    return;
814                }
815            };
816            if !filter.all_transitions && !e.status.is_terminal() {
817                continue;
818            }
819            if !filter.ids.is_empty() && !filter.ids.contains(&e.id.0) {
820                continue;
821            }
822            if !filter.keys.is_empty()
823                && !e
824                    .key
825                    .as_deref()
826                    .is_some_and(|k| filter.keys.iter().any(|f| f == k))
827            {
828                continue;
829            }
830            let ev = convert::event(&e, &names, &clock);
831            if allowed.as_ref().is_some_and(|ps| !ps.contains(&ev.pool)) {
832                continue;
833            }
834            if tx.send(Ok(ev)).await.is_err() {
835                return;
836            }
837        }
838    });
839    Ok(rx)
840}
841
842/// `cronoq.v1.Tasks`.
843pub struct TasksService {
844    /// Server state.
845    pub shared: Arc<Shared>,
846}
847
848type EventStream = Pin<Box<dyn Stream<Item = Result<pb::StatusEvent, Status>> + Send>>;
849
850#[tonic::async_trait]
851impl pb::tasks_server::Tasks for TasksService {
852    async fn submit(
853        &self,
854        req: Request<pb::SubmitRequest>,
855    ) -> Result<Response<pb::SubmitResponse>, Status> {
856        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
857        let app = self.shared.app_or_unavailable()?;
858        let r = req.into_inner();
859        let spec = r
860            .task
861            .ok_or_else(|| Status::invalid_argument("missing task"))?;
862        let wait = if r.wait {
863            Some(wait_bound(r.wait_timeout_s, &app.limits)?)
864        } else {
865            None
866        };
867        Ok(Response::new(
868            crate::route::submit(app, &p, spec, wait, &self.shared.stop).await?,
869        ))
870    }
871
872    async fn submit_batch(
873        &self,
874        req: Request<pb::SubmitBatchRequest>,
875    ) -> Result<Response<pb::SubmitBatchResponse>, Status> {
876        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
877        let app = self.shared.app_or_unavailable()?;
878        Ok(Response::new(
879            crate::route::submit_batch(app, &p, req.into_inner().tasks).await?,
880        ))
881    }
882
883    async fn cancel(
884        &self,
885        req: Request<pb::CancelRequest>,
886    ) -> Result<Response<pb::CancelResponse>, Status> {
887        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
888        let app = self.shared.app_or_unavailable()?;
889        let target = convert::task_ref(req.into_inner().task, &app.limits)?;
890        Ok(Response::new(pb::CancelResponse {
891            cancelled: crate::route::cancel(app, &p, target).await?,
892        }))
893    }
894
895    async fn get(&self, req: Request<pb::GetRequest>) -> Result<Response<pb::TaskInfo>, Status> {
896        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
897        let app = self.shared.app_or_unavailable()?;
898        let target = convert::task_ref(req.into_inner().task, &app.limits)?;
899        let (info, missing) = crate::route::get(app, &p, target).await?;
900        Ok(crate::route::with_missing(info, &missing))
901    }
902
903    async fn list_tasks(
904        &self,
905        req: Request<pb::ListTasksRequest>,
906    ) -> Result<Response<pb::ListTasksResponse>, Status> {
907        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
908        let app = self.shared.app_or_unavailable()?;
909        Ok(Response::new(
910            crate::route::list_tasks(app, &p, req.into_inner()).await?,
911        ))
912    }
913
914    async fn wait(
915        &self,
916        req: Request<pb::WaitRequest>,
917    ) -> Result<Response<pb::TaskResult>, Status> {
918        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
919        let app = self.shared.app_or_unavailable()?;
920        let r = req.into_inner();
921        let target = convert::task_ref(r.task, &app.limits)?;
922        let timeout = wait_bound(r.timeout_s, &app.limits)?;
923        let (result, missing) =
924            crate::route::wait(app, &p, target, timeout, &self.shared.stop).await?;
925        Ok(crate::route::with_missing(result, &missing))
926    }
927
928    type WatchStream = EventStream;
929
930    async fn watch(
931        &self,
932        req: Request<pb::WatchRequest>,
933    ) -> Result<Response<Self::WatchStream>, Status> {
934        let p = check_grpc(&self.shared.auth, &req, Role::Client)?;
935        let app = self.shared.app_or_unavailable()?;
936        let r = req.into_inner();
937        let filter = WatchFilter {
938            pools: r.pools,
939            ids: r.ids,
940            keys: r.keys,
941            all_transitions: r.all_transitions,
942        };
943        let rx = crate::route::watch(app, &p, filter, self.shared.stop.clone())?;
944        Ok(Response::new(Box::pin(ReceiverStream::new(rx))))
945    }
946}
947
948/// `cronoq.v1.Workers`.
949pub struct WorkersService {
950    /// Server state.
951    pub shared: Arc<Shared>,
952}
953
954type ServerStream = Pin<Box<dyn Stream<Item = Result<pb::ServerMessage, Status>> + Send>>;
955
956#[tonic::async_trait]
957impl pb::workers_server::Workers for WorkersService {
958    type WorkStream = ServerStream;
959
960    async fn work(
961        &self,
962        req: Request<Streaming<pb::WorkerMessage>>,
963    ) -> Result<Response<Self::WorkStream>, Status> {
964        let principal = check_grpc(&self.shared.auth, &req, Role::Worker)?;
965        let app = self.shared.app_or_unavailable()?.clone();
966        let mut inbound = req.into_inner();
967        let first = tokio::time::timeout(REGISTER_TIMEOUT, inbound.message())
968            .await
969            .map_err(|_| Status::deadline_exceeded("expected Register within 10s"))??
970            .and_then(|m| m.msg);
971        let Some(pb::worker_message::Msg::Register(reg)) = first else {
972            return Err(Status::invalid_argument(
973                "expected Register as first message",
974            ));
975        };
976        if reg.protocol_version.max(1) < cronoq_proto::PROTOCOL_VERSION {
977            return Err(Status::failed_precondition(format!(
978                "worker protocol version {} is not supported: this server needs version {} \
979                 (upgrade the worker)",
980                reg.protocol_version.max(1),
981                cronoq_proto::PROTOCOL_VERSION
982            )));
983        }
984        convert::check_str("pool", &reg.pool, 64)?;
985        let labels = convert::worker_labels(reg.labels, &app.limits)?;
986        if !principal.may_claim_labels(&labels) {
987            return Err(Status::permission_denied(
988                "credentials do not allow this worker's labels",
989            ));
990        }
991        let Some(info) = app.pool(&reg.pool).cloned() else {
992            check_pool(&principal, &reg.pool)?;
993            // Workers of another shard's pool are sent to that shard's leader.
994            check_pool_shard(&app, &reg.pool)?;
995            return Err(Status::not_found(format!("unknown pool '{}'", reg.pool)));
996        };
997        check_pool(&principal, &reg.pool)?;
998        let pool: Arc<str> = app
999            .pools
1000            .get_key_value(reg.pool.as_str())
1001            .map(|(k, _)| k.clone())
1002            .unwrap();
1003        let name = convert::sanitize_name(&reg.worker_name);
1004        let slots = reg.slots.min(info.slot_cap);
1005        let (tx, rx) = mpsc::channel(channel_capacity(info.slot_cap));
1006        let registry = app.registry.clone();
1007        let worker = registry.next_worker_id();
1008        // The ack is queued before the worker becomes eligible for leases.
1009        let _ = tx.try_send(Ok(pb::ServerMessage {
1010            msg: Some(pb::server_message::Msg::Registered(pb::Registered {
1011                worker_id: worker,
1012                lease_ttl_s: Some(info.lease_ttl.as_secs_f64()),
1013                slots,
1014                protocol_version: cronoq_proto::PROTOCOL_VERSION,
1015                max_message_bytes: app.limits.max_message_bytes as u64,
1016            })),
1017        }));
1018        let cut = CancellationToken::new();
1019        if !registry.register(
1020            worker,
1021            pool.clone(),
1022            labels.clone(),
1023            name.clone(),
1024            slots,
1025            tx,
1026            cut.clone(),
1027        ) {
1028            return Err(Status::unavailable("server is shutting down"));
1029        }
1030        if slots < reg.slots {
1031            tracing::info!(worker, pool = %pool, requested = reg.slots, granted = slots, "worker slots capped");
1032        }
1033        tracing::info!(worker, pool = %pool, slots, name = ?name, labels = ?labels, "worker registered");
1034        let slot_cap = info.slot_cap;
1035        let inbound_cut = cut.clone();
1036        tokio::spawn(async move {
1037            loop {
1038                let msg = tokio::select! {
1039                    m = inbound.message() => m,
1040                    _ = inbound_cut.cancelled() => break,
1041                };
1042                let msg = match msg {
1043                    Ok(Some(m)) => m,
1044                    Ok(None) => break,
1045                    Err(e) => {
1046                        tracing::debug!(worker, error = %e, "worker stream error");
1047                        break;
1048                    }
1049                };
1050                use pb::worker_message::Msg;
1051                match msg.msg {
1052                    Some(Msg::Heartbeat(h)) => registry.heartbeat(worker, h.lease),
1053                    Some(Msg::Complete(c)) => {
1054                        registry.finish(worker, c.lease, Ok(Bytes::from(c.output)));
1055                    }
1056                    Some(Msg::Fail(f)) => {
1057                        let message = convert::sanitize_message(&f.message);
1058                        let err = if f.permanent {
1059                            TaskError::permanent(message)
1060                        } else {
1061                            TaskError::Retryable {
1062                                message: message.into(),
1063                                retry_after: f.retry_after_s.and_then(|s| convert::secs(s).ok()),
1064                            }
1065                        };
1066                        registry.finish(worker, f.lease, Err(err));
1067                    }
1068                    Some(Msg::SetSlots(s)) => registry.set_slots(worker, s.slots.min(slot_cap)),
1069                    Some(Msg::Nack(n)) => {
1070                        registry.nack(worker, n.lease, &convert::sanitize_message(&n.reason))
1071                    }
1072                    Some(Msg::Cancelled(c)) => {
1073                        registry.finish(worker, c.lease, Err(TaskError::Cancelled));
1074                    }
1075                    Some(Msg::Register(_)) => {
1076                        registry.reject(
1077                            worker,
1078                            Status::invalid_argument("duplicate Register on one stream"),
1079                        );
1080                        break;
1081                    }
1082                    None => {}
1083                }
1084            }
1085            registry.unregister(worker);
1086            tracing::info!(worker, "worker disconnected");
1087        });
1088        let out = ReceiverStream::new(rx).take_until(cut.cancelled_owned());
1089        Ok(Response::new(Box::pin(out)))
1090    }
1091}
1092
1093/// `cronoq.v1.Admin`.
1094pub struct AdminService {
1095    /// Server state.
1096    pub shared: Arc<Shared>,
1097}
1098
1099/// The scheduler snapshot as a protobuf message.
1100pub async fn snapshot(app: &AppState) -> Result<pb::SnapshotResponse, Status> {
1101    let s = app.sched.snapshot().await.map_err(convert::error)?;
1102    let registry = app.registry.clone();
1103    let mut r = convert::snapshot(&s, &|p| registry.pool_worker_count(p));
1104    // Remote workers' stale reports are dropped before they reach the engine.
1105    if let Some(t) = &mut r.totals {
1106        t.stale_completions += registry.stale_reports();
1107    }
1108    Ok(r)
1109}
1110
1111/// Checks of a schedule's own fields, before it is routed to its shard.
1112fn check_recurring(r: &pb::RecurringRequest) -> Result<(), Status> {
1113    convert::check_str("name", &r.name, MAX_SCHEDULE_FIELD)?;
1114    convert::check_str("cron", &r.cron, MAX_SCHEDULE_FIELD)?;
1115    convert::check_str("tz", &r.tz, 64)?;
1116    if r.name.is_empty() {
1117        return Err(Status::invalid_argument("recurring schedule needs a name"));
1118    }
1119    Ok(())
1120}
1121
1122/// Add (or replace) a schedule on this shard.
1123pub async fn add_recurring(app: &AppState, r: pb::RecurringRequest) -> Result<(), Status> {
1124    check_recurring(&r)?;
1125    let template = r.template.unwrap_or_default();
1126    check_spec_shard(app, &template)?;
1127    let template = convert::task(template, &app.limits)?;
1128    let overlap = match pb::Overlap::try_from(r.overlap) {
1129        Ok(pb::Overlap::Unspecified | pb::Overlap::Skip) => OverlapPolicy::Skip,
1130        Ok(pb::Overlap::Allow) => OverlapPolicy::Allow,
1131        Ok(pb::Overlap::Queue) => OverlapPolicy::Queue,
1132        Ok(pb::Overlap::QueueOnSuccess) => OverlapPolicy::QueueOnSuccess,
1133        Err(_) => return Err(Status::invalid_argument("unknown overlap policy")),
1134    };
1135    let mut rec = Recurring::new(r.name, r.cron, template).overlap(overlap);
1136    if !r.tz.is_empty() {
1137        rec = rec.tz(r.tz);
1138    }
1139    app.sched.add_recurring(rec).await.map_err(convert::error)
1140}
1141
1142/// Remove a schedule from this shard.
1143pub async fn remove_recurring(app: &AppState, name: &str) -> Result<bool, Status> {
1144    app.sched
1145        .remove_recurring(name)
1146        .await
1147        .map_err(convert::error)
1148}
1149
1150#[tonic::async_trait]
1151impl pb::admin_server::Admin for AdminService {
1152    async fn snapshot(
1153        &self,
1154        req: Request<pb::Empty>,
1155    ) -> Result<Response<pb::SnapshotResponse>, Status> {
1156        let p = check_grpc(&self.shared.auth, &req, Role::Metrics)?;
1157        let app = self.shared.app_or_unavailable()?;
1158        Ok(Response::new(crate::route::snapshot(app, &p).await?))
1159    }
1160
1161    async fn add_recurring(
1162        &self,
1163        req: Request<pb::RecurringRequest>,
1164    ) -> Result<Response<pb::Empty>, Status> {
1165        let p = check_grpc(&self.shared.auth, &req, Role::Admin)?;
1166        let app = self.shared.app_or_unavailable()?;
1167        let r = req.into_inner();
1168        check_recurring(&r)?;
1169        crate::route::add_recurring(app, &p, r).await?;
1170        Ok(Response::new(pb::Empty {}))
1171    }
1172
1173    async fn remove_recurring(
1174        &self,
1175        req: Request<pb::RemoveRecurringRequest>,
1176    ) -> Result<Response<pb::RemoveRecurringResponse>, Status> {
1177        let p = check_grpc(&self.shared.auth, &req, Role::Admin)?;
1178        let app = self.shared.app_or_unavailable()?;
1179        let name = req.into_inner().name;
1180        convert::check_str("name", &name, MAX_SCHEDULE_FIELD)?;
1181        let removed = crate::route::remove_recurring(app, &p, name).await?;
1182        Ok(Response::new(pb::RemoveRecurringResponse { removed }))
1183    }
1184
1185    async fn list_workers(
1186        &self,
1187        req: Request<pb::Empty>,
1188    ) -> Result<Response<pb::ListWorkersResponse>, Status> {
1189        let p = check_grpc(&self.shared.auth, &req, Role::Metrics)?;
1190        let app = self.shared.app_or_unavailable()?;
1191        Ok(Response::new(crate::route::list_workers(app, &p).await?))
1192    }
1193
1194    async fn list_recurring(
1195        &self,
1196        req: Request<pb::Empty>,
1197    ) -> Result<Response<pb::ListRecurringResponse>, Status> {
1198        let p = check_grpc(&self.shared.auth, &req, Role::Metrics)?;
1199        let app = self.shared.app_or_unavailable()?;
1200        Ok(Response::new(crate::route::list_recurring(app, &p).await?))
1201    }
1202}