P3.1: Add TaskStore trait + SQLite backend (tables 1-7)
Define the TaskStore trait in miroir-core with SQLite backend for the seven always-present tables from plan §4: tasks, node_settings_version, aliases, sessions, idempotency_cache, jobs, leader_lease. Key design choices: - serde_json Value columns for tasks.node_tasks and aliases JSON fields - BLOB (32 raw bytes) for idempotency_cache.body_sha256 - CAS operations for job claims and leader lease acquisition - Idempotent migrations via schema_versions table with version gating - WAL mode + busy_timeout=5000 for concurrent write safety - Mutex<Connection> for thread-safe single-process access 15 tests covering CRUD round-trips, alias flipping with history retention, session/idempotency expiry, job claim/renew, leader lease acquire/renew/steal, migration idempotency, WAL mode, and concurrent writes without deadlock. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
96f426435c
commit
270ae73c15
2 changed files with 1411 additions and 0 deletions
246
crates/miroir-core/src/task_store/mod.rs
Normal file
246
crates/miroir-core/src/task_store/mod.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
mod sqlite;
|
||||
|
||||
pub use sqlite::SqliteTaskStore;
|
||||
|
||||
use crate::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Per-table store operations covering tables 1–7 from plan §4.
|
||||
pub trait TaskStore: Send + Sync {
|
||||
// --- Lifecycle ---
|
||||
|
||||
/// Run idempotent migrations for all tables. Safe to call on every startup.
|
||||
fn migrate(&self) -> Result<()>;
|
||||
|
||||
// --- Table 1: tasks ---
|
||||
|
||||
/// Insert a new task row.
|
||||
fn insert_task(&self, task: &NewTask) -> Result<()>;
|
||||
|
||||
/// Get a task by miroir_id.
|
||||
fn get_task(&self, miroir_id: &str) -> Result<Option<TaskRow>>;
|
||||
|
||||
/// Update a task's status.
|
||||
fn update_task_status(&self, miroir_id: &str, status: &str) -> Result<bool>;
|
||||
|
||||
/// Update a node task within a task's node_tasks JSON.
|
||||
fn update_node_task(&self, miroir_id: &str, node_id: &str, task_uid: u64) -> Result<bool>;
|
||||
|
||||
/// Set the error field on a task.
|
||||
fn set_task_error(&self, miroir_id: &str, error: &str) -> Result<bool>;
|
||||
|
||||
/// List tasks with optional status filter and pagination.
|
||||
fn list_tasks(&self, filter: &TaskFilter) -> Result<Vec<TaskRow>>;
|
||||
|
||||
// --- Table 2: node_settings_version ---
|
||||
|
||||
/// Upsert a settings version for (index_uid, node_id).
|
||||
fn upsert_node_settings_version(
|
||||
&self,
|
||||
index_uid: &str,
|
||||
node_id: &str,
|
||||
version: i64,
|
||||
updated_at: i64,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get the settings version for (index_uid, node_id).
|
||||
fn get_node_settings_version(
|
||||
&self,
|
||||
index_uid: &str,
|
||||
node_id: &str,
|
||||
) -> Result<Option<NodeSettingsVersionRow>>;
|
||||
|
||||
// --- Table 3: aliases ---
|
||||
|
||||
/// Create a new alias.
|
||||
fn create_alias(&self, alias: &NewAlias) -> Result<()>;
|
||||
|
||||
/// Get an alias by name.
|
||||
fn get_alias(&self, name: &str) -> Result<Option<AliasRow>>;
|
||||
|
||||
/// Flip a single alias to a new current_uid, recording history.
|
||||
fn flip_alias(&self, name: &str, new_uid: &str, history_retention: usize) -> Result<bool>;
|
||||
|
||||
/// Delete an alias.
|
||||
fn delete_alias(&self, name: &str) -> Result<bool>;
|
||||
|
||||
// --- Table 4: sessions ---
|
||||
|
||||
/// Create or replace a session.
|
||||
fn upsert_session(&self, session: &SessionRow) -> Result<()>;
|
||||
|
||||
/// Get a session by id.
|
||||
fn get_session(&self, session_id: &str) -> Result<Option<SessionRow>>;
|
||||
|
||||
/// Delete expired sessions.
|
||||
fn delete_expired_sessions(&self, now_ms: i64) -> Result<usize>;
|
||||
|
||||
// --- Table 5: idempotency_cache ---
|
||||
|
||||
/// Insert an idempotency cache entry.
|
||||
fn insert_idempotency_entry(&self, entry: &IdempotencyEntry) -> Result<()>;
|
||||
|
||||
/// Look up an idempotency entry by key.
|
||||
fn get_idempotency_entry(&self, key: &str) -> Result<Option<IdempotencyEntry>>;
|
||||
|
||||
/// Delete expired entries.
|
||||
fn delete_expired_idempotency_entries(&self, now_ms: i64) -> Result<usize>;
|
||||
|
||||
// --- Table 6: jobs ---
|
||||
|
||||
/// Insert a new job.
|
||||
fn insert_job(&self, job: &NewJob) -> Result<()>;
|
||||
|
||||
/// Get a job by id.
|
||||
fn get_job(&self, id: &str) -> Result<Option<JobRow>>;
|
||||
|
||||
/// Claim a queued job (CAS: only if still queued).
|
||||
fn claim_job(&self, id: &str, claimed_by: &str, claim_expires_at: i64) -> Result<bool>;
|
||||
|
||||
/// Update job state and progress.
|
||||
fn update_job_progress(&self, id: &str, state: &str, progress: &str) -> Result<bool>;
|
||||
|
||||
/// Renew a job claim (heartbeat).
|
||||
fn renew_job_claim(&self, id: &str, claim_expires_at: i64) -> Result<bool>;
|
||||
|
||||
/// List jobs by state.
|
||||
fn list_jobs_by_state(&self, state: &str) -> Result<Vec<JobRow>>;
|
||||
|
||||
// --- Table 7: leader_lease ---
|
||||
|
||||
/// Try to acquire a leader lease (CAS: only if expired or held by us).
|
||||
/// `now_ms` is the current time for expiry comparison.
|
||||
fn try_acquire_leader_lease(
|
||||
&self,
|
||||
scope: &str,
|
||||
holder: &str,
|
||||
expires_at: i64,
|
||||
now_ms: i64,
|
||||
) -> Result<bool>;
|
||||
|
||||
/// Renew a leader lease we already hold.
|
||||
fn renew_leader_lease(&self, scope: &str, holder: &str, expires_at: i64) -> Result<bool>;
|
||||
|
||||
/// Get current lease holder for a scope.
|
||||
fn get_leader_lease(&self, scope: &str) -> Result<Option<LeaderLeaseRow>>;
|
||||
}
|
||||
|
||||
// --- Row types ---
|
||||
|
||||
/// New task to insert (table 1).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewTask {
|
||||
pub miroir_id: String,
|
||||
pub created_at: i64,
|
||||
pub status: String,
|
||||
pub node_tasks: HashMap<String, u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Task row from the DB (table 1).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRow {
|
||||
pub miroir_id: String,
|
||||
pub created_at: i64,
|
||||
pub status: String,
|
||||
pub node_tasks: HashMap<String, u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Node settings version row (table 2).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeSettingsVersionRow {
|
||||
pub index_uid: String,
|
||||
pub node_id: String,
|
||||
pub version: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// New alias to create (table 3).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAlias {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub current_uid: Option<String>,
|
||||
pub target_uids: Option<Vec<String>>,
|
||||
pub version: i64,
|
||||
pub created_at: i64,
|
||||
pub history: Vec<AliasHistoryEntry>,
|
||||
}
|
||||
|
||||
/// Alias row from the DB (table 3).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AliasRow {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub current_uid: Option<String>,
|
||||
pub target_uids: Option<Vec<String>>,
|
||||
pub version: i64,
|
||||
pub created_at: i64,
|
||||
pub history: Vec<AliasHistoryEntry>,
|
||||
}
|
||||
|
||||
/// A single entry in alias history.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AliasHistoryEntry {
|
||||
pub uid: String,
|
||||
pub flipped_at: i64,
|
||||
}
|
||||
|
||||
/// Session row (table 4).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRow {
|
||||
pub session_id: String,
|
||||
pub last_write_mtask_id: Option<String>,
|
||||
pub last_write_at: Option<i64>,
|
||||
pub pinned_group: Option<i64>,
|
||||
pub min_settings_version: i64,
|
||||
pub ttl: i64,
|
||||
}
|
||||
|
||||
/// Idempotency cache entry (table 5).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdempotencyEntry {
|
||||
pub key: String,
|
||||
pub body_sha256: Vec<u8>,
|
||||
pub miroir_task_id: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// New job to insert (table 6).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewJob {
|
||||
pub id: String,
|
||||
pub type_: String,
|
||||
pub params: String,
|
||||
pub state: String,
|
||||
pub progress: String,
|
||||
}
|
||||
|
||||
/// Job row from the DB (table 6).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JobRow {
|
||||
pub id: String,
|
||||
pub type_: String,
|
||||
pub params: String,
|
||||
pub state: String,
|
||||
pub claimed_by: Option<String>,
|
||||
pub claim_expires_at: Option<i64>,
|
||||
pub progress: String,
|
||||
}
|
||||
|
||||
/// Leader lease row (table 7).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LeaderLeaseRow {
|
||||
pub scope: String,
|
||||
pub holder: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// Filter for listing tasks.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TaskFilter {
|
||||
pub status: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
1165
crates/miroir-core/src/task_store/sqlite.rs
Normal file
1165
crates/miroir-core/src/task_store/sqlite.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue