solti_model/domain/query/
task.rs1use serde::{Deserialize, Serialize};
8
9use crate::{LabelSelector, Labels, ModelError, ModelResult, Slot, Task, TaskId, TaskPhase};
10
11pub const DEFAULT_LIMIT: usize = 100;
13
14pub const MAX_LIMIT: usize = 1000;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(
40 rename_all = "camelCase",
41 deny_unknown_fields,
42 try_from = "raw::TaskFilterRaw"
43)]
44pub struct TaskFilter {
45 phases: Vec<TaskPhase>,
46 slot: Option<Slot>,
47 label_selector: LabelSelector,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(
56 rename_all = "camelCase",
57 deny_unknown_fields,
58 try_from = "raw::TaskContinuationRaw"
59)]
60pub struct TaskContinuation {
61 resource_version: String,
62 filter: TaskFilter,
63 after: TaskId,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct TaskQuery {
72 filter: TaskFilter,
73 limit: usize,
74 continuation: Option<TaskContinuation>,
75}
76
77impl Default for TaskQuery {
78 #[inline]
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct TaskPage<T> {
87 pub items: Vec<T>,
89 pub resource_version: String,
91 pub continuation: Option<TaskContinuation>,
95 pub remaining_item_count: usize,
97}
98
99impl TaskContinuation {
100 pub fn new(
109 resource_version: impl Into<String>,
110 filter: TaskFilter,
111 after: TaskId,
112 ) -> ModelResult<Self> {
113 let resource_version = resource_version.into();
114 if resource_version.trim().is_empty() {
115 return Err(ModelError::Invalid(
116 "continuation resourceVersion must not be empty".into(),
117 ));
118 }
119 Ok(Self {
120 resource_version,
121 filter,
122 after,
123 })
124 }
125
126 pub fn resource_version(&self) -> &str {
128 &self.resource_version
129 }
130
131 pub fn filter(&self) -> &TaskFilter {
133 &self.filter
134 }
135
136 pub fn after(&self) -> &TaskId {
138 &self.after
139 }
140}
141
142mod raw {
143 use super::*;
144
145 #[derive(Deserialize)]
146 #[serde(rename_all = "camelCase", deny_unknown_fields)]
147 pub(super) struct TaskFilterRaw {
148 #[serde(default)]
149 phases: Vec<TaskPhase>,
150 #[serde(default)]
151 slot: Option<Slot>,
152 #[serde(default)]
153 label_selector: LabelSelector,
154 }
155
156 impl TryFrom<TaskFilterRaw> for TaskFilter {
157 type Error = ModelError;
158
159 fn try_from(raw: TaskFilterRaw) -> Result<Self, Self::Error> {
160 raw.label_selector.validate()?;
161 let mut filter = Self {
162 phases: Vec::new(),
163 slot: raw.slot,
164 label_selector: raw.label_selector,
165 };
166 for phase in raw.phases {
167 filter = filter.with_phase(phase);
168 }
169 Ok(filter)
170 }
171 }
172
173 #[derive(Deserialize)]
174 #[serde(rename_all = "camelCase", deny_unknown_fields)]
175 pub(super) struct TaskContinuationRaw {
176 resource_version: String,
177 filter: TaskFilter,
178 after: TaskId,
179 }
180
181 impl TryFrom<TaskContinuationRaw> for TaskContinuation {
182 type Error = ModelError;
183
184 fn try_from(raw: TaskContinuationRaw) -> Result<Self, Self::Error> {
185 TaskContinuation::new(raw.resource_version, raw.filter, raw.after)
186 }
187 }
188}
189
190impl TaskFilter {
191 #[inline]
193 pub fn new() -> Self {
194 Self::default()
195 }
196
197 #[inline]
199 pub fn with_slot(mut self, slot: Slot) -> Self {
200 self.slot = Some(slot);
201 self
202 }
203
204 #[inline]
208 pub fn with_phase(mut self, phase: TaskPhase) -> Self {
209 if !self.phases.contains(&phase) {
210 self.phases.push(phase);
211 }
212 self
213 }
214
215 pub fn with_phases(mut self, phases: impl IntoIterator<Item = TaskPhase>) -> Self {
219 for phase in phases {
220 self = self.with_phase(phase);
221 }
222 self
223 }
224
225 #[inline]
233 pub fn with_label_selector(mut self, selector: LabelSelector) -> ModelResult<Self> {
234 selector.validate()?;
235 self.label_selector = selector;
236 Ok(self)
237 }
238
239 #[inline]
241 pub fn with_active(self) -> Self {
242 self.with_phase(TaskPhase::Pending)
243 .with_phase(TaskPhase::Running)
244 }
245
246 #[inline]
248 pub fn with_terminal(self) -> Self {
249 self.with_phase(TaskPhase::Succeeded)
250 .with_phase(TaskPhase::Exhausted)
251 .with_phase(TaskPhase::Canceled)
252 .with_phase(TaskPhase::Timeout)
253 .with_phase(TaskPhase::Failed)
254 }
255
256 #[inline]
258 pub fn matches(&self, task: &Task) -> bool {
259 self.slot.as_ref().is_none_or(|slot| slot == task.slot())
260 && self.matches_phase(task.phase())
261 && self.matches_labels(task.labels())
262 }
263
264 #[inline]
268 pub fn matches_phase(&self, phase: &TaskPhase) -> bool {
269 self.phases.is_empty() || self.phases.contains(phase)
270 }
271
272 #[inline]
274 pub fn matches_labels(&self, labels: &Labels) -> bool {
275 self.label_selector.matches(labels)
276 }
277
278 #[inline]
280 pub fn slot(&self) -> Option<&Slot> {
281 self.slot.as_ref()
282 }
283
284 #[inline]
286 pub fn phases(&self) -> &[TaskPhase] {
287 &self.phases
288 }
289
290 #[inline]
292 pub fn label_selector(&self) -> &LabelSelector {
293 &self.label_selector
294 }
295}
296
297impl TaskQuery {
298 #[inline]
310 pub fn new() -> Self {
311 Self::from_filter(TaskFilter::new())
312 }
313
314 #[inline]
316 pub fn from_filter(filter: TaskFilter) -> Self {
317 Self {
318 filter,
319 limit: DEFAULT_LIMIT,
320 continuation: None,
321 }
322 }
323
324 #[inline]
326 pub fn with_slot(mut self, slot: Slot) -> Self {
327 self.filter = self.filter.with_slot(slot);
328 self
329 }
330
331 #[inline]
335 pub fn with_phase(mut self, phase: TaskPhase) -> Self {
336 self.filter = self.filter.with_phase(phase);
337 self
338 }
339
340 #[inline]
344 pub fn with_phases(mut self, phases: impl IntoIterator<Item = TaskPhase>) -> Self {
345 self.filter = self.filter.with_phases(phases);
346 self
347 }
348
349 #[inline]
357 pub fn with_label_selector(mut self, selector: LabelSelector) -> ModelResult<Self> {
358 self.filter = self.filter.with_label_selector(selector)?;
359 Ok(self)
360 }
361
362 #[inline]
364 pub fn with_active(mut self) -> Self {
365 self.filter = self.filter.with_active();
366 self
367 }
368
369 #[inline]
371 pub fn with_terminal(mut self) -> Self {
372 self.filter = self.filter.with_terminal();
373 self
374 }
375
376 #[inline]
380 pub fn with_limit(mut self, limit: usize) -> Self {
381 self.limit = if limit == 0 {
382 DEFAULT_LIMIT
383 } else {
384 limit.min(MAX_LIMIT)
385 };
386 self
387 }
388
389 #[inline]
391 pub fn with_continuation(mut self, continuation: TaskContinuation) -> Self {
392 self.continuation = Some(continuation);
393 self
394 }
395
396 #[inline]
398 pub fn matches(&self, task: &Task) -> bool {
399 self.filter.matches(task)
400 }
401
402 #[inline]
404 pub fn matches_phase(&self, phase: &TaskPhase) -> bool {
405 self.filter.matches_phase(phase)
406 }
407
408 #[inline]
410 pub fn matches_labels(&self, labels: &Labels) -> bool {
411 self.filter.matches_labels(labels)
412 }
413
414 #[inline]
416 pub fn limit(&self) -> usize {
417 self.limit
418 }
419
420 #[inline]
422 pub fn continuation(&self) -> Option<&TaskContinuation> {
423 self.continuation.as_ref()
424 }
425
426 #[inline]
428 pub fn filter(&self) -> &TaskFilter {
429 &self.filter
430 }
431
432 #[inline]
434 pub fn slot(&self) -> Option<&Slot> {
435 self.filter.slot()
436 }
437
438 #[inline]
440 pub fn phases(&self) -> &[TaskPhase] {
441 self.filter.phases()
442 }
443
444 #[inline]
446 pub fn label_selector(&self) -> &LabelSelector {
447 self.filter.label_selector()
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum TaskWatchEvent {
454 Added(Task),
456 Modified(Task),
458 Deleted(Task),
460}
461
462impl TaskWatchEvent {
463 #[inline]
465 pub fn object(&self) -> &Task {
466 match self {
467 Self::Added(task) | Self::Modified(task) | Self::Deleted(task) => task,
468 }
469 }
470
471 #[inline]
473 pub fn resource_version(&self) -> &str {
474 self.object().metadata().resource_version()
475 }
476
477 #[inline]
479 pub fn into_object(self) -> Task {
480 match self {
481 Self::Added(task) | Self::Modified(task) | Self::Deleted(task) => task,
482 }
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::{EmbeddedSpec, TaskSpec, TaskWorkload};
490
491 fn labels(pairs: &[(&str, &str)]) -> Labels {
492 let mut labels = Labels::new();
493 for (key, value) in pairs {
494 labels.insert(*key, *value);
495 }
496 labels
497 }
498
499 #[test]
500 fn filters_deduplicate_phases_and_match_with_or_semantics() {
501 let query = TaskQuery::new()
502 .with_phase(TaskPhase::Pending)
503 .with_phase(TaskPhase::Running)
504 .with_phase(TaskPhase::Pending);
505
506 assert_eq!(query.phases(), &[TaskPhase::Pending, TaskPhase::Running]);
507 assert!(query.matches_phase(&TaskPhase::Pending));
508 assert!(query.matches_phase(&TaskPhase::Running));
509 assert!(!query.matches_phase(&TaskPhase::Failed));
510
511 let query = TaskQuery::new();
512 assert!(query.matches_phase(&TaskPhase::Failed));
513 assert!(query.matches_labels(&labels(&[("environment", "production")])));
514 }
515
516 #[test]
517 fn label_selector_is_applied() {
518 let query = TaskQuery::new()
519 .with_label_selector(
520 "environment=production,!tainted"
521 .parse::<LabelSelector>()
522 .unwrap(),
523 )
524 .unwrap();
525
526 assert!(query.matches_labels(&labels(&[("environment", "production")])));
527 assert!(!query.matches_labels(&labels(&[("environment", "development")])));
528 assert!(!query.matches_labels(&labels(&[
529 ("environment", "production"),
530 ("tainted", "true"),
531 ])));
532 }
533
534 #[test]
535 fn query_keeps_filter_separate_from_pagination() {
536 let filter = TaskFilter::new()
537 .with_slot(Slot::new("build").unwrap())
538 .with_phase(TaskPhase::Running);
539 let continuation =
540 TaskContinuation::new("store:7", filter.clone(), TaskId::new("build-50").unwrap())
541 .unwrap();
542 let query = TaskQuery::from_filter(filter.clone())
543 .with_limit(25)
544 .with_continuation(continuation.clone());
545
546 assert_eq!(query.filter(), &filter);
547 assert_eq!(query.limit(), 25);
548 assert_eq!(query.continuation(), Some(&continuation));
549 assert_eq!(continuation.resource_version(), "store:7");
550 assert_eq!(continuation.filter(), &filter);
551 assert_eq!(continuation.after().as_str(), "build-50");
552 }
553
554 #[test]
555 fn zero_limit_uses_default_and_continuation_requires_resource_version() {
556 assert_eq!(TaskQuery::new().with_limit(0).limit(), DEFAULT_LIMIT);
557 assert!(matches!(
558 TaskContinuation::new(" ", TaskFilter::new(), TaskId::new("build-50").unwrap(),),
559 Err(ModelError::Invalid(_))
560 ));
561 }
562
563 #[test]
564 fn continuation_has_a_strict_serde_roundtrip() {
565 let filter = TaskFilter::new()
566 .with_slot(Slot::new("build").unwrap())
567 .with_phase(TaskPhase::Running)
568 .with_label_selector("environment=production".parse().unwrap())
569 .unwrap();
570 let continuation =
571 TaskContinuation::new("store:7", filter, TaskId::new("build-50").unwrap()).unwrap();
572
573 let json = serde_json::to_string(&continuation).unwrap();
574 let decoded: TaskContinuation = serde_json::from_str(&json).unwrap();
575
576 assert_eq!(decoded, continuation);
577 assert!(
578 serde_json::from_str::<TaskContinuation>(
579 r#"{"resourceVersion":"","filter":{},"after":"build-50"}"#,
580 )
581 .is_err()
582 );
583 assert!(
584 serde_json::from_str::<TaskFilter>(
585 r#"{"labelSelector":{"matchExpressions":[{"key":"tier","operator":"In","values":[]}]}}"#,
586 )
587 .is_err()
588 );
589 assert!(serde_json::from_str::<TaskFilter>(r#"{"unknown":true}"#).is_err());
590 }
591
592 #[test]
593 fn watch_event_exposes_object_resource_version() {
594 let spec = TaskSpec::builder(
595 "build",
596 TaskWorkload::Embedded(EmbeddedSpec::new("v1").unwrap()),
597 1_000_u64,
598 )
599 .build()
600 .unwrap();
601 let mut task = Task::new("build-1", spec).unwrap();
602 task.set_resource_version("store:7").unwrap();
603 let event = TaskWatchEvent::Modified(task.clone());
604
605 assert_eq!(event.object(), &task);
606 assert_eq!(event.resource_version(), "store:7");
607 assert_eq!(event.into_object(), task);
608 }
609}