use serde::{Deserialize, Serialize};
use crate::{LabelSelector, Labels, ModelError, ModelResult, Slot, Task, TaskId, TaskPhase};
pub const DEFAULT_LIMIT: usize = 100;
pub const MAX_LIMIT: usize = 1000;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
rename_all = "camelCase",
deny_unknown_fields,
try_from = "raw::TaskFilterRaw"
)]
pub struct TaskFilter {
phases: Vec<TaskPhase>,
slot: Option<Slot>,
label_selector: LabelSelector,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
rename_all = "camelCase",
deny_unknown_fields,
try_from = "raw::TaskContinuationRaw"
)]
pub struct TaskContinuation {
resource_version: String,
filter: TaskFilter,
after: TaskId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskQuery {
filter: TaskFilter,
limit: usize,
continuation: Option<TaskContinuation>,
}
impl Default for TaskQuery {
#[inline]
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskPage<T> {
pub items: Vec<T>,
pub resource_version: String,
pub continuation: Option<TaskContinuation>,
pub remaining_item_count: usize,
}
impl TaskContinuation {
pub fn new(
resource_version: impl Into<String>,
filter: TaskFilter,
after: TaskId,
) -> ModelResult<Self> {
let resource_version = resource_version.into();
if resource_version.trim().is_empty() {
return Err(ModelError::Invalid(
"continuation resourceVersion must not be empty".into(),
));
}
Ok(Self {
resource_version,
filter,
after,
})
}
pub fn resource_version(&self) -> &str {
&self.resource_version
}
pub fn filter(&self) -> &TaskFilter {
&self.filter
}
pub fn after(&self) -> &TaskId {
&self.after
}
}
mod raw {
use super::*;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct TaskFilterRaw {
#[serde(default)]
phases: Vec<TaskPhase>,
#[serde(default)]
slot: Option<Slot>,
#[serde(default)]
label_selector: LabelSelector,
}
impl TryFrom<TaskFilterRaw> for TaskFilter {
type Error = ModelError;
fn try_from(raw: TaskFilterRaw) -> Result<Self, Self::Error> {
raw.label_selector.validate()?;
let mut filter = Self {
phases: Vec::new(),
slot: raw.slot,
label_selector: raw.label_selector,
};
for phase in raw.phases {
filter = filter.with_phase(phase);
}
Ok(filter)
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct TaskContinuationRaw {
resource_version: String,
filter: TaskFilter,
after: TaskId,
}
impl TryFrom<TaskContinuationRaw> for TaskContinuation {
type Error = ModelError;
fn try_from(raw: TaskContinuationRaw) -> Result<Self, Self::Error> {
TaskContinuation::new(raw.resource_version, raw.filter, raw.after)
}
}
}
impl TaskFilter {
#[inline]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn with_slot(mut self, slot: Slot) -> Self {
self.slot = Some(slot);
self
}
#[inline]
pub fn with_phase(mut self, phase: TaskPhase) -> Self {
if !self.phases.contains(&phase) {
self.phases.push(phase);
}
self
}
pub fn with_phases(mut self, phases: impl IntoIterator<Item = TaskPhase>) -> Self {
for phase in phases {
self = self.with_phase(phase);
}
self
}
#[inline]
pub fn with_label_selector(mut self, selector: LabelSelector) -> ModelResult<Self> {
selector.validate()?;
self.label_selector = selector;
Ok(self)
}
#[inline]
pub fn with_active(self) -> Self {
self.with_phase(TaskPhase::Pending)
.with_phase(TaskPhase::Running)
}
#[inline]
pub fn with_terminal(self) -> Self {
self.with_phase(TaskPhase::Succeeded)
.with_phase(TaskPhase::Exhausted)
.with_phase(TaskPhase::Canceled)
.with_phase(TaskPhase::Timeout)
.with_phase(TaskPhase::Failed)
}
#[inline]
pub fn matches(&self, task: &Task) -> bool {
self.slot.as_ref().is_none_or(|slot| slot == task.slot())
&& self.matches_phase(task.phase())
&& self.matches_labels(task.labels())
}
#[inline]
pub fn matches_phase(&self, phase: &TaskPhase) -> bool {
self.phases.is_empty() || self.phases.contains(phase)
}
#[inline]
pub fn matches_labels(&self, labels: &Labels) -> bool {
self.label_selector.matches(labels)
}
#[inline]
pub fn slot(&self) -> Option<&Slot> {
self.slot.as_ref()
}
#[inline]
pub fn phases(&self) -> &[TaskPhase] {
&self.phases
}
#[inline]
pub fn label_selector(&self) -> &LabelSelector {
&self.label_selector
}
}
impl TaskQuery {
#[inline]
pub fn new() -> Self {
Self::from_filter(TaskFilter::new())
}
#[inline]
pub fn from_filter(filter: TaskFilter) -> Self {
Self {
filter,
limit: DEFAULT_LIMIT,
continuation: None,
}
}
#[inline]
pub fn with_slot(mut self, slot: Slot) -> Self {
self.filter = self.filter.with_slot(slot);
self
}
#[inline]
pub fn with_phase(mut self, phase: TaskPhase) -> Self {
self.filter = self.filter.with_phase(phase);
self
}
#[inline]
pub fn with_phases(mut self, phases: impl IntoIterator<Item = TaskPhase>) -> Self {
self.filter = self.filter.with_phases(phases);
self
}
#[inline]
pub fn with_label_selector(mut self, selector: LabelSelector) -> ModelResult<Self> {
self.filter = self.filter.with_label_selector(selector)?;
Ok(self)
}
#[inline]
pub fn with_active(mut self) -> Self {
self.filter = self.filter.with_active();
self
}
#[inline]
pub fn with_terminal(mut self) -> Self {
self.filter = self.filter.with_terminal();
self
}
#[inline]
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = if limit == 0 {
DEFAULT_LIMIT
} else {
limit.min(MAX_LIMIT)
};
self
}
#[inline]
pub fn with_continuation(mut self, continuation: TaskContinuation) -> Self {
self.continuation = Some(continuation);
self
}
#[inline]
pub fn matches(&self, task: &Task) -> bool {
self.filter.matches(task)
}
#[inline]
pub fn matches_phase(&self, phase: &TaskPhase) -> bool {
self.filter.matches_phase(phase)
}
#[inline]
pub fn matches_labels(&self, labels: &Labels) -> bool {
self.filter.matches_labels(labels)
}
#[inline]
pub fn limit(&self) -> usize {
self.limit
}
#[inline]
pub fn continuation(&self) -> Option<&TaskContinuation> {
self.continuation.as_ref()
}
#[inline]
pub fn filter(&self) -> &TaskFilter {
&self.filter
}
#[inline]
pub fn slot(&self) -> Option<&Slot> {
self.filter.slot()
}
#[inline]
pub fn phases(&self) -> &[TaskPhase] {
self.filter.phases()
}
#[inline]
pub fn label_selector(&self) -> &LabelSelector {
self.filter.label_selector()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskWatchEvent {
Added(Task),
Modified(Task),
Deleted(Task),
}
impl TaskWatchEvent {
#[inline]
pub fn object(&self) -> &Task {
match self {
Self::Added(task) | Self::Modified(task) | Self::Deleted(task) => task,
}
}
#[inline]
pub fn resource_version(&self) -> &str {
self.object().metadata().resource_version()
}
#[inline]
pub fn into_object(self) -> Task {
match self {
Self::Added(task) | Self::Modified(task) | Self::Deleted(task) => task,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EmbeddedSpec, TaskSpec, TaskWorkload};
fn labels(pairs: &[(&str, &str)]) -> Labels {
let mut labels = Labels::new();
for (key, value) in pairs {
labels.insert(*key, *value);
}
labels
}
#[test]
fn filters_deduplicate_phases_and_match_with_or_semantics() {
let query = TaskQuery::new()
.with_phase(TaskPhase::Pending)
.with_phase(TaskPhase::Running)
.with_phase(TaskPhase::Pending);
assert_eq!(query.phases(), &[TaskPhase::Pending, TaskPhase::Running]);
assert!(query.matches_phase(&TaskPhase::Pending));
assert!(query.matches_phase(&TaskPhase::Running));
assert!(!query.matches_phase(&TaskPhase::Failed));
let query = TaskQuery::new();
assert!(query.matches_phase(&TaskPhase::Failed));
assert!(query.matches_labels(&labels(&[("environment", "production")])));
}
#[test]
fn label_selector_is_applied() {
let query = TaskQuery::new()
.with_label_selector(
"environment=production,!tainted"
.parse::<LabelSelector>()
.unwrap(),
)
.unwrap();
assert!(query.matches_labels(&labels(&[("environment", "production")])));
assert!(!query.matches_labels(&labels(&[("environment", "development")])));
assert!(!query.matches_labels(&labels(&[
("environment", "production"),
("tainted", "true"),
])));
}
#[test]
fn query_keeps_filter_separate_from_pagination() {
let filter = TaskFilter::new()
.with_slot(Slot::new("build").unwrap())
.with_phase(TaskPhase::Running);
let continuation =
TaskContinuation::new("store:7", filter.clone(), TaskId::new("build-50").unwrap())
.unwrap();
let query = TaskQuery::from_filter(filter.clone())
.with_limit(25)
.with_continuation(continuation.clone());
assert_eq!(query.filter(), &filter);
assert_eq!(query.limit(), 25);
assert_eq!(query.continuation(), Some(&continuation));
assert_eq!(continuation.resource_version(), "store:7");
assert_eq!(continuation.filter(), &filter);
assert_eq!(continuation.after().as_str(), "build-50");
}
#[test]
fn zero_limit_uses_default_and_continuation_requires_resource_version() {
assert_eq!(TaskQuery::new().with_limit(0).limit(), DEFAULT_LIMIT);
assert!(matches!(
TaskContinuation::new(" ", TaskFilter::new(), TaskId::new("build-50").unwrap(),),
Err(ModelError::Invalid(_))
));
}
#[test]
fn continuation_has_a_strict_serde_roundtrip() {
let filter = TaskFilter::new()
.with_slot(Slot::new("build").unwrap())
.with_phase(TaskPhase::Running)
.with_label_selector("environment=production".parse().unwrap())
.unwrap();
let continuation =
TaskContinuation::new("store:7", filter, TaskId::new("build-50").unwrap()).unwrap();
let json = serde_json::to_string(&continuation).unwrap();
let decoded: TaskContinuation = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, continuation);
assert!(
serde_json::from_str::<TaskContinuation>(
r#"{"resourceVersion":"","filter":{},"after":"build-50"}"#,
)
.is_err()
);
assert!(
serde_json::from_str::<TaskFilter>(
r#"{"labelSelector":{"matchExpressions":[{"key":"tier","operator":"In","values":[]}]}}"#,
)
.is_err()
);
assert!(serde_json::from_str::<TaskFilter>(r#"{"unknown":true}"#).is_err());
}
#[test]
fn watch_event_exposes_object_resource_version() {
let spec = TaskSpec::builder(
"build",
TaskWorkload::Embedded(EmbeddedSpec::new("v1").unwrap()),
1_000_u64,
)
.build()
.unwrap();
let mut task = Task::new("build-1", spec).unwrap();
task.set_resource_version("store:7").unwrap();
let event = TaskWatchEvent::Modified(task.clone());
assert_eq!(event.object(), &task);
assert_eq!(event.resource_version(), "store:7");
assert_eq!(event.into_object(), task);
}
}