use crate::error::Result;
use crate::live::Tracker;
use rusqlite::Connection;
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
const LOG_DDL: &str = "CREATE TABLE IF NOT EXISTS \"__roomrs_inv_log\" \
(\"seq\" INTEGER PRIMARY KEY AUTOINCREMENT, \
\"tbl\" TEXT NOT NULL, \"src\" TEXT NOT NULL)";
const PRUNE_MARGIN: i64 = 1000;
const MAX_CONSEC_FAILURES: u32 = 20;
fn plock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| {
log::warn!("mutex poisoned in multi-instance state — recovering");
e.into_inner()
})
}
pub(crate) fn generate_instance_id() -> String {
static SEQ: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!(
"{}-{:x}-{}",
std::process::id(),
nanos,
SEQ.fetch_add(1, Ordering::Relaxed)
)
}
pub(crate) struct MiState {
stop: Mutex<bool>,
cv: Condvar,
join: Mutex<Option<std::thread::JoinHandle<()>>>,
pub(crate) tables: HashSet<String>,
}
impl MiState {
pub(crate) fn shutdown(&self) {
*plock(&self.stop) = true;
self.cv.notify_all();
}
pub(crate) fn join_poller(&self) {
let handle = plock(&self.join).take();
if let Some(h) = handle {
if h.thread().id() == std::thread::current().id() {
log::warn!("MI poller shutdown on its own thread — detaching instead of joining");
} else {
let _ = h.join();
}
}
}
}
fn trigger_names(version: u32, table: &str) -> [(String, &'static str); 3] {
[
(format!("__roomrs_inv_v{version}_{table}_i"), "INSERT"),
(format!("__roomrs_inv_v{version}_{table}_u"), "UPDATE"),
(format!("__roomrs_inv_v{version}_{table}_d"), "DELETE"),
]
}
fn migrate_log_shape(conn: &Connection) -> Result<()> {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__roomrs_inv_log'",
[],
|r| r.get(0),
)?;
if exists == 0 {
return Ok(());
}
if log_has_src(conn)? {
return Ok(());
}
log::warn!("old multi-instance log schema detected — dropping and recreating");
let old: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '__roomrs_inv_%'",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok()).collect()
};
for name in old {
conn.execute_batch(&format!(
"DROP TRIGGER IF EXISTS {}",
crate::database::escape_ident(&name)
))?;
}
conn.execute_batch("DROP TABLE IF EXISTS \"__roomrs_inv_log\"")?;
Ok(())
}
fn log_has_src(conn: &Connection) -> Result<bool> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('__roomrs_inv_log') WHERE name='src'",
[],
|r| r.get(0),
)?;
Ok(n > 0)
}
pub(crate) fn install(conn: &Connection, version: u32, tables: &HashSet<String>) -> Result<()> {
conn.execute_batch("BEGIN IMMEDIATE")?;
match install_inner(conn, version, tables) {
Ok(()) => {
conn.execute_batch("COMMIT")?;
Ok(())
}
Err(e) => {
if let Err(re) = conn.execute_batch("ROLLBACK") {
log::error!("multi-instance install rollback failed: {re}");
}
Err(e)
}
}
}
fn install_inner(conn: &Connection, version: u32, tables: &HashSet<String>) -> Result<()> {
migrate_log_shape(conn)?;
conn.execute_batch(LOG_DDL)?;
let stale: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '__roomrs_inv_%'",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok())
.filter(|name| !is_current_trigger(name, version, tables))
.collect()
};
for name in stale {
conn.execute_batch(&format!(
"DROP TRIGGER IF EXISTS {}",
crate::database::escape_ident(&name)
))?;
}
for table in tables {
for (name, event) in trigger_names(version, table) {
conn.execute_batch(&format!(
"CREATE TRIGGER IF NOT EXISTS {} AFTER {event} ON {} \
BEGIN INSERT INTO \"__roomrs_inv_log\" (tbl, src) \
VALUES ({}, roomrs_src()); END",
crate::database::escape_ident(&name),
crate::database::escape_ident(table),
crate::database::escape_literal(table),
))?;
}
}
Ok(())
}
fn is_current_trigger(name: &str, version: u32, tables: &HashSet<String>) -> bool {
tables
.iter()
.any(|t| trigger_names(version, t).iter().any(|(n, _)| n == name))
}
pub(crate) fn validate(conn: &Connection, version: u32, tables: &HashSet<String>) -> Result<()> {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__roomrs_inv_log'",
[],
|r| r.get(0),
)?;
if exists == 0 || !log_has_src(conn)? {
return Err(crate::error::Error::Migration(
"멀티 인스턴스 로그 테이블이 없거나 구형입니다 — Auto 정책으로 재설치가 필요합니다"
.into(),
));
}
for table in tables {
for (name, _) in trigger_names(version, table) {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name = ?1",
[&name],
|r| r.get(0),
)?;
if n == 0 {
return Err(crate::error::Error::Migration(format!(
"멀티 인스턴스 트리거 소실: {name} — 외부 도구가 테이블을 재생성했을 수 있습니다"
)));
}
}
}
Ok(())
}
pub(crate) fn start_poller(
conn: Connection,
tracker: Arc<Tracker>,
interval: Duration,
tables: HashSet<String>,
my_src: String,
) -> Result<Arc<MiState>> {
let initial: i64 = conn.query_row(
"SELECT COALESCE(MAX(seq), 0) FROM \"__roomrs_inv_log\"",
[],
|r| r.get(0),
)?;
let state = Arc::new(MiState {
stop: Mutex::new(false),
cv: Condvar::new(),
join: Mutex::new(None),
tables,
});
let st = Arc::clone(&state);
let handle = std::thread::Builder::new()
.name("roomrs-mi-poller".into())
.spawn(move || {
let mut last_seen = initial;
let mut prune_tick = 0u32;
let mut fails = 0u32;
loop {
let wait = interval * (1 + fails.min(5));
{
let g = plock(&st.stop);
let (g, _) = st
.cv
.wait_timeout_while(g, wait, |stop| !*stop)
.unwrap_or_else(PoisonError::into_inner);
if *g {
return;
}
}
let fetched: std::result::Result<Vec<(i64, String, String)>, rusqlite::Error> =
(|| {
let mut stmt = conn.prepare_cached(
"SELECT seq, tbl, src FROM \"__roomrs_inv_log\" WHERE seq > ?1",
)?;
let mapped =
stmt.query_map([last_seen], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
mapped.collect()
})();
let rows = match fetched {
Ok(v) => {
fails = 0;
v
}
Err(_) => {
fails += 1;
if fails >= MAX_CONSEC_FAILURES {
log::error!(
"MI poller stopped after {MAX_CONSEC_FAILURES} consecutive failures — \
cross-process invalidation is no longer delivered"
);
return;
}
continue;
}
};
if rows.is_empty() {
continue;
}
let first_seq = rows.iter().map(|(s, _, _)| *s).min().unwrap_or(last_seen);
last_seen = rows.iter().map(|(s, _, _)| *s).max().unwrap_or(last_seen);
log::debug!(
"MI poller consumed seq {first_seq}..={last_seen} ({} rows)",
rows.len()
);
let remote: HashSet<String> = rows
.into_iter()
.filter(|(_, _, src)| *src != my_src)
.map(|(_, t, _)| t)
.collect();
if !remote.is_empty() {
tracker.invalidate(Some(remote));
}
prune_tick += 1;
if prune_tick % 40 == 0 {
let _ = conn.execute(
"DELETE FROM \"__roomrs_inv_log\" WHERE seq <= ?1 - ?2",
rusqlite::params![last_seen, PRUNE_MARGIN],
);
}
}
})
.map_err(|e| crate::error::Error::Internal(format!("MI 폴러 스레드 생성 실패: {e}")))?;
*plock(&state.join) = Some(handle);
Ok(state)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::functions::FunctionFlags;
fn test_conn(src: &str) -> Connection {
let conn = Connection::open_in_memory().unwrap();
let id = src.to_string();
conn.create_scalar_function(
"roomrs_src",
0,
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
move |_| Ok(id.clone()),
)
.unwrap();
conn
}
#[test]
fn install_escapes_quoted_table_name() {
let conn = test_conn("me");
conn.execute_batch("CREATE TABLE \"we\"\"ird\" (id INTEGER PRIMARY KEY)")
.unwrap();
let tables: HashSet<String> = HashSet::from(["we\"ird".to_string()]);
install(&conn, 1, &tables).unwrap();
conn.execute("INSERT INTO \"we\"\"ird\" (id) VALUES (1)", [])
.unwrap();
let (tbl, src): (String, String) = conn
.query_row("SELECT tbl, src FROM \"__roomrs_inv_log\"", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(tbl, "we\"ird", "리터럴 이스케이프");
assert_eq!(src, "me", "roomrs_src() 기록");
}
#[test]
fn install_migrates_old_log_shape() {
let conn = test_conn("me");
conn.execute_batch(
"CREATE TABLE t (id INTEGER PRIMARY KEY); \
CREATE TABLE \"__roomrs_inv_log\" \
(\"seq\" INTEGER PRIMARY KEY AUTOINCREMENT, \"tbl\" TEXT NOT NULL); \
CREATE TRIGGER \"__roomrs_inv_v1_t_i\" AFTER INSERT ON t \
BEGIN INSERT INTO \"__roomrs_inv_log\" (tbl) VALUES ('t'); END",
)
.unwrap();
let tables: HashSet<String> = HashSet::from(["t".to_string()]);
install(&conn, 1, &tables).unwrap();
assert!(log_has_src(&conn).unwrap(), "src 컬럼 추가됨");
conn.execute("INSERT INTO t (id) VALUES (1)", []).unwrap();
let src: String = conn
.query_row("SELECT src FROM \"__roomrs_inv_log\"", [], |r| r.get(0))
.unwrap();
assert_eq!(src, "me");
}
#[test]
fn poller_rejects_initial_sequence_read_failure() {
let (tracker, notifier) = Tracker::start(Connection::open_in_memory().unwrap()).unwrap();
let result = start_poller(
Connection::open_in_memory().unwrap(),
Arc::clone(&tracker),
Duration::from_millis(1),
HashSet::new(),
"me".into(),
);
assert!(result.is_err());
tracker.shutdown();
notifier.join().unwrap();
}
}