Skip to main content

onetaskgraph_plugin_api/
batch.rs

1//! Batched writes: many items' writes in one call, each held and answered on its own.
2//!
3//! A caller doing the same thing to many items — a comment on each, an update of each — asks a
4//! source once rather than once per item, and a source whose backend can carry several writes
5//! in one request sends them together. Every item is still its own write: one item's failure
6//! never writes or refuses another's, and each answer says what became of its own item.
7//!
8//! The defaults on [`TaskSource`] make one call per item through the single-item methods, so a
9//! source that predates these behaves exactly as it did, one request per item; one whose
10//! backend batches overrides them, and keeps a per-item request for exactly the case its
11//! backend would fail as a whole.
12
13use std::cmp::Ordering;
14use std::sync::Arc;
15
16use crate::{
17    Comment, HeldVersion, NativeId, NewComment, Priority, SourceError, Task, TaskSource,
18    TaskUpdate, TaskUpdateOutcome,
19};
20
21/// One update of a batch: a targeted update of one existing task, held to the version the
22/// caller read when `if_version` names one, written only while the task holds one of the
23/// priorities `when_priority` names when it names any, and only while it holds exactly the
24/// people `when_assignees` names when it names a set.
25#[derive(Debug, Clone, PartialEq)]
26pub struct BatchUpdate {
27    /// The task.
28    pub id: NativeId,
29    /// Every field written, together, in one held write.
30    pub update: TaskUpdate,
31    /// The version the task must be at for anything to be written.
32    // llmlint: ignore[invalid_states_unrepresentable] A version is the source's own opaque token, which only the source that spelled it reads; `None` is the ordinary unheld write.
33    pub if_version: Option<String>,
34    /// The priorities the task must hold for anything to be written — [`Priority::None`]
35    /// matching a task with none. Empty sets no guard.
36    pub when_priority: Vec<Priority>,
37    /// The people the task must be assigned to — exactly these, whatever their order — for
38    /// anything to be written; an empty set holds it to an unassigned task. `None` sets no
39    /// guard.
40    // llmlint: ignore[invalid_states_unrepresentable] Unlike `when_priority`, an empty set is a guard of its own here — held only while unassigned — so "no guard" needs its own state, which `None` is.
41    pub when_assignees: Option<Vec<String>>,
42}
43
44/// One comment of a batch: a new comment on one existing task, held to the version the caller
45/// read when `if_version` names one.
46#[derive(Debug, Clone, PartialEq)]
47pub struct BatchComment {
48    /// The task commented on.
49    pub task: NativeId,
50    /// The comment.
51    pub comment: NewComment,
52    /// The version the task must be at for the comment to be written.
53    // llmlint: ignore[invalid_states_unrepresentable] As `BatchUpdate::if_version`.
54    pub if_version: Option<String>,
55}
56
57/// What became of one item of a batched write.
58#[derive(Debug, Clone, PartialEq)]
59pub enum BatchAnswer<T> {
60    /// It was written, and this is the source's answer for it.
61    Written(T),
62    /// It was at another version than the one it was held to, so nothing was written; `found`
63    /// is the version it is at.
64    VersionMoved {
65        /// The version the source found the task at.
66        found: Option<String>,
67    },
68    /// Its priority was not one the write's guard names, so nothing was written; this is the
69    /// task as the source read it.
70    // llmlint: ignore[invalid_states_unrepresentable] One answer type serves both batched writes so the plugin contract names one type, not two; a comment carries no guard, so no source answers a comment `PriorityHeld` (`comment_each` never does, and each override decides comments without one). A second answer type would rebuild every plugin for a variant nothing produces.
71    PriorityHeld(Box<Task>),
72    /// It was not assigned to exactly the people the write's `when_assignees` names, so nothing
73    /// was written; this is the task as the source read it. Answered only once its priority
74    /// guard, when it has one, held.
75    // llmlint: ignore[invalid_states_unrepresentable] As `PriorityHeld`: a comment carries no guard, so no source answers a comment `AssigneesHeld`.
76    AssigneesHeld(Box<Task>),
77    /// The source holds no such task.
78    Missing,
79}
80
81/// The default of [`TaskSource::update_tasks`]: each update on its own, in order.
82pub(crate) async fn update_each<S: TaskSource + ?Sized>(
83    source: &S,
84    updates: &[BatchUpdate],
85) -> Vec<Result<BatchAnswer<TaskUpdateOutcome>, SourceError>> {
86    let mut answers = Vec::with_capacity(updates.len());
87    for each in updates {
88        answers.push(update_one(source, each).await);
89    }
90    answers
91}
92
93async fn update_one<S: TaskSource + ?Sized>(
94    source: &S,
95    each: &BatchUpdate,
96) -> Result<BatchAnswer<TaskUpdateOutcome>, SourceError> {
97    let held = match decide(
98        source,
99        &each.id,
100        each.if_version.as_deref(),
101        &Guards::of(each),
102    )
103    .await?
104    {
105        Decided::Answer(answer) => return Ok(answer),
106        Decided::Write(held) => held,
107    };
108    let written = match held {
109        Some(version) if source.holds_versions() => {
110            let hold = Arc::new(HeldVersion::new(each.id.clone(), version));
111            let written = HeldVersion::scope(
112                Arc::clone(&hold),
113                source.update_task(&each.id, &each.update),
114            )
115            .await;
116            if let Some(found) = hold.found() {
117                return Ok(BatchAnswer::VersionMoved { found: Some(found) });
118            }
119            written?
120        }
121        _ => source.update_task(&each.id, &each.update).await?,
122    };
123    Ok(written.map_or(BatchAnswer::Missing, BatchAnswer::Written))
124}
125
126/// The default of [`TaskSource::add_comments`]: each comment on its own, in order.
127pub(crate) async fn comment_each<S: TaskSource + ?Sized>(
128    source: &S,
129    comments: &[BatchComment],
130) -> Vec<Result<BatchAnswer<Comment>, SourceError>> {
131    let mut answers = Vec::with_capacity(comments.len());
132    for each in comments {
133        answers.push(comment_one(source, each).await);
134    }
135    answers
136}
137
138async fn comment_one<S: TaskSource + ?Sized>(
139    source: &S,
140    each: &BatchComment,
141) -> Result<BatchAnswer<Comment>, SourceError> {
142    let held = match decide(
143        source,
144        &each.task,
145        each.if_version.as_deref(),
146        &Guards::NONE,
147    )
148    .await?
149    {
150        Decided::Answer(answer) => return Ok(answer),
151        Decided::Write(held) => held,
152    };
153    let written = match held {
154        Some(version) if source.holds_versions() => {
155            let hold = Arc::new(HeldVersion::new(each.task.clone(), version));
156            let written = HeldVersion::scope(
157                Arc::clone(&hold),
158                source.add_comment(&each.task, &each.comment),
159            )
160            .await;
161            if let Some(found) = hold.found() {
162                return Ok(BatchAnswer::VersionMoved { found: Some(found) });
163            }
164            written?
165        }
166        _ => source.add_comment(&each.task, &each.comment).await?,
167    };
168    Ok(written.map_or(BatchAnswer::Missing, BatchAnswer::Written))
169}
170
171/// What a batched write is guarded by, beside the version it is held to.
172#[derive(Debug, Clone, Copy, Default)]
173pub(crate) struct Guards<'a> {
174    /// The priorities the task must hold one of; empty sets no guard.
175    pub priority: &'a [Priority],
176    /// The people the task must hold exactly; `None` sets no guard.
177    pub assignees: Option<&'a [String]>,
178}
179
180impl<'a> Guards<'a> {
181    /// No guard at all.
182    pub const NONE: Guards<'static> = Guards {
183        priority: &[],
184        assignees: None,
185    };
186
187    /// The guards `update` names.
188    #[must_use]
189    pub fn of(update: &'a BatchUpdate) -> Self {
190        Self {
191            priority: &update.when_priority,
192            assignees: update.when_assignees.as_deref(),
193        }
194    }
195
196    /// Whether neither guard is set.
197    #[must_use]
198    pub const fn is_empty(&self) -> bool {
199        self.priority.is_empty() && self.assignees.is_none()
200    }
201
202    /// The answer a guard that `task` does not meet writes in place of the write — its
203    /// priority guard decided first — or `None` when every guard set holds of it.
204    #[must_use]
205    pub fn held_by<T>(&self, task: &Task) -> Option<BatchAnswer<T>> {
206        if !self.priority.is_empty() && !self.priority.contains(&task.priority) {
207            return Some(BatchAnswer::PriorityHeld(Box::new(task.clone())));
208        }
209        let assigned = task.assignees.as_deref().unwrap_or_default();
210        self.assignees
211            .is_some_and(|wanted| !crate::work::same_assignees(assigned, wanted))
212            .then(|| BatchAnswer::AssigneesHeld(Box::new(task.clone())))
213    }
214}
215
216/// What deciding one held item came to before its write: an answer that writes nothing, or the
217/// write, held to the version it is to be checked at where the source checks it itself.
218enum Decided<T> {
219    Answer(BatchAnswer<T>),
220    Write(Option<String>),
221}
222
223/// Decide one item against its hold and its guard.
224///
225/// A source that checks a held version inside its own write is not read for a hold alone; any
226/// other is read once, immediately before the write, and so is every source for a priority or
227/// an assignee guard — whose write is then held to the version that read found, so a change
228/// landing between the read and the write is refused rather than written over.
229async fn decide<S: TaskSource + ?Sized, T>(
230    source: &S,
231    id: &NativeId,
232    if_version: Option<&str>,
233    guards: &Guards<'_>,
234) -> Result<Decided<T>, SourceError> {
235    if if_version.is_none() && guards.is_empty() {
236        return Ok(Decided::Write(None));
237    }
238    if guards.is_empty() && source.holds_versions() {
239        return Ok(Decided::Write(if_version.map(str::to_owned)));
240    }
241    let Some(task) = source.get_task(id).await? else {
242        return Ok(Decided::Answer(BatchAnswer::Missing));
243    };
244    if let Some(version) = if_version
245        && !version_at(source, task.version.as_deref(), version)
246    {
247        return Ok(Decided::Answer(BatchAnswer::VersionMoved {
248            found: task.version,
249        }));
250    }
251    if let Some(held) = guards.held_by(&task) {
252        return Ok(Decided::Answer(held));
253    }
254    Ok(Decided::Write(task.version))
255}
256
257/// Whether `found` is the version `version` names, in `source`'s own order of its tokens.
258#[must_use]
259pub fn version_at<S: TaskSource + ?Sized>(source: &S, found: Option<&str>, version: &str) -> bool {
260    found.is_some_and(|found| source.order_versions(found, version) == Some(Ordering::Equal))
261}
262
263/// Decide one batched item against its hold and its guard on `task`, the source's own read of
264/// it — the decision [`TaskSource::update_tasks`]' default makes, for an override that read
265/// many items together: `Some` answer when nothing is to be written. `when_priority` and
266/// `when_assignees` are [`BatchUpdate`]'s own guards, empty and `None` setting none.
267#[must_use]
268pub fn decided_on<S: TaskSource + ?Sized, T>(
269    source: &S,
270    task: Option<&Task>,
271    if_version: Option<&str>,
272    when_priority: &[Priority],
273    when_assignees: Option<&[String]>,
274) -> Option<BatchAnswer<T>> {
275    let guards = Guards {
276        priority: when_priority,
277        assignees: when_assignees,
278    };
279    let Some(task) = task else {
280        return Some(BatchAnswer::Missing);
281    };
282    if let Some(version) = if_version
283        && !version_at(source, task.version.as_deref(), version)
284    {
285        return Some(BatchAnswer::VersionMoved {
286            found: task.version.clone(),
287        });
288    }
289    guards.held_by(task)
290}