use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use anyhow::{Context, anyhow};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use crate::earned_value::PlanBaseline;
use crate::locks::PlanState;
use crate::plan::{CallerId, DeliverableStatus, LockInfo, PlanGraph, PlanId, PlannerError};
use crate::task::CriticalPathResult;
pub const DB_PATH_ENV: &str = "CPM_PLANNER_DB";
const DEFAULT_DB_RELATIVE: [&str; 4] = [".local", "share", "praxec", "cpm-planner.db"];
pub(crate) fn home_dir_from(
lookup: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> Option<PathBuf> {
["HOME", "USERPROFILE"]
.into_iter()
.find_map(|name| lookup(name).filter(|v| !v.is_empty()))
.map(PathBuf::from)
}
pub(crate) fn home_dir() -> Option<PathBuf> {
home_dir_from(|name| std::env::var_os(name))
}
fn default_db_path_from(
lookup: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> anyhow::Result<PathBuf> {
if let Some(overridden) = lookup(DB_PATH_ENV) {
return Ok(PathBuf::from(overridden));
}
let home = home_dir_from(&lookup).ok_or_else(|| {
anyhow!(
"neither HOME nor USERPROFILE is set and {DB_PATH_ENV} was not provided; \
cannot locate database"
)
})?;
Ok(DEFAULT_DB_RELATIVE
.iter()
.fold(home, |path, part| path.join(part)))
}
pub(crate) fn backend(err: impl Into<anyhow::Error>) -> PlannerError {
PlannerError::BackendError(err.into())
}
fn dt_from_micros(us: i64, column: &str) -> Result<DateTime<Utc>, PlannerError> {
DateTime::from_timestamp_micros(us)
.ok_or_else(|| backend(anyhow!("corrupt timestamp in column {column}: {us}")))
}
pub struct SqlitePlanStore {
conn: Mutex<Connection>,
}
type PlanVariantBaseline<R> = (
(R, Option<crate::portfolio::VariantInfo>),
Option<PlanBaseline>,
);
impl SqlitePlanStore {
pub fn open(path: &Path) -> anyhow::Result<Self> {
if path.as_os_str() == ":memory:" {
return Self::open_in_memory();
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent directory for {}", path.display()))?;
}
let conn = Connection::open(path)
.with_context(|| format!("opening sqlite database at {}", path.display()))?;
Self::init(conn)
}
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory().context("opening in-memory sqlite database")?;
Self::init(conn)
}
pub fn open_default() -> anyhow::Result<Self> {
Self::open(&Self::default_db_path()?)
}
pub fn default_db_path() -> anyhow::Result<PathBuf> {
default_db_path_from(|name| std::env::var_os(name))
}
fn init(mut conn: Connection) -> anyhow::Result<Self> {
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;",
)
.context("applying sqlite pragmas")?;
migrate(&mut conn)?;
let store = Self {
conn: Mutex::new(conn),
};
store
.quarantine_expired(Utc::now())
.context("quarantining expired locks at startup")?;
Ok(store)
}
pub fn quarantine_expired(&self, now: DateTime<Utc>) -> anyhow::Result<usize> {
let mut conn = self
.conn
.lock()
.map_err(|_| anyhow!("planner store mutex poisoned"))?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let in_progress = serde_json::to_string(&DeliverableStatus::InProgress)?;
let now_us = now.timestamp_micros();
let expired: Vec<(String, String)> = {
let mut stmt =
tx.prepare("SELECT plan_id, deliverable_id FROM locks WHERE expires_at_us < ?1")?;
stmt.query_map(params![now_us], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?
};
{
let mut stmt = tx.prepare(
"SELECT plan_id, deliverable_id, acquired_at_us, expires_at_us
FROM locks WHERE expires_at_us < ?1",
)?;
let leases: Vec<(String, String, i64, i64)> = stmt
.query_map(params![now_us], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.collect::<Result<_, _>>()?;
for (plan_id, deliverable_id, acquired_us, expires_us) in leases {
let start = dt_from_micros(acquired_us, "locks.acquired_at_us")
.map_err(|e| anyhow!("{e}"))?;
let end = dt_from_micros(expires_us, "locks.expires_at_us")
.map_err(|e| anyhow!("{e}"))?;
let hours = crate::locks::hours_between(start, end);
crate::ev_store::add_leased_hours(
&tx,
&PlanId(plan_id),
&deliverable_id,
hours,
end,
)
.map_err(|e| anyhow!("{e}"))?;
}
}
let orphans: Vec<(String, String)> = {
let mut stmt = tx.prepare(
"SELECT plan_id, deliverable_id FROM deliverable_statuses
WHERE status = ?1
AND lockless = 0
AND (plan_id, deliverable_id) NOT IN
(SELECT plan_id, deliverable_id FROM locks)",
)?;
stmt.query_map(params![in_progress], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?
};
type PlanSnapshot = Option<(PlanGraph, HashMap<String, DeliverableStatus>)>;
let mut plans: HashMap<String, PlanSnapshot> = HashMap::new();
for (plan_id, deliverable_id) in expired.iter().chain(orphans.iter()) {
if !plans.contains_key(plan_id) {
let graph: Option<PlanGraph> = tx
.query_row(
"SELECT graph FROM plans WHERE plan_id = ?1",
params![plan_id],
|r| r.get::<_, String>(0),
)
.optional()?
.and_then(|g| serde_json::from_str(&g).ok());
let mut statuses = HashMap::new();
let mut stmt = tx.prepare(
"SELECT deliverable_id, status FROM deliverable_statuses WHERE plan_id = ?1",
)?;
for row in stmt.query_map(params![plan_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})? {
let (id, st) = row?;
if let Ok(st) = serde_json::from_str(&st) {
statuses.insert(id, st);
}
}
plans.insert(plan_id.clone(), graph.map(|g| (g, statuses)));
}
let status = plans
.get(plan_id)
.and_then(Option::as_ref)
.and_then(|(g, st)| {
g.deliverables
.iter()
.find(|d| &d.id == deliverable_id)
.map(|d| crate::locks::rederive_status(d, st))
})
.unwrap_or(DeliverableStatus::Ready);
tx.execute(
"UPDATE deliverable_statuses
SET status = ?1, lapse_count = lapse_count + 1, lockless = 0
WHERE plan_id = ?2 AND deliverable_id = ?3",
params![serde_json::to_string(&status)?, plan_id, deliverable_id],
)?;
}
let reaped = tx.execute(
"DELETE FROM locks WHERE expires_at_us < ?1",
params![now_us],
)?;
let orphaned = orphans.len();
tx.commit()?;
if reaped > 0 || orphaned > 0 {
tracing::warn!(
expired_locks = reaped,
orphaned_in_progress = orphaned,
"quarantined stale planner state"
);
}
Ok(reaped)
}
pub(crate) fn submit_or_get(
&self,
graph_hash: &str,
build: impl FnOnce() -> Result<(PlanId, PlanState), PlannerError>,
) -> Result<PlanId, PlannerError> {
let mut conn = self.lock_conn()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(backend)?;
let existing: Option<String> = tx
.query_row(
"SELECT plan_id FROM submit_dedup WHERE graph_hash = ?1",
params![graph_hash],
|row| row.get(0),
)
.optional()
.map_err(backend)?;
if let Some(plan_id) = existing {
return Ok(PlanId(plan_id));
}
let (plan_id, mut state) = build()?;
insert_plan(&tx, &plan_id, &mut state, Utc::now())?;
tx.execute(
"INSERT INTO submit_dedup (graph_hash, plan_id) VALUES (?1, ?2)",
params![graph_hash, plan_id.0],
)
.map_err(backend)?;
tx.commit().map_err(backend)?;
Ok(plan_id)
}
#[cfg(test)]
pub(crate) fn mutate_plan<R>(
&self,
plan_id: &PlanId,
f: impl FnOnce(&mut PlanState) -> Result<R, PlannerError>,
) -> Result<R, PlannerError> {
self.mutate_plan_inner(plan_id, false, f)
}
pub(crate) fn mutate_executable_plan<R>(
&self,
plan_id: &PlanId,
f: impl FnOnce(&mut PlanState) -> Result<R, PlannerError>,
) -> Result<R, PlannerError> {
self.mutate_plan_inner(plan_id, true, f)
}
pub(crate) fn mutate_executable_plan_tx<R>(
&self,
plan_id: &PlanId,
f: impl FnOnce(&Transaction<'_>, &mut PlanState) -> Result<R, PlannerError>,
) -> Result<R, PlannerError> {
let mut conn = self.lock_conn()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(backend)?;
crate::portfolio::ensure_executable(&tx, plan_id)?;
let mut state =
load_plan_state(&tx, plan_id)?.ok_or_else(|| PlannerError::PlanNotFound {
plan_id: plan_id.0.clone(),
})?;
let out = f(&tx, &mut state)?;
save_plan_state(&tx, plan_id, &mut state)?;
tx.commit().map_err(backend)?;
Ok(out)
}
fn mutate_plan_inner<R>(
&self,
plan_id: &PlanId,
gated: bool,
f: impl FnOnce(&mut PlanState) -> Result<R, PlannerError>,
) -> Result<R, PlannerError> {
let mut conn = self.lock_conn()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(backend)?;
if gated {
crate::portfolio::ensure_executable(&tx, plan_id)?;
}
let mut state =
load_plan_state(&tx, plan_id)?.ok_or_else(|| PlannerError::PlanNotFound {
plan_id: plan_id.0.clone(),
})?;
let out = f(&mut state)?;
save_plan_state(&tx, plan_id, &mut state)?;
tx.commit().map_err(backend)?;
Ok(out)
}
pub(crate) fn read_plan<R>(
&self,
plan_id: &PlanId,
f: impl FnOnce(&PlanState) -> R,
) -> Result<R, PlannerError> {
let mut conn = self.lock_conn()?;
let tx = conn.transaction().map_err(backend)?;
let state = load_plan_state(&tx, plan_id)?.ok_or_else(|| PlannerError::PlanNotFound {
plan_id: plan_id.0.clone(),
})?;
Ok(f(&state))
}
pub(crate) fn read_plan_and_variant<R>(
&self,
plan_id: &PlanId,
f: impl FnOnce(&PlanState) -> R,
) -> Result<PlanVariantBaseline<R>, PlannerError> {
self.read_tx(|tx| {
let state =
load_plan_state(tx, plan_id)?.ok_or_else(|| PlannerError::PlanNotFound {
plan_id: plan_id.0.clone(),
})?;
let info = crate::portfolio::variant_info(tx, plan_id)?;
let baseline =
crate::ev_store::latest_baseline(tx, plan_id)?.map(|stored| PlanBaseline {
baseline_number: stored.baseline.number,
as_of: stored.baseline.start,
});
Ok(((f(&state), info), baseline))
})
}
pub(crate) fn write_tx<R>(
&self,
f: impl FnOnce(&Transaction<'_>) -> Result<R, PlannerError>,
) -> Result<R, PlannerError> {
let mut conn = self.lock_conn()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(backend)?;
let out = f(&tx)?;
tx.commit().map_err(backend)?;
Ok(out)
}
pub(crate) fn read_tx<R>(
&self,
f: impl FnOnce(&Transaction<'_>) -> Result<R, PlannerError>,
) -> Result<R, PlannerError> {
let mut conn = self.lock_conn()?;
let tx = conn.transaction().map_err(backend)?;
f(&tx)
}
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, PlannerError> {
self.conn
.lock()
.map_err(|_| backend(anyhow!("planner store mutex poisoned")))
}
}
const MIGRATIONS: &[fn(&Connection) -> anyhow::Result<()>] = &[
migrate_v1_base_schema, migrate_v2_cpm_version, migrate_v3_portfolio, migrate_v4_earned_value, migrate_v5_baseline_backfilled, ];
fn migrate(conn: &mut Connection) -> anyhow::Result<()> {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current: i64 = tx.query_row("PRAGMA user_version", [], |r| r.get(0))?;
let supported = MIGRATIONS.len() as i64;
if current > supported {
return Err(anyhow!(
"database schema version {current} is newer than this cpm-planner supports \
({supported}); upgrade cpm-planner"
));
}
for (i, step) in MIGRATIONS.iter().enumerate() {
let version = i as i64 + 1;
if version > current {
step(&tx).with_context(|| format!("applying schema migration v{version}"))?;
tx.pragma_update(None, "user_version", version)?;
}
}
ensure_v4_columns(&tx).context("healing schema v4 columns")?;
recompute_stale_results(&tx)?;
tx.commit()?;
Ok(())
}
fn migrate_v1_base_schema(conn: &Connection) -> anyhow::Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS plans (
plan_id TEXT PRIMARY KEY,
graph TEXT NOT NULL,
cached_result TEXT NOT NULL,
created_at_us INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS deliverable_statuses (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
failure_count INTEGER NOT NULL DEFAULT 0,
lapse_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE IF NOT EXISTS locks (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
caller_id TEXT NOT NULL,
acquired_at_us INTEGER NOT NULL,
expires_at_us INTEGER NOT NULL,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE IF NOT EXISTS submit_dedup (
graph_hash TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE
);",
)
.context("creating planner tables")?;
migrate_counter_columns(conn)
}
fn migrate_v2_cpm_version(conn: &Connection) -> anyhow::Result<()> {
let mut stmt = conn
.prepare("PRAGMA table_info(plans)")
.context("probing plans columns")?;
let has_column = stmt
.query_map([], |row| row.get::<_, String>(1))
.context("reading plans column names")?
.collect::<Result<Vec<_>, _>>()
.context("reading plans column name")?
.iter()
.any(|c| c == "cpm_version");
if !has_column {
conn.execute_batch("ALTER TABLE plans ADD COLUMN cpm_version INTEGER NOT NULL DEFAULT 0")
.context("adding plans.cpm_version column")?;
}
Ok(())
}
fn migrate_v3_portfolio(conn: &Connection) -> anyhow::Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS plan_lines (
project TEXT,
name TEXT,
selected_variant TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (project, name)
);
CREATE TABLE IF NOT EXISTS variants (
project TEXT,
name TEXT,
variant TEXT,
plan_id TEXT NOT NULL UNIQUE REFERENCES plans(plan_id) ON DELETE CASCADE,
source_path TEXT,
content_hash TEXT,
head_revision INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (project, name, variant)
);
CREATE TABLE IF NOT EXISTS revisions (
plan_id TEXT REFERENCES plans(plan_id) ON DELETE CASCADE,
revision INTEGER,
graph TEXT NOT NULL,
content_hash TEXT,
created_at_us INTEGER NOT NULL,
PRIMARY KEY (plan_id, revision)
);",
)
.context("creating portfolio tables")
}
fn migrate_v4_earned_value(conn: &Connection) -> anyhow::Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS baselines (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
number INTEGER NOT NULL,
start_us INTEGER NOT NULL,
calendar TEXT,
rows TEXT NOT NULL,
bac REAL NOT NULL,
reason TEXT,
created_at_us INTEGER NOT NULL,
PRIMARY KEY (plan_id, number)
);
CREATE TABLE IF NOT EXISTS ev_actuals (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
earned_pct INTEGER,
actual_hours REAL,
leased_hours REAL NOT NULL DEFAULT 0,
evidence TEXT NOT NULL DEFAULT '[]',
updated_at_us INTEGER,
removed_at_us INTEGER,
frozen_pct INTEGER,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE IF NOT EXISTS ev_snapshots (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
taken_at_us INTEGER NOT NULL,
as_of_us INTEGER NOT NULL,
baseline_number INTEGER NOT NULL,
summary TEXT NOT NULL,
PRIMARY KEY (plan_id, taken_at_us)
);
CREATE INDEX IF NOT EXISTS ev_snapshots_by_position
ON ev_snapshots(plan_id, baseline_number, as_of_us, taken_at_us);
CREATE INDEX IF NOT EXISTS ev_snapshots_by_as_of
ON ev_snapshots(plan_id, as_of_us, taken_at_us);",
)
.context("creating earned-value tables")
}
fn migrate_v5_baseline_backfilled(conn: &Connection) -> anyhow::Result<()> {
add_column_if_missing(
conn,
"baselines",
"backfilled",
"INTEGER NOT NULL DEFAULT 0",
)?;
Ok(())
}
fn ensure_v4_columns(conn: &Connection) -> anyhow::Result<()> {
add_column_if_missing(
conn,
"deliverable_statuses",
"lockless",
"INTEGER NOT NULL DEFAULT 0",
)?;
add_column_if_missing(conn, "ev_actuals", "removed_at_us", "INTEGER")?;
add_column_if_missing(conn, "ev_actuals", "frozen_pct", "INTEGER")?;
if add_column_if_missing(
conn,
"ev_snapshots",
"baseline_number",
"INTEGER NOT NULL DEFAULT 0",
)? {
conn.execute_batch(
"UPDATE ev_snapshots
SET baseline_number = COALESCE(json_extract(summary, '$.baseline_number'), 0)",
)
.context("backfilling ev_snapshots.baseline_number")?;
}
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS ev_snapshots_by_position
ON ev_snapshots(plan_id, baseline_number, as_of_us, taken_at_us);
CREATE INDEX IF NOT EXISTS ev_snapshots_by_as_of
ON ev_snapshots(plan_id, as_of_us, taken_at_us);",
)
.context("creating ev_snapshots indexes")
}
fn add_column_if_missing(
conn: &Connection,
table: &str,
column: &str,
decl: &str,
) -> anyhow::Result<bool> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.with_context(|| format!("probing {table} columns"))?;
let has_column = stmt
.query_map([], |row| row.get::<_, String>(1))
.with_context(|| format!("reading {table} column names"))?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("reading {table} column name"))?
.iter()
.any(|c| c == column);
if !has_column {
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))
.with_context(|| format!("adding {table}.{column} column"))?;
}
Ok(!has_column)
}
fn recompute_stale_results(conn: &Connection) -> anyhow::Result<()> {
let stale: Vec<(String, String)> = {
let mut stmt = conn.prepare("SELECT plan_id, graph FROM plans WHERE cpm_version < ?1")?;
stmt.query_map([crate::algorithm::CPM_VERSION], |r| {
Ok((r.get(0)?, r.get(1)?))
})?
.collect::<Result<_, _>>()?
};
for (plan_id, graph_json) in stale {
let graph: PlanGraph = match serde_json::from_str(&graph_json) {
Ok(g) => g,
Err(e) => {
tracing::warn!(%plan_id, error = %e, "could not decode stored graph; leaving cached result as is");
continue;
}
};
if graph
.deliverables
.iter()
.any(|d| d.id == crate::plan::START_ID || d.id == crate::plan::FINISH_ID)
{
tracing::warn!(%plan_id, "stored graph uses a reserved endpoint id; leaving cached result as is");
continue;
}
match crate::schedule::compute_cpm(&graph) {
Ok(result) => {
conn.execute(
"UPDATE plans SET cached_result = ?1, cpm_version = ?2 WHERE plan_id = ?3",
params![
serde_json::to_string(&result)?,
crate::algorithm::CPM_VERSION,
plan_id
],
)?;
}
Err(e) => tracing::warn!(%plan_id, error = %e, "could not recompute stored CPM result"),
}
}
Ok(())
}
fn migrate_counter_columns(conn: &Connection) -> anyhow::Result<()> {
for column in ["attempt_count", "failure_count", "lapse_count"] {
add_column_if_missing(
conn,
"deliverable_statuses",
column,
"INTEGER NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
pub(crate) fn load_plan_state(
tx: &Transaction<'_>,
plan_id: &PlanId,
) -> Result<Option<PlanState>, PlannerError> {
let row: Option<(String, String)> = tx
.query_row(
"SELECT graph, cached_result FROM plans WHERE plan_id = ?1",
params![plan_id.0],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(backend)?;
let Some((graph_json, result_json)) = row else {
return Ok(None);
};
let graph: PlanGraph = serde_json::from_str(&graph_json).map_err(backend)?;
let cached_result: CriticalPathResult = serde_json::from_str(&result_json).map_err(backend)?;
let mut statuses: HashMap<String, DeliverableStatus> = HashMap::new();
let mut attempt_counts: HashMap<String, u32> = HashMap::new();
let mut failure_counts: HashMap<String, u32> = HashMap::new();
let mut lapse_counts: HashMap<String, u32> = HashMap::new();
let mut lockless: HashSet<String> = HashSet::new();
{
let mut stmt = tx
.prepare(
"SELECT deliverable_id, status, attempt_count, failure_count, lapse_count,
lockless
FROM deliverable_statuses WHERE plan_id = ?1",
)
.map_err(backend)?;
let rows = stmt
.query_map(params![plan_id.0], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, u32>(2)?,
row.get::<_, u32>(3)?,
row.get::<_, u32>(4)?,
row.get::<_, bool>(5)?,
))
})
.map_err(backend)?;
for row in rows {
let (id, status_json, attempt_count, failure_count, lapse_count, is_lockless) =
row.map_err(backend)?;
let status: DeliverableStatus = serde_json::from_str(&status_json).map_err(backend)?;
statuses.insert(id.clone(), status);
if attempt_count > 0 {
attempt_counts.insert(id.clone(), attempt_count);
}
if failure_count > 0 {
failure_counts.insert(id.clone(), failure_count);
}
if is_lockless {
lockless.insert(id.clone());
}
if lapse_count > 0 {
lapse_counts.insert(id, lapse_count);
}
}
}
let mut locks: HashMap<String, LockInfo> = HashMap::new();
{
let mut stmt = tx
.prepare(
"SELECT deliverable_id, caller_id, acquired_at_us, expires_at_us
FROM locks WHERE plan_id = ?1",
)
.map_err(backend)?;
let rows = stmt
.query_map(params![plan_id.0], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
))
})
.map_err(backend)?;
for row in rows {
let (deliverable_id, caller_id, acquired_us, expires_us) = row.map_err(backend)?;
locks.insert(
deliverable_id.clone(),
LockInfo {
plan_id: plan_id.clone(),
deliverable_id,
caller_id: CallerId(caller_id),
acquired_at: dt_from_micros(acquired_us, "locks.acquired_at_us")?,
expires_at: dt_from_micros(expires_us, "locks.expires_at_us")?,
},
);
}
}
let mut file_claims: HashMap<PathBuf, crate::locks::FileClaim> = HashMap::new();
for deliverable_id in locks.keys() {
if let Some(d) = graph.deliverables.iter().find(|d| &d.id == deliverable_id) {
crate::locks::add_file_claims(&mut file_claims, deliverable_id, &d.owned_files);
}
}
Ok(Some(PlanState {
graph,
statuses,
attempt_counts,
failure_counts,
lapse_counts,
locks,
lockless,
file_claims,
cached_result,
leased_hours: HashMap::new(),
reported_actuals: HashMap::new(),
actuals_updated_at: None,
}))
}
pub(crate) fn insert_plan(
tx: &Transaction<'_>,
plan_id: &PlanId,
state: &mut PlanState,
now: DateTime<Utc>,
) -> Result<(), PlannerError> {
let graph_json = serde_json::to_string(&state.graph).map_err(backend)?;
let result_json = serde_json::to_string(&state.cached_result).map_err(backend)?;
tx.execute(
"INSERT INTO plans (plan_id, graph, cached_result, created_at_us, cpm_version)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
plan_id.0,
graph_json,
result_json,
now.timestamp_micros(),
crate::algorithm::CPM_VERSION
],
)
.map_err(backend)?;
save_plan_state(tx, plan_id, state)
}
pub(crate) fn replace_plan_state(
tx: &Transaction<'_>,
plan_id: &PlanId,
state: &mut PlanState,
) -> Result<(), PlannerError> {
let graph_json = serde_json::to_string(&state.graph).map_err(backend)?;
let result_json = serde_json::to_string(&state.cached_result).map_err(backend)?;
tx.execute(
"UPDATE plans SET graph = ?1, cached_result = ?2, cpm_version = ?3 WHERE plan_id = ?4",
params![
graph_json,
result_json,
crate::algorithm::CPM_VERSION,
plan_id.0
],
)
.map_err(backend)?;
tx.execute(
"DELETE FROM deliverable_statuses WHERE plan_id = ?1",
params![plan_id.0],
)
.map_err(backend)?;
save_plan_state(tx, plan_id, state)
}
pub(crate) fn save_plan_state(
tx: &Transaction<'_>,
plan_id: &PlanId,
state: &mut PlanState,
) -> Result<(), PlannerError> {
{
let mut stmt = tx
.prepare(
"INSERT INTO deliverable_statuses
(plan_id, deliverable_id, status, attempt_count, failure_count,
lapse_count, lockless)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(plan_id, deliverable_id) DO UPDATE
SET status = excluded.status,
attempt_count = excluded.attempt_count,
failure_count = excluded.failure_count,
lapse_count = excluded.lapse_count,
lockless = excluded.lockless",
)
.map_err(backend)?;
for (deliverable_id, status) in &state.statuses {
let status_json = serde_json::to_string(status).map_err(backend)?;
stmt.execute(params![
plan_id.0,
deliverable_id,
status_json,
state.attempt_count(deliverable_id),
state.failure_count(deliverable_id),
state.lapse_count(deliverable_id),
state.is_lockless_in_progress(deliverable_id)
])
.map_err(backend)?;
}
}
tx.execute("DELETE FROM locks WHERE plan_id = ?1", params![plan_id.0])
.map_err(backend)?;
{
let mut stmt = tx
.prepare(
"INSERT INTO locks
(plan_id, deliverable_id, caller_id, acquired_at_us, expires_at_us)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.map_err(backend)?;
for (deliverable_id, lock) in &state.locks {
stmt.execute(params![
plan_id.0,
deliverable_id,
lock.caller_id.0,
lock.acquired_at.timestamp_micros(),
lock.expires_at.timestamp_micros(),
])
.map_err(backend)?;
}
}
let deltas = state.take_actuals_deltas();
if let Some(at) = deltas.at {
for (deliverable_id, hours) in &deltas.leased_hours {
crate::ev_store::add_leased_hours(tx, plan_id, deliverable_id, *hours, at)?;
}
for (deliverable_id, report) in &deltas.reported {
crate::ev_store::record_reported(tx, plan_id, deliverable_id, report, at)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::Deliverable;
use crate::task::CriticalPathResult;
fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<std::ffi::OsString> {
let vars: Vec<(String, String)> = vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.into())
}
fn expected_db_under(home: &str) -> PathBuf {
PathBuf::from(home)
.join(".local")
.join("share")
.join("praxec")
.join("cpm-planner.db")
}
#[test]
fn default_db_path_prefers_home() {
let path = default_db_path_from(env_of(&[("HOME", "/h"), ("USERPROFILE", "/u")])).unwrap();
assert_eq!(path, expected_db_under("/h"));
}
#[test]
fn default_db_path_falls_back_to_userprofile_without_home() {
let path = default_db_path_from(env_of(&[("USERPROFILE", "/u")])).unwrap();
assert_eq!(path, expected_db_under("/u"));
let path = default_db_path_from(env_of(&[("HOME", ""), ("USERPROFILE", "/u")])).unwrap();
assert_eq!(path, expected_db_under("/u"));
}
#[test]
fn default_db_path_errors_without_home_or_userprofile() {
let err = default_db_path_from(env_of(&[])).unwrap_err().to_string();
assert!(err.contains("HOME") && err.contains("USERPROFILE"), "{err}");
assert!(err.contains(DB_PATH_ENV), "{err}");
}
#[test]
fn default_db_path_env_override_wins() {
let path = default_db_path_from(env_of(&[(DB_PATH_ENV, "/x.db")])).unwrap();
assert_eq!(path, PathBuf::from("/x.db"));
}
fn plan_state_with_one_ready() -> PlanState {
let graph = PlanGraph {
deliverables: vec![Deliverable {
id: "d1".to_string(),
owned_files: vec!["src/a.rs".into()],
prerequisites: vec![],
estimated_effort_hours: Some(1.0),
metadata: serde_json::Value::Null,
duration_hours: None,
estimate: None,
milestone: false,
earning_rule: None,
}],
max_chained_dispatch: None,
};
let mut statuses = HashMap::new();
statuses.insert("d1".to_string(), DeliverableStatus::Ready);
PlanState::new(graph, statuses, CriticalPathResult::default())
}
#[test]
fn submit_or_get_is_idempotent_within_one_store() {
let store = SqlitePlanStore::open_in_memory().unwrap();
let first = store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_a".into()), plan_state_with_one_ready()))
})
.unwrap();
let second = store
.submit_or_get("hash-1", || {
panic!("build must not run on a dedup hit");
})
.unwrap();
assert_eq!(first, second);
}
#[test]
fn mutate_plan_persists_statuses_and_locks() {
let store = SqlitePlanStore::open_in_memory().unwrap();
let plan_id = store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_a".into()), plan_state_with_one_ready()))
})
.unwrap();
let now = Utc::now();
store
.mutate_plan(&plan_id, |state| {
state
.statuses
.insert("d1".to_string(), DeliverableStatus::InProgress);
state.locks.insert(
"d1".to_string(),
LockInfo {
plan_id: plan_id.clone(),
deliverable_id: "d1".to_string(),
caller_id: CallerId("c1".to_string()),
acquired_at: now,
expires_at: now + chrono::Duration::seconds(60),
},
);
Ok(())
})
.unwrap();
store
.read_plan(&plan_id, |state| {
assert_eq!(
state.statuses.get("d1"),
Some(&DeliverableStatus::InProgress)
);
let lock = state.locks.get("d1").expect("lock persisted");
assert_eq!(lock.caller_id, CallerId("c1".to_string()));
assert_eq!(
state.file_claims.get(&PathBuf::from("src/a.rs")),
Some(&crate::locks::FileClaim::Exclusive("d1".to_string()))
);
})
.unwrap();
}
#[test]
fn mutate_plan_error_rolls_back() {
let store = SqlitePlanStore::open_in_memory().unwrap();
let plan_id = store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_a".into()), plan_state_with_one_ready()))
})
.unwrap();
let err = store.mutate_plan(&plan_id, |state| {
state
.statuses
.insert("d1".to_string(), DeliverableStatus::Complete);
Err::<(), _>(PlannerError::LockNotHeld {
caller_id: "c1".to_string(),
deliverable_id: "d1".to_string(),
})
});
assert!(matches!(err, Err(PlannerError::LockNotHeld { .. })));
store
.read_plan(&plan_id, |state| {
assert_eq!(state.statuses.get("d1"), Some(&DeliverableStatus::Ready));
})
.unwrap();
}
#[test]
fn unknown_plan_is_plan_not_found() {
let store = SqlitePlanStore::open_in_memory().unwrap();
let missing = PlanId("plan_missing".into());
let err = store.read_plan(&missing, |_| ());
assert!(matches!(err, Err(PlannerError::PlanNotFound { .. })));
}
#[test]
fn attempt_counts_round_trip_through_the_store() {
let store = SqlitePlanStore::open_in_memory().unwrap();
let plan_id = store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_a".into()), plan_state_with_one_ready()))
})
.unwrap();
store
.mutate_plan(&plan_id, |state| {
*state.attempt_counts.entry("d1".to_string()).or_insert(0) += 1;
Ok(())
})
.unwrap();
store
.read_plan(&plan_id, |state| {
assert_eq!(state.attempt_count("d1"), 1);
})
.unwrap();
}
#[test]
fn failure_and_lapse_counts_round_trip_through_the_store() {
let store = SqlitePlanStore::open_in_memory().unwrap();
let plan_id = store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_a".into()), plan_state_with_one_ready()))
})
.unwrap();
store
.mutate_plan(&plan_id, |state| {
*state.failure_counts.entry("d1".to_string()).or_insert(0) += 1;
*state.lapse_counts.entry("d1".to_string()).or_insert(0) += 2;
Ok(())
})
.unwrap();
store
.read_plan(&plan_id, |state| {
assert_eq!(state.failure_count("d1"), 1);
assert_eq!(state.lapse_count("d1"), 2);
})
.unwrap();
}
struct TempDbFile {
path: PathBuf,
}
impl TempDbFile {
fn new(tag: &str) -> Self {
Self {
path: std::env::temp_dir().join(format!(
"cpm-planner-store-test-{tag}-{}.db",
uuid::Uuid::new_v4().simple()
)),
}
}
}
impl Drop for TempDbFile {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let mut p = self.path.clone().into_os_string();
p.push(suffix);
let _ = std::fs::remove_file(PathBuf::from(p));
}
}
}
fn v3_database(tag: &str) -> TempDbFile {
let db = TempDbFile::new(tag);
let store = SqlitePlanStore::open(&db.path).unwrap();
store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_v3".into()), plan_state_with_one_ready()))
})
.unwrap();
drop(store);
let conn = Connection::open(&db.path).unwrap();
conn.execute_batch(
"DROP TABLE baselines; DROP TABLE ev_actuals; DROP TABLE ev_snapshots;
ALTER TABLE deliverable_statuses DROP COLUMN lockless;
PRAGMA user_version = 3;",
)
.unwrap();
db
}
#[test]
fn store_at_v4_without_lockless_column_heals_on_open() {
let db = TempDbFile::new("v4-heal");
let store = SqlitePlanStore::open(&db.path).unwrap();
store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_v4".into()), plan_state_with_one_ready()))
})
.unwrap();
drop(store);
Connection::open(&db.path)
.unwrap()
.execute_batch(
"DROP INDEX ev_snapshots_by_position; DROP INDEX ev_snapshots_by_as_of;
ALTER TABLE ev_snapshots DROP COLUMN baseline_number;
ALTER TABLE ev_actuals DROP COLUMN removed_at_us;
ALTER TABLE ev_actuals DROP COLUMN frozen_pct;
ALTER TABLE deliverable_statuses DROP COLUMN lockless;",
)
.unwrap();
let store = SqlitePlanStore::open(&db.path).unwrap();
let status = store
.read_plan(&PlanId("plan_v4".into()), |s| s.statuses.get("d1").cloned())
.unwrap();
assert_eq!(status, Some(DeliverableStatus::Ready));
}
#[test]
fn heal_backfills_snapshot_baseline_number_from_summary() {
let db = TempDbFile::new("v4-backfill");
let store = SqlitePlanStore::open(&db.path).unwrap();
store
.submit_or_get("hash-1", || {
Ok((PlanId("plan_v4".into()), plan_state_with_one_ready()))
})
.unwrap();
drop(store);
Connection::open(&db.path)
.unwrap()
.execute_batch(
"INSERT INTO ev_snapshots
(plan_id, taken_at_us, as_of_us, baseline_number, summary)
VALUES ('plan_v4', 1, 1, 3, '{\"baseline_number\": 3}');
DROP INDEX ev_snapshots_by_position; DROP INDEX ev_snapshots_by_as_of;
ALTER TABLE ev_snapshots DROP COLUMN baseline_number;",
)
.unwrap();
drop(SqlitePlanStore::open(&db.path).unwrap());
let baseline_number: i64 = Connection::open(&db.path)
.unwrap()
.query_row("SELECT baseline_number FROM ev_snapshots", [], |r| r.get(0))
.unwrap();
assert_eq!(baseline_number, 3);
}
#[test]
fn migrated_v3_in_progress_without_lock_is_quarantined_as_lease_backed() {
let db = v3_database("v3-orphan");
let conn = Connection::open(&db.path).unwrap();
conn.execute(
"UPDATE deliverable_statuses SET status = ?1 WHERE deliverable_id = 'd1'",
params![serde_json::to_string(&DeliverableStatus::InProgress).unwrap()],
)
.unwrap();
drop(conn);
let store = SqlitePlanStore::open(&db.path).unwrap();
let status = store
.read_plan(&PlanId("plan_v3".into()), |s| s.statuses.get("d1").cloned())
.unwrap();
assert_eq!(status, Some(DeliverableStatus::Ready));
}
#[test]
fn migrating_v3_to_v4_keeps_plans() {
let db = v3_database("v3-keeps");
let store = SqlitePlanStore::open(&db.path).unwrap();
let status = store
.read_plan(&PlanId("plan_v3".into()), |s| s.statuses.get("d1").cloned())
.unwrap();
assert_eq!(status, Some(DeliverableStatus::Ready));
}
#[test]
fn migrating_v3_to_v4_creates_the_earned_value_tables() {
let db = v3_database("v3-tables");
drop(SqlitePlanStore::open(&db.path).unwrap());
let conn = Connection::open(&db.path).unwrap();
let tables: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'
AND name IN ('baselines', 'ev_actuals', 'ev_snapshots')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(tables, 3);
}
#[test]
fn opening_a_pre_attempt_count_database_migrates_in_place() {
let db = TempDbFile::new("migration");
{
let conn = Connection::open(&db.path).unwrap();
conn.execute_batch(
"CREATE TABLE plans (
plan_id TEXT PRIMARY KEY,
graph TEXT NOT NULL,
cached_result TEXT NOT NULL,
created_at_us INTEGER NOT NULL
);
CREATE TABLE deliverable_statuses (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE locks (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
caller_id TEXT NOT NULL,
acquired_at_us INTEGER NOT NULL,
expires_at_us INTEGER NOT NULL,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE submit_dedup (
graph_hash TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE
);",
)
.unwrap();
let legacy = plan_state_with_one_ready();
conn.execute(
"INSERT INTO plans (plan_id, graph, cached_result, created_at_us)
VALUES (?1, ?2, ?3, ?4)",
params![
"plan_legacy",
serde_json::to_string(&legacy.graph).unwrap(),
serde_json::to_string(&legacy.cached_result).unwrap(),
0i64
],
)
.unwrap();
conn.execute(
"INSERT INTO deliverable_statuses (plan_id, deliverable_id, status)
VALUES (?1, ?2, ?3)",
params![
"plan_legacy",
"d1",
serde_json::to_string(&DeliverableStatus::Ready).unwrap()
],
)
.unwrap();
}
let plan_id = PlanId("plan_legacy".into());
let store = SqlitePlanStore::open(&db.path).unwrap();
store
.read_plan(&plan_id, |state| {
assert_eq!(state.statuses.get("d1"), Some(&DeliverableStatus::Ready));
assert_eq!(state.attempt_count("d1"), 0);
})
.unwrap();
store
.mutate_plan(&plan_id, |state| {
state.attempt_counts.insert("d1".to_string(), 2);
Ok(())
})
.unwrap();
drop(store);
let store2 = SqlitePlanStore::open(&db.path).unwrap();
store2
.read_plan(&plan_id, |state| {
assert_eq!(state.attempt_count("d1"), 2);
})
.unwrap();
}
#[test]
fn opening_a_pre_lapse_count_database_treats_legacy_attempts_as_neither_failures_nor_lapses() {
let db = TempDbFile::new("lapse-migration");
{
let conn = Connection::open(&db.path).unwrap();
conn.execute_batch(
"CREATE TABLE plans (
plan_id TEXT PRIMARY KEY,
graph TEXT NOT NULL,
cached_result TEXT NOT NULL,
created_at_us INTEGER NOT NULL
);
CREATE TABLE deliverable_statuses (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE locks (
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
deliverable_id TEXT NOT NULL,
caller_id TEXT NOT NULL,
acquired_at_us INTEGER NOT NULL,
expires_at_us INTEGER NOT NULL,
PRIMARY KEY (plan_id, deliverable_id)
);
CREATE TABLE submit_dedup (
graph_hash TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE
);",
)
.unwrap();
let legacy = plan_state_with_one_ready();
conn.execute(
"INSERT INTO plans (plan_id, graph, cached_result, created_at_us)
VALUES (?1, ?2, ?3, ?4)",
params![
"plan_live",
serde_json::to_string(&legacy.graph).unwrap(),
serde_json::to_string(&legacy.cached_result).unwrap(),
0i64
],
)
.unwrap();
conn.execute(
"INSERT INTO deliverable_statuses (plan_id, deliverable_id, status, attempt_count)
VALUES (?1, ?2, ?3, ?4)",
params![
"plan_live",
"d1",
serde_json::to_string(&DeliverableStatus::Ready).unwrap(),
2u32
],
)
.unwrap();
}
let plan_id = PlanId("plan_live".into());
let store = SqlitePlanStore::open(&db.path).unwrap();
store
.read_plan(&plan_id, |state| {
assert_eq!(state.statuses.get("d1"), Some(&DeliverableStatus::Ready));
assert_eq!(state.attempt_count("d1"), 2, "legacy history preserved");
assert_eq!(
state.failure_count("d1"),
0,
"legacy leases are not failures"
);
assert_eq!(state.lapse_count("d1"), 0, "legacy leases are not lapses");
})
.unwrap();
store
.mutate_plan(&plan_id, |state| {
state.failure_counts.insert("d1".to_string(), 1);
state.lapse_counts.insert("d1".to_string(), 3);
Ok(())
})
.unwrap();
drop(store);
let store2 = SqlitePlanStore::open(&db.path).unwrap();
store2
.read_plan(&plan_id, |state| {
assert_eq!(state.attempt_count("d1"), 2);
assert_eq!(state.failure_count("d1"), 1);
assert_eq!(state.lapse_count("d1"), 3);
})
.unwrap();
}
}