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}