use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use clap::{Args, Parser, Subcommand, ValueEnum};
use adya::gen::{GenOpts, Kind};
use adya::run::{Client, RunOpts};
use adya::{check, Error, History, Opts, Report, Valid, Workload};
#[derive(Parser)]
#[command(name = "adya", version, about = "Find isolation anomalies in database transaction histories")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Clone, Copy, ValueEnum)]
enum Model {
ListAppend,
RwRegister,
}
impl Model {
fn workload(self) -> Workload {
match self {
Model::ListAppend => Workload::ListAppend,
Model::RwRegister => Workload::RwRegister,
}
}
}
#[derive(Args)]
struct CheckArgs {
#[arg(short, long, value_enum, default_value = "list-append")]
model: Model,
#[arg(short = 'c', long = "consistency-models", default_value = "strict-serializable", value_delimiter = ',')]
consistency_models: Vec<String>,
#[arg(short, long, value_delimiter = ',')]
anomalies: Vec<String>,
#[arg(long = "cycle-search-timeout", default_value_t = 1000)]
timeout_ms: u64,
#[arg(long)]
wfr_keys: bool,
#[arg(long)]
sequential_keys: bool,
#[arg(long)]
linearizable_keys: bool,
#[arg(long, value_name = "FILE")]
transaction_order: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Clone, Copy, ValueEnum, PartialEq, Eq)]
enum Target {
Sim,
Sqlite,
Postgres,
Mysql,
Exec,
}
#[derive(Subcommand)]
enum Cmd {
Check {
#[arg(required = true)]
files: Vec<PathBuf>,
#[command(flatten)]
check: CheckArgs,
},
Run {
#[arg(value_enum)]
target: Target,
#[arg(long)]
url: Option<String>,
#[arg(short, long, default_value = "serializable")]
isolation: String,
#[arg(short, long, default_value_t = 8)]
processes: usize,
#[arg(short = 'n', long, default_value_t = 2000)]
txns: usize,
#[arg(long)]
time_limit: Option<u64>,
#[arg(long, default_value_t = 8)]
keys: usize,
#[arg(long, default_value_t = 32)]
max_writes_per_key: u64,
#[arg(long, default_value_t = 0)]
seed: u64,
#[arg(long, requires = "heal")]
fault: Option<String>,
#[arg(long, requires = "fault")]
heal: Option<String>,
#[arg(long, default_value_t = 10)]
fault_every: u64,
#[arg(long, default_value_t = 5)]
fault_for: u64,
#[arg(short, long, default_value = "history.jsonl")]
out: PathBuf,
#[command(flatten)]
check: CheckArgs,
},
}
fn main() -> ExitCode {
let code = match Cli::parse().cmd {
Cmd::Check { files, check } => check_files(&files, &check),
Cmd::Run {
target,
url,
isolation,
processes,
txns,
time_limit,
keys,
max_writes_per_key,
seed,
fault,
heal,
fault_every,
fault_for,
out,
check,
} => {
let kind = match check.model {
Model::ListAppend => Kind::ListAppend,
Model::RwRegister => Kind::RwRegister,
};
let gen = GenOpts { kind, key_count: keys.max(1), max_writes_per_key, ..GenOpts::default() };
let opts = RunOpts {
processes: processes.max(1),
txns,
time_limit: time_limit.map(Duration::from_secs),
gen,
seed,
nemesis: fault.zip(heal).map(|(fault, heal)| adya::run::Nemesis {
fault,
heal,
every: Duration::from_secs(fault_every),
duration: Duration::from_secs(fault_for),
}),
};
match record(target, url, &isolation, &opts) {
Err(e) => {
eprintln!("error: {e}");
3
}
Ok(history) => match std::fs::write(&out, history) {
Err(e) => {
eprintln!("error: writing {}: {e}", out.display());
3
}
Ok(()) => {
eprintln!("wrote {}", out.display());
check_files(&[out], &check)
}
},
}
}
};
ExitCode::from(code)
}
fn record(target: Target, url: Option<String>, isolation: &str, opts: &RunOpts) -> Result<String, Error> {
#[allow(unused_variables)] let list = opts.gen.kind == Kind::ListAppend;
if target == Target::Sim {
let iso = adya::sim::Isolation::parse(isolation)
.ok_or_else(|| Error::new(format!("unknown sim isolation {isolation:?}")))?;
let sim = adya::sim::SimOpts {
isolation: iso,
processes: opts.processes,
txns: opts.txns,
gen: opts.gen.clone(),
seed: opts.seed,
..Default::default()
};
return Ok(adya::sim::run(&sim));
}
let url = url.ok_or_else(|| Error::new("--url is required for this target"))?;
let connect: Box<dyn Fn() -> Result<Box<dyn Client>, Error> + Sync> = match target {
Target::Sim => unreachable!(),
#[cfg(feature = "sqlite")]
Target::Sqlite => {
adya::db::sqlite::prepare(&url)?;
let mode = if isolation == "serializable" { "deferred".to_string() } else { isolation.to_string() };
Box::new(move || adya::db::sqlite::connect(&url, &mode, list))
}
#[cfg(feature = "postgres")]
Target::Postgres => {
adya::db::postgres::prepare(&url)?;
let level = isolation.to_string();
Box::new(move || adya::db::postgres::connect(&url, &level, list))
}
#[cfg(feature = "mysql")]
Target::Mysql => {
adya::db::mysql::prepare(&url)?;
let level = isolation.to_string();
Box::new(move || adya::db::mysql::connect(&url, &level, list))
}
Target::Exec => Box::new(move || adya::db::exec::connect(&url)),
#[allow(unreachable_patterns)]
_ => return Err(Error::new("this build of adya was compiled without that driver")),
};
let (history, stats) = adya::run::run(&*connect, opts)?;
eprintln!("{} ok, {} failed, {} indeterminate", stats.ok, stats.fail, stats.info);
Ok(history)
}
fn check_files(files: &[PathBuf], args: &CheckArgs) -> u8 {
let mut models = Vec::new();
for m in &args.consistency_models {
match adya::model::canonical(m) {
Some(c) => models.push(c.to_string()),
None => {
eprintln!("unknown consistency model {m:?}; known models:");
eprintln!(" {}", adya::model::all_models().into_iter().collect::<Vec<_>>().join(", "));
return 3;
}
}
}
let transaction_order = match &args.transaction_order {
None => None,
Some(p) => {
let parsed = std::fs::read_to_string(p)
.map_err(|e| e.to_string())
.and_then(|t| {
serde_json::from_str::<std::collections::HashMap<String, i64>>(&t).map_err(|e| e.to_string())
})
.and_then(|m| {
m.into_iter().map(|(k, v)| k.parse::<u64>().map(|k| (k, v)).map_err(|e| e.to_string())).collect()
});
match parsed {
Ok(m) => Some(m),
Err(e) => {
eprintln!("{}: {e}", p.display());
return 3;
}
}
}
};
let opts = Opts {
transaction_order,
models,
anomalies: args.anomalies.clone(),
timeout: Duration::from_millis(args.timeout_ms),
wfr_keys: args.wfr_keys,
sequential_keys: args.sequential_keys,
linearizable_keys: args.linearizable_keys,
};
let mut worst = 0u8;
for f in files {
let parsed = std::fs::read_to_string(f)
.map_err(|e| e.to_string())
.and_then(|t| History::parse(&t).map_err(|e| e.to_string()));
let code = match parsed {
Err(e) => {
eprintln!("{}: {e}", f.display());
3
}
Ok(h) => {
let r = check(&h, args.model.workload(), &opts);
print(f, &r, args.json);
match r.valid {
Valid::True => 0,
Valid::False => 1,
Valid::Unknown => 2,
}
}
};
worst = worst.max(code);
}
worst
}
fn print(f: &Path, r: &Report, json: bool) {
use std::io::Write;
let mut o = std::io::stdout().lock();
if json {
let _ = writeln!(o, "{}", serde_json::to_string_pretty(r).unwrap());
return;
}
let v = match r.valid {
Valid::True => "true",
Valid::False => "false",
Valid::Unknown => "unknown",
};
let _ = writeln!(o, "{}\t{v}", f.display());
const SHOWN: usize = 3;
for (kind, list) in &r.anomalies {
for (i, a) in list.iter().take(SHOWN).enumerate() {
let _ = writeln!(o, "\n{kind} #{i}");
for line in a.explanation.lines() {
let _ = writeln!(o, " {line}");
}
}
if list.len() > SHOWN {
let _ = writeln!(o, "\n... and {} more {kind} (see --json)", list.len() - SHOWN);
}
}
if !r.anomalies.is_empty() {
let counts: Vec<String> = r.anomalies.iter().map(|(k, v)| format!("{} {k}", v.len())).collect();
let _ = writeln!(o, "\nFound {}.", counts.join(", "));
}
if !r.not.is_empty() {
let _ = writeln!(o, "\nNot {}.", r.not.join(", "));
if !r.also_not.is_empty() {
let _ = writeln!(o, "Also not {}.", r.also_not.join(", "));
}
}
}