kasl/db/server_outbox.rs
1//! Days owed to kasl-server: what could not be sent, kept until it can.
2//!
3//! A row here is a date, never a payload. The day is rebuilt from the local
4//! tables at the moment it is finally sent, so a correction made while the
5//! network was down is the version that arrives - which is also the rule the
6//! server plays by, where the last upload wins (ADR 0004 in kasl-server).
7//!
8//! Storing the assembled JSON instead would freeze the day at the moment it
9//! first failed, and an employee who fixed a task on Tuesday would watch the
10//! broken Monday copy land on Friday.
11//!
12//! ```rust,no_run
13//! # fn main() -> anyhow::Result<()> {
14//! use kasl::db::server_outbox::ServerOutbox;
15//! use chrono::Local;
16//!
17//! let mut outbox = ServerOutbox::new()?;
18//! outbox.enqueue(Local::now().date_naive(), "the server could not be reached")?;
19//!
20//! for owed in outbox.pending()? {
21//! println!("{} is still owed", owed.date);
22//! }
23//! # Ok(())
24//! # }
25//! ```
26
27use crate::db::db::Db;
28use anyhow::Result;
29use chrono::{NaiveDate, NaiveDateTime};
30use rusqlite::Connection;
31
32const SCHEMA_SERVER_OUTBOX: &str = "CREATE TABLE IF NOT EXISTS server_outbox (
33 id INTEGER PRIMARY KEY,
34 date DATE NOT NULL UNIQUE,
35 queued_at TIMESTAMP NOT NULL,
36 attempts INTEGER NOT NULL DEFAULT 0,
37 last_attempt_at TIMESTAMP,
38 last_error TEXT
39);";
40
41/// Records a day as owed, or notes another failed attempt at one already
42/// recorded.
43///
44/// `ON CONFLICT` is what keeps a fortnight offline from becoming a fortnight
45/// of duplicate rows for the same date: the debt is the date, and failing to
46/// pay it again does not create a second one. `queued_at` is deliberately not
47/// touched on conflict - it answers "how long has this been stuck", which a
48/// refresh on every retry would erase.
49const ENQUEUE: &str = "INSERT INTO server_outbox (date, queued_at, attempts, last_attempt_at, last_error)
50 VALUES (?1, datetime(CURRENT_TIMESTAMP, 'localtime'), 1, datetime(CURRENT_TIMESTAMP, 'localtime'), ?2)
51 ON CONFLICT(date) DO UPDATE SET
52 attempts = attempts + 1,
53 last_attempt_at = datetime(CURRENT_TIMESTAMP, 'localtime'),
54 last_error = ?2";
55
56const SELECT_PENDING: &str = "SELECT id, date, queued_at, attempts, last_attempt_at, last_error FROM server_outbox ORDER BY date ASC";
57
58const DELETE_BY_DATE: &str = "DELETE FROM server_outbox WHERE date = ?1";
59
60const COUNT_PENDING: &str = "SELECT COUNT(*) FROM server_outbox";
61
62/// One day still owed to the server.
63#[derive(Debug, Clone)]
64pub struct OwedDay {
65 /// Database primary key.
66 pub id: i32,
67
68 /// The date whose day has not been delivered.
69 pub date: NaiveDate,
70
71 /// When the day was first found undeliverable. Kept across retries, so
72 /// the age of the debt stays readable.
73 pub queued_at: NaiveDateTime,
74
75 /// How many times delivery has been tried.
76 pub attempts: i32,
77
78 /// When it was last tried.
79 pub last_attempt_at: Option<NaiveDateTime>,
80
81 /// What went wrong last time, as the user would read it.
82 pub last_error: Option<String>,
83}
84
85/// Access to the outbox table.
86pub struct ServerOutbox {
87 pub conn: Connection,
88}
89
90impl ServerOutbox {
91 /// Opens the database and ensures the outbox table exists.
92 pub fn new() -> Result<Self> {
93 let db = Db::new()?;
94 db.conn.execute(SCHEMA_SERVER_OUTBOX, [])?;
95 Ok(ServerOutbox { conn: db.conn })
96 }
97
98 /// Records `date` as owed, or notes another failed attempt at it.
99 ///
100 /// Safe to call for a date already queued: the row is updated rather than
101 /// duplicated.
102 pub fn enqueue(&mut self, date: NaiveDate, error: &str) -> Result<()> {
103 self.conn.execute(ENQUEUE, rusqlite::params![date, error])?;
104 Ok(())
105 }
106
107 /// Every day still owed, oldest first.
108 ///
109 /// Oldest first because a backlog is delivered in the order it happened:
110 /// a dashboard filling in from the far end reads as a machine catching
111 /// up, one filling in at random reads as a machine malfunctioning.
112 pub fn pending(&self) -> Result<Vec<OwedDay>> {
113 let mut statement = self.conn.prepare(SELECT_PENDING)?;
114 let rows = statement.query_map([], |row| {
115 Ok(OwedDay {
116 id: row.get(0)?,
117 date: row.get(1)?,
118 queued_at: row.get(2)?,
119 attempts: row.get(3)?,
120 last_attempt_at: row.get(4)?,
121 last_error: row.get(5)?,
122 })
123 })?;
124
125 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
126 }
127
128 /// How many days are owed.
129 pub fn count(&self) -> Result<i64> {
130 Ok(self.conn.query_row(COUNT_PENDING, [], |row| row.get(0))?)
131 }
132
133 /// Forgets `date`, whether it was delivered or given up on.
134 ///
135 /// One method for both outcomes on purpose: the queue's business is
136 /// whether a day is still owed, and a day the server will never accept is
137 /// no more owed than one it has taken. Which of the two happened is said
138 /// out loud by the caller, where the user can see it.
139 pub fn remove(&mut self, date: NaiveDate) -> Result<bool> {
140 Ok(self.conn.execute(DELETE_BY_DATE, rusqlite::params![date])? > 0)
141 }
142}