kasl/libs/day_delivery.rs
1//! Getting days to kasl-server, including the ones that did not go the first
2//! time.
3//!
4//! `kasl server push` sends today and is done. This is what happens when it
5//! cannot: the date is written to an outbox, and the next successful run pays
6//! the whole debt off in one request.
7//!
8//! Three rules shape it, and each exists because of a way the naive version
9//! goes wrong:
10//!
11//! * **A failure is sorted before it is stored.** A day the server will never
12//! accept as sent (`4xx`) is not queued - queuing it creates a row that
13//! retries forever and never succeeds. Only a day the server could not
14//! answer for (`5xx`, `429`, no connection at all) is worth keeping.
15//! * **A queued day is rebuilt, not replayed.** The outbox holds a date; the
16//! payload is assembled at delivery. A day corrected while the network was
17//! down arrives corrected.
18//! * **A backlog is one request.** The batch endpoint answers per day, so one
19//! day the server refuses does not strand the rest behind it (ADR 0005 in
20//! kasl-server).
21
22use crate::api::kasl_server::{DayResult, DayUpload, KaslServer, UploadError};
23use crate::db::server_outbox::ServerOutbox;
24use crate::libs::day_upload::build_day_upload;
25use anyhow::Result;
26use chrono::NaiveDate;
27
28/// How many days go in one request.
29///
30/// The server caps a batch and answers `413` past it (`KASL_MAX_BATCH_DAYS`,
31/// ADR 0005), so a long backlog is split here rather than bounced there. Well
32/// under any plausible server limit: the cost of an extra request is one round
33/// trip, the cost of guessing too high is a refusal the user has to decode.
34pub const BATCH_SIZE: usize = 30;
35
36/// What became of one date in a delivery run.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Delivered {
39 /// The server stored it. Any queue entry for the date is gone.
40 Accepted {
41 date: NaiveDate,
42 pauses: usize,
43 tasks: usize,
44 deleted_tasks: u64,
45 },
46
47 /// The server refused it, and would refuse it again. Dropped from the
48 /// queue rather than retried forever; the reason is carried so it can be
49 /// said out loud.
50 Refused { date: NaiveDate, reason: String },
51
52 /// The day could not be built or sent this time, and is still owed.
53 Deferred { date: NaiveDate, reason: String },
54}
55
56impl Delivered {
57 /// The date this outcome is about.
58 pub fn date(&self) -> NaiveDate {
59 match self {
60 Delivered::Accepted { date, .. } | Delivered::Refused { date, .. } | Delivered::Deferred { date, .. } => *date,
61 }
62 }
63}
64
65/// Sends `dates` to the server, updating the outbox for each.
66///
67/// A date whose day no longer exists locally is dropped from the queue rather
68/// than carried: the workday was deleted after it was queued, and there is
69/// nothing left to owe. Silently retrying it forever would be the alternative.
70///
71/// The return is one outcome per date that had something to send, in the
72/// order the dates were given.
73pub async fn deliver(client: &KaslServer, token: &str, outbox: &mut ServerOutbox, dates: &[NaiveDate]) -> Result<Vec<Delivered>> {
74 let mut outcomes = Vec::new();
75 let mut sendable: Vec<(NaiveDate, DayUpload)> = Vec::new();
76
77 for &date in dates {
78 match build_day_upload(date) {
79 // Nothing to send: the workday is gone from the database, so the
80 // debt is gone with it.
81 Ok(None) => {
82 outbox.remove(date)?;
83 }
84 Ok(Some(day)) => sendable.push((date, day)),
85 // A day that cannot be assembled is a local problem - a timestamp
86 // with no valid offset, a task without an id - and no amount of
87 // retrying fixes it from here. It stays queued, because the fix
88 // is an edit the user makes and then the day goes.
89 Err(error) => {
90 let reason = error.to_string();
91 outbox.enqueue(date, &reason)?;
92 outcomes.push(Delivered::Deferred { date, reason });
93 }
94 }
95 }
96
97 for chunk in sendable.chunks(BATCH_SIZE) {
98 let days: Vec<DayUpload> = chunk.iter().map(|(_, day)| day.clone()).collect();
99 let dates: Vec<NaiveDate> = chunk.iter().map(|(date, _)| *date).collect();
100
101 match client.upload_batch(token, &days).await {
102 Ok(result) => {
103 // Read per day, never by the status: a batch answers 200 with
104 // refused days inside it (ADR 0005).
105 let mut answered: Vec<NaiveDate> = Vec::with_capacity(result.results.len());
106 for day_result in &result.results {
107 let outcome = record(outbox, day_result)?;
108 answered.push(outcome.date());
109 outcomes.push(outcome);
110 }
111
112 // A day sent but not reported on stays owed. Which day went
113 // unanswered is not inferable from position - the server
114 // names each date it answers for - so the dates are compared
115 // rather than the counts. Keeping a day the server did store
116 // costs one harmless re-upload; dropping one it did not
117 // loses the day for good.
118 for date in dates.iter().filter(|date| !answered.contains(date)) {
119 let reason = "the server did not report on this day".to_string();
120 outbox.enqueue(*date, &reason)?;
121 outcomes.push(Delivered::Deferred { date: *date, reason });
122 }
123 }
124 // The request itself failed. Whether these days are worth keeping
125 // is the same question the single-day path asks.
126 Err(error) => {
127 let retryable = error.is_retryable();
128 let reason = error.to_string();
129 for date in dates {
130 outcomes.push(settle(outbox, date, &reason, retryable)?);
131 }
132 }
133 }
134 }
135
136 Ok(outcomes)
137}
138
139/// Applies one day's reported fate to the outbox.
140fn record(outbox: &mut ServerOutbox, result: &DayResult) -> Result<Delivered> {
141 match result {
142 DayResult::Accepted { day } => {
143 outbox.remove(day.date)?;
144 Ok(Delivered::Accepted {
145 date: day.date,
146 pauses: day.pauses,
147 tasks: day.tasks,
148 deleted_tasks: day.deleted_tasks,
149 })
150 }
151 // A day refused inside a batch was refused on its own merits - the
152 // server validated it and said no. Sending it again unchanged would
153 // get the same answer, so it leaves the queue.
154 DayResult::Rejected { date, error } => {
155 outbox.remove(*date)?;
156 Ok(Delivered::Refused {
157 date: *date,
158 reason: error.clone(),
159 })
160 }
161 }
162}
163
164/// Queues or drops one date after a failure, by whether a retry could work.
165fn settle(outbox: &mut ServerOutbox, date: NaiveDate, reason: &str, retryable: bool) -> Result<Delivered> {
166 if retryable {
167 outbox.enqueue(date, reason)?;
168 Ok(Delivered::Deferred {
169 date,
170 reason: reason.to_string(),
171 })
172 } else {
173 // Never going to be accepted as sent. Keeping it would build a queue
174 // of days that retry forever and never leave.
175 outbox.remove(date)?;
176 Ok(Delivered::Refused {
177 date,
178 reason: reason.to_string(),
179 })
180 }
181}
182
183/// Records the outcome of a single-day upload, so `push` feeds the same queue.
184///
185/// Split from [`deliver`] because the single-day path has already sent its
186/// day and only needs the bookkeeping; sharing the decision is the point,
187/// since the two paths disagreeing about what is worth retrying is exactly
188/// the bug this shape prevents.
189pub fn record_single(outbox: &mut ServerOutbox, date: NaiveDate, error: &UploadError) -> Result<Delivered> {
190 settle(outbox, date, &error.to_string(), error.is_retryable())
191}
192
193// The batch size is exercised where it can actually be observed - by counting
194// the requests a backlog longer than it produces (`tests/day_delivery.rs`).
195// Asserting the constant against a literal here would only restate the line
196// above it.