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, and written only while the task holds one of the
23/// priorities `when_priority` names when it names any.
24#[derive(Debug, Clone, PartialEq)]
25pub struct BatchUpdate {
26    /// The task.
27    pub id: NativeId,
28    /// Every field written, together, in one held write.
29    pub update: TaskUpdate,
30    /// The version the task must be at for anything to be written.
31    // 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.
32    pub if_version: Option<String>,
33    /// The priorities the task must hold for anything to be written — [`Priority::None`]
34    /// matching a task with none. Empty sets no guard.
35    pub when_priority: Vec<Priority>,
36}
37
38/// One comment of a batch: a new comment on one existing task, held to the version the caller
39/// read when `if_version` names one.
40#[derive(Debug, Clone, PartialEq)]
41pub struct BatchComment {
42    /// The task commented on.
43    pub task: NativeId,
44    /// The comment.
45    pub comment: NewComment,
46    /// The version the task must be at for the comment to be written.
47    // llmlint: ignore[invalid_states_unrepresentable] As `BatchUpdate::if_version`.
48    pub if_version: Option<String>,
49}
50
51/// What became of one item of a batched write.
52#[derive(Debug, Clone, PartialEq)]
53pub enum BatchAnswer<T> {
54    /// It was written, and this is the source's answer for it.
55    Written(T),
56    /// It was at another version than the one it was held to, so nothing was written; `found`
57    /// is the version it is at.
58    VersionMoved {
59        /// The version the source found the task at.
60        found: Option<String>,
61    },
62    /// Its priority was not one the write's guard names, so nothing was written; this is the
63    /// task as the source read it.
64    // 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.
65    PriorityHeld(Box<Task>),
66    /// The source holds no such task.
67    Missing,
68}
69
70/// The default of [`TaskSource::update_tasks`]: each update on its own, in order.
71pub(crate) async fn update_each<S: TaskSource + ?Sized>(
72    source: &S,
73    updates: &[BatchUpdate],
74) -> Vec<Result<BatchAnswer<TaskUpdateOutcome>, SourceError>> {
75    let mut answers = Vec::with_capacity(updates.len());
76    for each in updates {
77        answers.push(update_one(source, each).await);
78    }
79    answers
80}
81
82async fn update_one<S: TaskSource + ?Sized>(
83    source: &S,
84    each: &BatchUpdate,
85) -> Result<BatchAnswer<TaskUpdateOutcome>, SourceError> {
86    let held = match decide(
87        source,
88        &each.id,
89        each.if_version.as_deref(),
90        &each.when_priority,
91    )
92    .await?
93    {
94        Decided::Answer(answer) => return Ok(answer),
95        Decided::Write(held) => held,
96    };
97    let written = match held {
98        Some(version) if source.holds_versions() => {
99            let hold = Arc::new(HeldVersion::new(each.id.clone(), version));
100            let written = HeldVersion::scope(
101                Arc::clone(&hold),
102                source.update_task(&each.id, &each.update),
103            )
104            .await;
105            if let Some(found) = hold.found() {
106                return Ok(BatchAnswer::VersionMoved { found: Some(found) });
107            }
108            written?
109        }
110        _ => source.update_task(&each.id, &each.update).await?,
111    };
112    Ok(written.map_or(BatchAnswer::Missing, BatchAnswer::Written))
113}
114
115/// The default of [`TaskSource::add_comments`]: each comment on its own, in order.
116pub(crate) async fn comment_each<S: TaskSource + ?Sized>(
117    source: &S,
118    comments: &[BatchComment],
119) -> Vec<Result<BatchAnswer<Comment>, SourceError>> {
120    let mut answers = Vec::with_capacity(comments.len());
121    for each in comments {
122        answers.push(comment_one(source, each).await);
123    }
124    answers
125}
126
127async fn comment_one<S: TaskSource + ?Sized>(
128    source: &S,
129    each: &BatchComment,
130) -> Result<BatchAnswer<Comment>, SourceError> {
131    let held = match decide(source, &each.task, each.if_version.as_deref(), &[]).await? {
132        Decided::Answer(answer) => return Ok(answer),
133        Decided::Write(held) => held,
134    };
135    let written = match held {
136        Some(version) if source.holds_versions() => {
137            let hold = Arc::new(HeldVersion::new(each.task.clone(), version));
138            let written = HeldVersion::scope(
139                Arc::clone(&hold),
140                source.add_comment(&each.task, &each.comment),
141            )
142            .await;
143            if let Some(found) = hold.found() {
144                return Ok(BatchAnswer::VersionMoved { found: Some(found) });
145            }
146            written?
147        }
148        _ => source.add_comment(&each.task, &each.comment).await?,
149    };
150    Ok(written.map_or(BatchAnswer::Missing, BatchAnswer::Written))
151}
152
153/// What deciding one held item came to before its write: an answer that writes nothing, or the
154/// write, held to the version it is to be checked at where the source checks it itself.
155enum Decided<T> {
156    Answer(BatchAnswer<T>),
157    Write(Option<String>),
158}
159
160/// Decide one item against its hold and its guard.
161///
162/// A source that checks a held version inside its own write is not read for a hold alone; any
163/// other is read once, immediately before the write, and so is every source for a priority
164/// guard — whose write is then held to the version that read found, so a change landing
165/// between the read and the write is refused rather than written over.
166async fn decide<S: TaskSource + ?Sized, T>(
167    source: &S,
168    id: &NativeId,
169    if_version: Option<&str>,
170    when_priority: &[Priority],
171) -> Result<Decided<T>, SourceError> {
172    if if_version.is_none() && when_priority.is_empty() {
173        return Ok(Decided::Write(None));
174    }
175    if when_priority.is_empty() && source.holds_versions() {
176        return Ok(Decided::Write(if_version.map(str::to_owned)));
177    }
178    let Some(task) = source.get_task(id).await? else {
179        return Ok(Decided::Answer(BatchAnswer::Missing));
180    };
181    if let Some(version) = if_version
182        && !version_at(source, task.version.as_deref(), version)
183    {
184        return Ok(Decided::Answer(BatchAnswer::VersionMoved {
185            found: task.version,
186        }));
187    }
188    if !when_priority.is_empty() && !when_priority.contains(&task.priority) {
189        return Ok(Decided::Answer(BatchAnswer::PriorityHeld(Box::new(task))));
190    }
191    Ok(Decided::Write(task.version))
192}
193
194/// Whether `found` is the version `version` names, in `source`'s own order of its tokens.
195#[must_use]
196pub fn version_at<S: TaskSource + ?Sized>(source: &S, found: Option<&str>, version: &str) -> bool {
197    found.is_some_and(|found| source.order_versions(found, version) == Some(Ordering::Equal))
198}
199
200/// Decide one batched item against its hold and its guard on `task`, the source's own read of
201/// it — the decision [`TaskSource::update_tasks`]' default makes, for an override that read
202/// many items together: `Some` answer when nothing is to be written.
203#[must_use]
204pub fn decided_on<S: TaskSource + ?Sized, T>(
205    source: &S,
206    task: Option<&Task>,
207    if_version: Option<&str>,
208    when_priority: &[Priority],
209) -> Option<BatchAnswer<T>> {
210    let Some(task) = task else {
211        return Some(BatchAnswer::Missing);
212    };
213    if let Some(version) = if_version
214        && !version_at(source, task.version.as_deref(), version)
215    {
216        return Some(BatchAnswer::VersionMoved {
217            found: task.version.clone(),
218        });
219    }
220    if !when_priority.is_empty() && !when_priority.contains(&task.priority) {
221        return Some(BatchAnswer::PriorityHeld(Box::new(task.clone())));
222    }
223    None
224}