use serde::{Deserialize, Serialize};
pub const RUN_JOURNAL_TABLE: &str = "_faucet_run_journal";
pub const PREVIOUS_TABLE_SUFFIX: &str = "__faucet_prev";
pub const DEFAULT_RUN_ID_COLUMN: &str = "_faucet_run_id";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RollbackWriteSpec {
pub run_id: String,
pub run_id_column: String,
#[serde(default)]
pub journal: bool,
#[serde(default)]
pub keep_previous: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RollbackMode {
Append,
Upsert,
Overwrite,
}
impl RollbackMode {
pub fn for_write_mode(mode: crate::write_mode::WriteMode) -> Self {
match mode {
crate::write_mode::WriteMode::Append => RollbackMode::Append,
crate::write_mode::WriteMode::Upsert | crate::write_mode::WriteMode::Delete => {
RollbackMode::Upsert
}
crate::write_mode::WriteMode::Overwrite => RollbackMode::Overwrite,
}
}
pub fn as_str(self) -> &'static str {
match self {
RollbackMode::Append => "append",
RollbackMode::Upsert => "upsert",
RollbackMode::Overwrite => "overwrite",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RollbackOptions {
pub run_id_column: String,
pub mode: RollbackMode,
pub force: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RollbackOutcome {
pub deleted: u64,
pub restored: u64,
pub conflicts: u64,
pub applied: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl RollbackOutcome {
pub fn nothing(note: impl Into<String>) -> Self {
Self {
applied: true,
note: Some(note.into()),
..Default::default()
}
}
pub fn blocked(conflicts: u64) -> Self {
Self {
conflicts,
applied: false,
note: Some(format!(
"{conflicts} key(s) were changed by a later run; pass --force to restore them anyway"
)),
..Default::default()
}
}
}
pub fn key_json(tuple: &crate::write_mode::KeyTuple) -> String {
let parts: Vec<String> = tuple
.0
.iter()
.map(|(k, v)| format!("{}:{}", serde_json::Value::String(k.clone()), v))
.collect();
format!("{{{}}}", parts.join(","))
}
pub fn canonical_key(tuple: &crate::write_mode::KeyTuple) -> crate::write_mode::KeyTuple {
crate::write_mode::KeyTuple(
tuple
.0
.iter()
.map(|(k, v)| {
let v = match v {
serde_json::Value::Null => serde_json::Value::Null,
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
serde_json::Value::Bool(b) => serde_json::Value::String(b.to_string()),
serde_json::Value::Number(n) => serde_json::Value::String(n.to_string()),
other => serde_json::Value::String(other.to_string()),
};
(k.clone(), v)
})
.collect(),
)
}
pub fn plan_keys(
plan: &crate::write_mode::WritePlan,
key: &[String],
) -> Vec<crate::write_mode::KeyTuple> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(plan.upserts.len() + plan.deletes.len());
let upserts = plan
.upserts
.iter()
.filter_map(|r| crate::write_mode::record_key(r, key));
for t in upserts.chain(plan.deletes.iter().cloned()) {
let c = canonical_key(&t);
if seen.insert(key_json(&c)) {
out.push(c);
}
}
out
}
#[derive(Clone, Copy)]
pub struct JournalSql {
pub quote: fn(&str) -> String,
pub placeholder: fn(usize) -> String,
pub before_type: &'static str,
pub insert_prefix: &'static str,
pub insert_suffix: &'static str,
pub now: &'static str,
pub key_column: &'static str,
pub primary_key: &'static str,
}
pub const KEY_COLUMN_TEXT: &str = "key_json TEXT NOT NULL";
pub const PRIMARY_KEY_TEXT: &str = "PRIMARY KEY (run_id, table_name, key_json)";
impl JournalSql {
fn t(&self) -> String {
(self.quote)(RUN_JOURNAL_TABLE)
}
pub fn create(&self) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {t} (run_id VARCHAR(64) NOT NULL, table_name VARCHAR(255) NOT NULL, \
{key}, before_json {bt}, recorded_at TIMESTAMP DEFAULT {now}, {pk})",
t = self.t(),
key = self.key_column,
bt = self.before_type,
now = self.now,
pk = self.primary_key,
)
}
pub fn insert(&self, rows: usize) -> String {
let mut n = 0usize;
let tuples: Vec<String> = (0..rows)
.map(|_| {
let ph: Vec<String> = (0..4)
.map(|_| {
n += 1;
(self.placeholder)(n)
})
.collect();
format!("({})", ph.join(", "))
})
.collect();
format!(
"{prefix} INTO {t} (run_id, table_name, key_json, before_json) VALUES {v}{suffix}",
prefix = self.insert_prefix,
t = self.t(),
v = tuples.join(", "),
suffix = self.insert_suffix,
)
}
pub fn select(&self) -> String {
format!(
"SELECT key_json, before_json FROM {t} WHERE run_id = {p1} AND table_name = {p2}",
t = self.t(),
p1 = (self.placeholder)(1),
p2 = (self.placeholder)(2),
)
}
pub fn delete_table(&self) -> String {
format!(
"DELETE FROM {t} WHERE run_id = {p1} AND table_name = {p2}",
t = self.t(),
p1 = (self.placeholder)(1),
p2 = (self.placeholder)(2),
)
}
pub fn keys_in(&self, key: &[String], tuples: usize, start: usize) -> (String, usize) {
let cols: Vec<String> = key.iter().map(|k| (self.quote)(k)).collect();
let mut n = start;
let groups: Vec<String> = (0..tuples)
.map(|_| {
let ph: Vec<String> = key
.iter()
.map(|_| {
n += 1;
(self.placeholder)(n)
})
.collect();
format!("({})", ph.join(", "))
})
.collect();
(
format!("({}) IN ({})", cols.join(", "), groups.join(", ")),
n,
)
}
pub fn delete_by_run(&self, table_ref: &str, run_id_col: &str) -> String {
format!(
"DELETE FROM {table_ref} WHERE {c} = {p}",
c = (self.quote)(run_id_col),
p = (self.placeholder)(1)
)
}
pub fn count_by_run(&self, table_ref: &str, run_id_col: &str) -> String {
format!(
"SELECT count(*) FROM {table_ref} WHERE {c} = {p}",
c = (self.quote)(run_id_col),
p = (self.placeholder)(1)
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct JournalEntry {
pub key: serde_json::Map<String, serde_json::Value>,
pub before: Option<serde_json::Value>,
}
impl JournalEntry {
pub fn decode(key_json: &str, before_json: Option<&str>) -> Result<Self, crate::FaucetError> {
let key: serde_json::Value = serde_json::from_str(key_json).map_err(|e| {
crate::FaucetError::Sink(format!("rollback: malformed journal key {key_json:?}: {e}"))
})?;
let key = match key {
serde_json::Value::Object(m) => m,
other => {
return Err(crate::FaucetError::Sink(format!(
"rollback: journal key is not an object: {other}"
)));
}
};
let before = match before_json {
None => None,
Some(b) => Some(serde_json::from_str(b).map_err(|e| {
crate::FaucetError::Sink(format!("rollback: malformed before-image: {e}"))
})?),
};
Ok(Self { key, before })
}
pub fn tuple(&self, key: &[String]) -> crate::write_mode::KeyTuple {
crate::write_mode::KeyTuple(
key.iter()
.map(|k| {
(
k.clone(),
self.key.get(k).cloned().unwrap_or(serde_json::Value::Null),
)
})
.collect(),
)
}
}
pub fn plan_restore(entries: &[JournalEntry]) -> (Vec<&JournalEntry>, Vec<serde_json::Value>) {
let mut deletes = Vec::new();
let mut restores = Vec::new();
for e in entries {
match &e.before {
None => deletes.push(e),
Some(b) => restores.push(b.clone()),
}
}
(deletes, restores)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::write_mode::WriteMode;
fn q(s: &str) -> String {
format!("\"{s}\"")
}
fn ph(n: usize) -> String {
format!("${n}")
}
fn sql() -> JournalSql {
JournalSql {
quote: q,
placeholder: ph,
before_type: "JSONB",
insert_prefix: "INSERT",
insert_suffix: " ON CONFLICT DO NOTHING",
now: "now()",
key_column: KEY_COLUMN_TEXT,
primary_key: PRIMARY_KEY_TEXT,
}
}
#[test]
fn journal_sql_shapes() {
let j = sql();
assert!(
j.create()
.starts_with("CREATE TABLE IF NOT EXISTS \"_faucet_run_journal\"")
);
assert!(j.create().contains("before_json JSONB"));
assert!(j.create().contains("key_json TEXT NOT NULL"));
assert!(
j.create()
.ends_with("PRIMARY KEY (run_id, table_name, key_json))"),
"{}",
j.create()
);
let ins = j.insert(2);
assert!(
ins.starts_with("INSERT INTO \"_faucet_run_journal\""),
"{ins}"
);
assert!(ins.contains("($1, $2, $3, $4), ($5, $6, $7, $8)"), "{ins}");
assert!(ins.ends_with(" ON CONFLICT DO NOTHING"));
assert_eq!(
j.select(),
"SELECT key_json, before_json FROM \"_faucet_run_journal\" WHERE run_id = $1 AND table_name = $2"
);
assert!(
j.delete_table()
.starts_with("DELETE FROM \"_faucet_run_journal\" WHERE run_id = $1")
);
let (pred, next) = j.keys_in(&["a".into(), "b".into()], 2, 1);
assert_eq!(pred, "(\"a\", \"b\") IN (($2, $3), ($4, $5))");
assert_eq!(next, 5);
assert_eq!(
j.delete_by_run("\"t\"", "_faucet_run_id"),
"DELETE FROM \"t\" WHERE \"_faucet_run_id\" = $1"
);
assert_eq!(
j.count_by_run("\"t\"", "_faucet_run_id"),
"SELECT count(*) FROM \"t\" WHERE \"_faucet_run_id\" = $1"
);
}
#[test]
fn journal_entries_decode_and_plan() {
let created = JournalEntry::decode("{\"id\":1}", None).unwrap();
let changed = JournalEntry::decode("{\"id\":2}", Some("{\"id\":2,\"v\":\"old\"}")).unwrap();
assert_eq!(
created.tuple(&["id".into()]).0,
vec![("id".to_string(), serde_json::json!(1))]
);
assert_eq!(
created.tuple(&["id".into(), "missing".into()]).0[1],
("missing".to_string(), serde_json::Value::Null)
);
let entries = [created.clone(), changed.clone()];
let (deletes, restores) = plan_restore(&entries);
assert_eq!(deletes, vec![&created]);
assert_eq!(restores, vec![serde_json::json!({"id": 2, "v": "old"})]);
assert!(JournalEntry::decode("nope", None).is_err());
assert!(JournalEntry::decode("[1]", None).is_err());
assert!(JournalEntry::decode("{\"id\":1}", Some("{broken")).is_err());
let kt = crate::write_mode::KeyTuple(vec![
("b".into(), serde_json::json!(2)),
("a".into(), serde_json::json!("x")),
]);
assert_eq!(
key_json(&kt),
"{\"b\":2,\"a\":\"x\"}",
"configured key order, not sorted"
);
}
#[test]
fn plan_keys_are_canonical_and_deduplicated() {
let spec = crate::write_mode::WriteSpec {
write_mode: WriteMode::Upsert,
key: vec!["id".into()],
..Default::default()
};
let page = vec![
serde_json::json!({"id": 7, "v": 1}),
serde_json::json!({"id": "7", "v": 2}),
serde_json::json!({"id": true}),
serde_json::json!({"id": null}),
];
let plan = crate::write_mode::plan_writes(&page, &spec);
let keys = plan_keys(&plan, &spec.key);
assert_eq!(keys.len(), 2);
assert_eq!(key_json(&keys[0]), "{\"id\":\"7\"}");
assert_eq!(key_json(&keys[1]), "{\"id\":\"true\"}");
let nested = canonical_key(&crate::write_mode::KeyTuple(vec![
("k".into(), serde_json::json!([1])),
("n".into(), serde_json::Value::Null),
]));
assert_eq!(nested.0[0].1, serde_json::json!("[1]"));
assert_eq!(nested.0[1].1, serde_json::Value::Null);
let del = crate::write_mode::WriteSpec {
write_mode: WriteMode::Delete,
key: vec!["id".into()],
..Default::default()
};
let plan = crate::write_mode::plan_writes(&[serde_json::json!({"id": 3})], &del);
assert_eq!(key_json(&plan_keys(&plan, &del.key)[0]), "{\"id\":\"3\"}");
}
#[test]
fn mode_follows_the_write_mode() {
assert_eq!(
RollbackMode::for_write_mode(WriteMode::Append),
RollbackMode::Append
);
assert_eq!(
RollbackMode::for_write_mode(WriteMode::Upsert),
RollbackMode::Upsert
);
assert_eq!(
RollbackMode::for_write_mode(WriteMode::Delete),
RollbackMode::Upsert
);
assert_eq!(
RollbackMode::for_write_mode(WriteMode::Overwrite),
RollbackMode::Overwrite
);
assert_eq!(RollbackMode::Upsert.as_str(), "upsert");
assert_eq!(
serde_json::to_string(&RollbackMode::Overwrite).unwrap(),
"\"overwrite\""
);
}
#[test]
fn outcomes_carry_their_reason() {
let n = RollbackOutcome::nothing("no rows");
assert!(n.applied && n.deleted == 0);
let b = RollbackOutcome::blocked(3);
assert!(!b.applied && b.conflicts == 3);
assert!(b.note.unwrap().contains("--force"));
let spec: RollbackWriteSpec = serde_json::from_value(serde_json::json!({
"run_id": "r1", "run_id_column": "_faucet_run_id"
}))
.unwrap();
assert!(!spec.journal && !spec.keep_previous);
assert!(
serde_json::from_value::<RollbackWriteSpec>(serde_json::json!({
"run_id": "r1", "run_id_column": "c", "bogus": 1
}))
.is_err()
);
}
}