1use serde::{Deserialize, Serialize};
23
24pub const RUN_JOURNAL_TABLE: &str = "_faucet_run_journal";
28
29pub const PREVIOUS_TABLE_SUFFIX: &str = "__faucet_prev";
32
33pub const DEFAULT_RUN_ID_COLUMN: &str = "_faucet_run_id";
36
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct RollbackWriteSpec {
44 pub run_id: String,
46 pub run_id_column: String,
49 #[serde(default)]
51 pub journal: bool,
52 #[serde(default)]
54 pub keep_previous: bool,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum RollbackMode {
61 Append,
63 Upsert,
65 Overwrite,
67}
68
69impl RollbackMode {
70 pub fn for_write_mode(mode: crate::write_mode::WriteMode) -> Self {
72 match mode {
73 crate::write_mode::WriteMode::Append => RollbackMode::Append,
74 crate::write_mode::WriteMode::Upsert | crate::write_mode::WriteMode::Delete => {
75 RollbackMode::Upsert
76 }
77 crate::write_mode::WriteMode::Overwrite => RollbackMode::Overwrite,
78 }
79 }
80
81 pub fn as_str(self) -> &'static str {
82 match self {
83 RollbackMode::Append => "append",
84 RollbackMode::Upsert => "upsert",
85 RollbackMode::Overwrite => "overwrite",
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct RollbackOptions {
93 pub run_id_column: String,
95 pub mode: RollbackMode,
96 pub force: bool,
100 pub dry_run: bool,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106pub struct RollbackOutcome {
107 pub deleted: u64,
109 pub restored: u64,
112 pub conflicts: u64,
115 pub applied: bool,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub note: Option<String>,
121}
122
123impl RollbackOutcome {
124 pub fn nothing(note: impl Into<String>) -> Self {
126 Self {
127 applied: true,
128 note: Some(note.into()),
129 ..Default::default()
130 }
131 }
132
133 pub fn blocked(conflicts: u64) -> Self {
135 Self {
136 conflicts,
137 applied: false,
138 note: Some(format!(
139 "{conflicts} key(s) were changed by a later run; pass --force to restore them anyway"
140 )),
141 ..Default::default()
142 }
143 }
144}
145
146pub fn key_json(tuple: &crate::write_mode::KeyTuple) -> String {
150 let parts: Vec<String> = tuple
153 .0
154 .iter()
155 .map(|(k, v)| format!("{}:{}", serde_json::Value::String(k.clone()), v))
156 .collect();
157 format!("{{{}}}", parts.join(","))
158}
159
160pub fn canonical_key(tuple: &crate::write_mode::KeyTuple) -> crate::write_mode::KeyTuple {
165 crate::write_mode::KeyTuple(
166 tuple
167 .0
168 .iter()
169 .map(|(k, v)| {
170 let v = match v {
171 serde_json::Value::Null => serde_json::Value::Null,
172 serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
173 serde_json::Value::Bool(b) => serde_json::Value::String(b.to_string()),
174 serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()),
175 other => serde_json::Value::String(other.to_string()),
176 };
177 (k.clone(), v)
178 })
179 .collect(),
180 )
181}
182
183pub fn plan_keys(
187 plan: &crate::write_mode::WritePlan,
188 key: &[String],
189) -> Vec<crate::write_mode::KeyTuple> {
190 let mut seen = std::collections::HashSet::new();
191 let mut out = Vec::with_capacity(plan.upserts.len() + plan.deletes.len());
192 let upserts = plan
193 .upserts
194 .iter()
195 .filter_map(|r| crate::write_mode::record_key(r, key));
196 for t in upserts.chain(plan.deletes.iter().cloned()) {
197 let c = canonical_key(&t);
198 if seen.insert(key_json(&c)) {
199 out.push(c);
200 }
201 }
202 out
203}
204
205#[derive(Clone, Copy)]
215pub struct JournalSql {
216 pub quote: fn(&str) -> String,
217 pub placeholder: fn(usize) -> String,
218 pub before_type: &'static str,
219 pub insert_prefix: &'static str,
220 pub insert_suffix: &'static str,
221 pub now: &'static str,
222 pub key_column: &'static str,
226 pub primary_key: &'static str,
228}
229
230pub const KEY_COLUMN_TEXT: &str = "key_json TEXT NOT NULL";
232pub const PRIMARY_KEY_TEXT: &str = "PRIMARY KEY (run_id, table_name, key_json)";
234
235impl JournalSql {
236 fn t(&self) -> String {
237 (self.quote)(RUN_JOURNAL_TABLE)
238 }
239
240 pub fn create(&self) -> String {
242 format!(
243 "CREATE TABLE IF NOT EXISTS {t} (run_id VARCHAR(64) NOT NULL, table_name VARCHAR(255) NOT NULL, \
244 {key}, before_json {bt}, recorded_at TIMESTAMP DEFAULT {now}, {pk})",
245 t = self.t(),
246 key = self.key_column,
247 bt = self.before_type,
248 now = self.now,
249 pk = self.primary_key,
250 )
251 }
252
253 pub fn insert(&self, rows: usize) -> String {
257 let mut n = 0usize;
258 let tuples: Vec<String> = (0..rows)
259 .map(|_| {
260 let ph: Vec<String> = (0..4)
261 .map(|_| {
262 n += 1;
263 (self.placeholder)(n)
264 })
265 .collect();
266 format!("({})", ph.join(", "))
267 })
268 .collect();
269 format!(
270 "{prefix} INTO {t} (run_id, table_name, key_json, before_json) VALUES {v}{suffix}",
271 prefix = self.insert_prefix,
272 t = self.t(),
273 v = tuples.join(", "),
274 suffix = self.insert_suffix,
275 )
276 }
277
278 pub fn select(&self) -> String {
280 format!(
281 "SELECT key_json, before_json FROM {t} WHERE run_id = {p1} AND table_name = {p2}",
282 t = self.t(),
283 p1 = (self.placeholder)(1),
284 p2 = (self.placeholder)(2),
285 )
286 }
287
288 pub fn delete_table(&self) -> String {
290 format!(
291 "DELETE FROM {t} WHERE run_id = {p1} AND table_name = {p2}",
292 t = self.t(),
293 p1 = (self.placeholder)(1),
294 p2 = (self.placeholder)(2),
295 )
296 }
297
298 pub fn keys_in(&self, key: &[String], tuples: usize, start: usize) -> (String, usize) {
302 let cols: Vec<String> = key.iter().map(|k| (self.quote)(k)).collect();
303 let mut n = start;
304 let groups: Vec<String> = (0..tuples)
305 .map(|_| {
306 let ph: Vec<String> = key
307 .iter()
308 .map(|_| {
309 n += 1;
310 (self.placeholder)(n)
311 })
312 .collect();
313 format!("({})", ph.join(", "))
314 })
315 .collect();
316 (
317 format!("({}) IN ({})", cols.join(", "), groups.join(", ")),
318 n,
319 )
320 }
321
322 pub fn delete_by_run(&self, table_ref: &str, run_id_col: &str) -> String {
324 format!(
325 "DELETE FROM {table_ref} WHERE {c} = {p}",
326 c = (self.quote)(run_id_col),
327 p = (self.placeholder)(1)
328 )
329 }
330
331 pub fn count_by_run(&self, table_ref: &str, run_id_col: &str) -> String {
333 format!(
334 "SELECT count(*) FROM {table_ref} WHERE {c} = {p}",
335 c = (self.quote)(run_id_col),
336 p = (self.placeholder)(1)
337 )
338 }
339}
340
341#[derive(Debug, Clone, PartialEq)]
344pub struct JournalEntry {
345 pub key: serde_json::Map<String, serde_json::Value>,
346 pub before: Option<serde_json::Value>,
347}
348
349impl JournalEntry {
350 pub fn decode(key_json: &str, before_json: Option<&str>) -> Result<Self, crate::FaucetError> {
352 let key: serde_json::Value = serde_json::from_str(key_json).map_err(|e| {
353 crate::FaucetError::Sink(format!("rollback: malformed journal key {key_json:?}: {e}"))
354 })?;
355 let key = match key {
356 serde_json::Value::Object(m) => m,
357 other => {
358 return Err(crate::FaucetError::Sink(format!(
359 "rollback: journal key is not an object: {other}"
360 )));
361 }
362 };
363 let before = match before_json {
364 None => None,
365 Some(b) => Some(serde_json::from_str(b).map_err(|e| {
366 crate::FaucetError::Sink(format!("rollback: malformed before-image: {e}"))
367 })?),
368 };
369 Ok(Self { key, before })
370 }
371
372 pub fn tuple(&self, key: &[String]) -> crate::write_mode::KeyTuple {
374 crate::write_mode::KeyTuple(
375 key.iter()
376 .map(|k| {
377 (
378 k.clone(),
379 self.key.get(k).cloned().unwrap_or(serde_json::Value::Null),
380 )
381 })
382 .collect(),
383 )
384 }
385}
386
387pub fn plan_restore(entries: &[JournalEntry]) -> (Vec<&JournalEntry>, Vec<serde_json::Value>) {
391 let mut deletes = Vec::new();
392 let mut restores = Vec::new();
393 for e in entries {
394 match &e.before {
395 None => deletes.push(e),
396 Some(b) => restores.push(b.clone()),
397 }
398 }
399 (deletes, restores)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::write_mode::WriteMode;
406
407 fn q(s: &str) -> String {
408 format!("\"{s}\"")
409 }
410 fn ph(n: usize) -> String {
411 format!("${n}")
412 }
413 fn sql() -> JournalSql {
414 JournalSql {
415 quote: q,
416 placeholder: ph,
417 before_type: "JSONB",
418 insert_prefix: "INSERT",
419 insert_suffix: " ON CONFLICT DO NOTHING",
420 now: "now()",
421 key_column: KEY_COLUMN_TEXT,
422 primary_key: PRIMARY_KEY_TEXT,
423 }
424 }
425
426 #[test]
427 fn journal_sql_shapes() {
428 let j = sql();
429 assert!(
430 j.create()
431 .starts_with("CREATE TABLE IF NOT EXISTS \"_faucet_run_journal\"")
432 );
433 assert!(j.create().contains("before_json JSONB"));
434 assert!(j.create().contains("key_json TEXT NOT NULL"));
435 assert!(
436 j.create()
437 .ends_with("PRIMARY KEY (run_id, table_name, key_json))"),
438 "{}",
439 j.create()
440 );
441 let ins = j.insert(2);
442 assert!(
443 ins.starts_with("INSERT INTO \"_faucet_run_journal\""),
444 "{ins}"
445 );
446 assert!(ins.contains("($1, $2, $3, $4), ($5, $6, $7, $8)"), "{ins}");
447 assert!(ins.ends_with(" ON CONFLICT DO NOTHING"));
448 assert_eq!(
449 j.select(),
450 "SELECT key_json, before_json FROM \"_faucet_run_journal\" WHERE run_id = $1 AND table_name = $2"
451 );
452 assert!(
453 j.delete_table()
454 .starts_with("DELETE FROM \"_faucet_run_journal\" WHERE run_id = $1")
455 );
456 let (pred, next) = j.keys_in(&["a".into(), "b".into()], 2, 1);
457 assert_eq!(pred, "(\"a\", \"b\") IN (($2, $3), ($4, $5))");
458 assert_eq!(next, 5);
459 assert_eq!(
460 j.delete_by_run("\"t\"", "_faucet_run_id"),
461 "DELETE FROM \"t\" WHERE \"_faucet_run_id\" = $1"
462 );
463 assert_eq!(
464 j.count_by_run("\"t\"", "_faucet_run_id"),
465 "SELECT count(*) FROM \"t\" WHERE \"_faucet_run_id\" = $1"
466 );
467 }
468
469 #[test]
470 fn journal_entries_decode_and_plan() {
471 let created = JournalEntry::decode("{\"id\":1}", None).unwrap();
472 let changed = JournalEntry::decode("{\"id\":2}", Some("{\"id\":2,\"v\":\"old\"}")).unwrap();
473 assert_eq!(
474 created.tuple(&["id".into()]).0,
475 vec![("id".to_string(), serde_json::json!(1))]
476 );
477 assert_eq!(
478 created.tuple(&["id".into(), "missing".into()]).0[1],
479 ("missing".to_string(), serde_json::Value::Null)
480 );
481 let entries = [created.clone(), changed.clone()];
482 let (deletes, restores) = plan_restore(&entries);
483 assert_eq!(deletes, vec![&created]);
484 assert_eq!(restores, vec![serde_json::json!({"id": 2, "v": "old"})]);
485 assert!(JournalEntry::decode("nope", None).is_err());
486 assert!(JournalEntry::decode("[1]", None).is_err());
487 assert!(JournalEntry::decode("{\"id\":1}", Some("{broken")).is_err());
488 let kt = crate::write_mode::KeyTuple(vec![
489 ("b".into(), serde_json::json!(2)),
490 ("a".into(), serde_json::json!("x")),
491 ]);
492 assert_eq!(
493 key_json(&kt),
494 "{\"b\":2,\"a\":\"x\"}",
495 "configured key order, not sorted"
496 );
497 }
498
499 #[test]
500 fn plan_keys_are_canonical_and_deduplicated() {
501 let spec = crate::write_mode::WriteSpec {
502 write_mode: WriteMode::Upsert,
503 key: vec!["id".into()],
504 ..Default::default()
505 };
506 let page = vec![
507 serde_json::json!({"id": 7, "v": 1}),
508 serde_json::json!({"id": "7", "v": 2}),
509 serde_json::json!({"id": true}),
510 serde_json::json!({"id": null}),
511 ];
512 let plan = crate::write_mode::plan_writes(&page, &spec);
513 let keys = plan_keys(&plan, &spec.key);
514 assert_eq!(keys.len(), 2);
516 assert_eq!(key_json(&keys[0]), "{\"id\":\"7\"}");
517 assert_eq!(key_json(&keys[1]), "{\"id\":\"true\"}");
518 let nested = canonical_key(&crate::write_mode::KeyTuple(vec![
519 ("k".into(), serde_json::json!([1])),
520 ("n".into(), serde_json::Value::Null),
521 ]));
522 assert_eq!(nested.0[0].1, serde_json::json!("[1]"));
523 assert_eq!(nested.0[1].1, serde_json::Value::Null);
524 let del = crate::write_mode::WriteSpec {
526 write_mode: WriteMode::Delete,
527 key: vec!["id".into()],
528 ..Default::default()
529 };
530 let plan = crate::write_mode::plan_writes(&[serde_json::json!({"id": 3})], &del);
531 assert_eq!(key_json(&plan_keys(&plan, &del.key)[0]), "{\"id\":\"3\"}");
532 }
533
534 #[test]
535 fn mode_follows_the_write_mode() {
536 assert_eq!(
537 RollbackMode::for_write_mode(WriteMode::Append),
538 RollbackMode::Append
539 );
540 assert_eq!(
541 RollbackMode::for_write_mode(WriteMode::Upsert),
542 RollbackMode::Upsert
543 );
544 assert_eq!(
545 RollbackMode::for_write_mode(WriteMode::Delete),
546 RollbackMode::Upsert
547 );
548 assert_eq!(
549 RollbackMode::for_write_mode(WriteMode::Overwrite),
550 RollbackMode::Overwrite
551 );
552 assert_eq!(RollbackMode::Upsert.as_str(), "upsert");
553 assert_eq!(
554 serde_json::to_string(&RollbackMode::Overwrite).unwrap(),
555 "\"overwrite\""
556 );
557 }
558
559 #[test]
560 fn outcomes_carry_their_reason() {
561 let n = RollbackOutcome::nothing("no rows");
562 assert!(n.applied && n.deleted == 0);
563 let b = RollbackOutcome::blocked(3);
564 assert!(!b.applied && b.conflicts == 3);
565 assert!(b.note.unwrap().contains("--force"));
566 let spec: RollbackWriteSpec = serde_json::from_value(serde_json::json!({
567 "run_id": "r1", "run_id_column": "_faucet_run_id"
568 }))
569 .unwrap();
570 assert!(!spec.journal && !spec.keep_previous);
571 assert!(
572 serde_json::from_value::<RollbackWriteSpec>(serde_json::json!({
573 "run_id": "r1", "run_id_column": "c", "bogus": 1
574 }))
575 .is_err()
576 );
577 }
578}