Skip to main content

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.