use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use turso::core::types::{ImmutableRecordRef, ValueRef};
use crate::db::{Connection, params};
use crate::util::UnwrapPoison;
const CHANNEL_CAPACITY: usize = 1024;
const DRAIN_BATCH: i64 = 1000;
const DRAIN_POLL: Duration = Duration::from_millis(100);
pub(crate) const CDC_TABLE: &str = "turso_cdc";
const EXCLUDED_TABLES: &[&str] = &[CDC_TABLE, "turso_cdc_version", "ticket_chronicle"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeType {
Insert,
Update,
Delete,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pk {
Text(String),
Integer(i64),
}
#[derive(Debug, Clone, PartialEq)]
pub enum CdcValue {
Null,
Integer(i64),
Real(f64),
Text(String),
Blob(Vec<u8>),
}
impl CdcValue {
#[must_use]
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(t) => Some(t),
_ => None,
}
}
#[must_use]
pub fn as_integer(&self) -> Option<i64> {
match self {
Self::Integer(i) => Some(*i),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct RowRecord {
columns: Vec<String>,
values: Vec<Option<CdcValue>>,
}
impl RowRecord {
fn from_blob(blob: &[u8], info: &TableInfo) -> anyhow::Result<Self> {
let record = ImmutableRecordRef::from_bin_record(blob);
let mut values = Vec::with_capacity(info.columns.len());
let mut iter = record.iter()?;
for _ in 0..info.columns.len() {
match iter.next() {
Some(Ok(v)) => values.push(Some(value_ref_to_cdc(&v))),
Some(Err(e)) => return Err(e.into()),
None => values.push(None),
}
}
Ok(Self {
columns: info.columns.clone(),
values,
})
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&CdcValue> {
self.columns
.iter()
.position(|c| c == name)
.and_then(|idx| self.values[idx].as_ref())
}
}
#[derive(Debug, Clone)]
pub struct ChangeEvent {
pub table: String,
pub change_id: i64,
pub change_time: i64,
pub change_type: ChangeType,
pub pk: Pk,
pub before: Option<RowRecord>,
pub after: Option<RowRecord>,
}
impl ChangeEvent {
#[must_use]
pub fn ticket_id(&self) -> Option<&str> {
if self.table == "tickets" {
match &self.pk {
Pk::Text(id) => Some(id),
Pk::Integer(_) => None,
}
} else {
None
}
}
}
#[derive(Debug, Clone)]
struct TableInfo {
columns: Vec<String>,
pk_ordinals: Vec<usize>,
}
impl TableInfo {
async fn query(conn: &Connection, table: &str) -> anyhow::Result<Self> {
let rows = conn
.query(&format!("PRAGMA table_info({table})"), ())
.await?;
let mut columns = Vec::with_capacity(rows.len());
let mut pk_ordinals = Vec::new();
for (i, row) in rows.iter().enumerate() {
let name: String = row.get_value(1)?.as_text().cloned().unwrap_or_default();
let pk: i64 = row.get_value(5)?.as_integer().copied().unwrap_or(0);
columns.push(name);
if pk > 0 {
pk_ordinals.push(i);
}
}
Ok(Self {
columns,
pk_ordinals,
})
}
}
static TABLE_INFO: OnceLock<Mutex<HashMap<String, TableInfo>>> = OnceLock::new();
static TABLES: OnceLock<Mutex<HashMap<String, tokio::sync::broadcast::Sender<ChangeEvent>>>> =
OnceLock::new();
static TICKET_SENDER: OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>> = OnceLock::new();
#[must_use]
pub(crate) fn ticket_sender() -> &'static tokio::sync::broadcast::Sender<ChangeEvent> {
TICKET_SENDER.get_or_init(|| {
let (tx, _rx) = tokio::sync::broadcast::channel(CHANNEL_CAPACITY);
tx
})
}
#[must_use]
pub(crate) fn ticket_sender_lock() -> &'static OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>>
{
&TICKET_SENDER
}
static WORKSPACE_SENDER: OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>> = OnceLock::new();
static USER_SENDER: OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>> = OnceLock::new();
static USER_CHANNEL_SENDER: OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>> = OnceLock::new();
fn init_table_sender(
table: &'static str,
slot: &'static OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>>,
) {
let tx = slot
.get_or_init(|| tokio::sync::broadcast::channel(CHANNEL_CAPACITY).0)
.clone();
TABLES
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap_poison()
.insert(table.to_string(), tx);
}
pub(crate) fn init_workspace_tx() {
init_table_sender("workspaces", &WORKSPACE_SENDER);
}
pub(crate) fn init_users_tx() {
init_table_sender("users", &USER_SENDER);
}
pub(crate) fn init_user_channels_tx() {
init_table_sender("user_channels", &USER_CHANNEL_SENDER);
}
#[must_use]
pub(crate) fn workspace_sender_lock()
-> &'static OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>> {
&WORKSPACE_SENDER
}
#[must_use]
pub(crate) fn users_sender_lock() -> &'static OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>>
{
&USER_SENDER
}
#[must_use]
pub(crate) fn user_channels_sender_lock()
-> &'static OnceLock<tokio::sync::broadcast::Sender<ChangeEvent>> {
&USER_CHANNEL_SENDER
}
type TicketMaterializer = Arc<
dyn for<'a> Fn(&'a ChangeEvent) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>
+ Send
+ Sync,
>;
static TICKET_MATERIALIZER: OnceLock<TicketMaterializer> = OnceLock::new();
pub(crate) fn register_ticket_materializer(
f: impl for<'a> Fn(&'a ChangeEvent) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>
+ Send
+ Sync
+ 'static,
) {
let _ = TICKET_MATERIALIZER.set(Arc::new(f));
}
#[cfg(test)]
pub(crate) fn subscribe(table: &str) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
if table == "tickets" {
return ticket_sender().subscribe();
}
let mut map = TABLES
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap_poison();
map.entry(table.to_string())
.or_insert_with(|| {
let (tx, _rx) = tokio::sync::broadcast::channel(CHANNEL_CAPACITY);
tx
})
.subscribe()
}
pub(crate) async fn enable_capture(conn: &Connection) -> anyhow::Result<()> {
conn.execute(
&format!("PRAGMA capture_data_changes_conn('full,{CDC_TABLE}');"),
(),
)
.await?;
Ok(())
}
pub(crate) fn spawn_drainer(conn: Connection) {
static STARTED: OnceLock<()> = OnceLock::new();
if STARTED.get().is_some() {
return;
}
let _ = STARTED.set(());
tokio::spawn(async move {
loop {
if let Err(e) = drain_once(&conn).await {
tracing::warn!(error = %e, "ticket CDC drain failed");
}
tokio::time::sleep(DRAIN_POLL).await;
}
});
}
static DRAIN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub(crate) async fn drain_once(conn: &Connection) -> anyhow::Result<()> {
let _drain_guard = DRAIN_LOCK.lock().await;
let rows = conn
.query_cached(
&format!(
"SELECT change_id, change_time, change_type, \
table_name, id, before, after \
FROM {CDC_TABLE} ORDER BY change_id LIMIT ?1"
),
params![DRAIN_BATCH],
)
.await?;
if rows.is_empty() {
return Ok(());
}
let mut infos: HashMap<String, TableInfo> = HashMap::new();
let subscribed_tables: std::collections::HashSet<String> = {
let mut s = std::collections::HashSet::new();
if let Some(map) = TABLES.get().and_then(|m| m.lock().ok()) {
s.extend(map.keys().cloned());
}
s
};
let mut prune_up_to: i64 = -1;
for row in &rows {
let Some(change_id) = row.get_value(0).ok().and_then(|v| v.as_integer().copied()) else {
continue;
};
let Some(table) = row.get_value(3).ok().and_then(|v| v.as_text().cloned()) else {
prune_up_to = change_id;
continue;
};
if EXCLUDED_TABLES.contains(&table.as_str()) {
prune_up_to = change_id;
continue;
}
let change_type = match row.get_value(2).ok().and_then(|v| v.as_integer().copied()) {
Some(1) => ChangeType::Insert,
Some(0) => ChangeType::Update,
Some(-1) => ChangeType::Delete,
_ => {
prune_up_to = change_id;
continue;
}
};
if table != "tickets" && !subscribed_tables.contains(&table) {
prune_up_to = change_id;
continue;
}
match decode_row(conn, &mut infos, row, &table, change_type).await {
Ok(event) => {
if event.table == "tickets"
&& let Some(m) = TICKET_MATERIALIZER.get().cloned()
&& let Err(e) = m(&event).await
{
tracing::warn!(table = %event.table, change_id = event.change_id, error = %e, "ticket materializer failed");
break;
}
broadcast_event(&event);
prune_up_to = change_id;
}
Err(e) => {
tracing::warn!(table = %table, change_id, error = %e, "CDC row skipped (decode failed)");
prune_up_to = change_id;
}
}
}
if prune_up_to >= 0 {
conn.execute_cached(
&format!("DELETE FROM {CDC_TABLE} WHERE change_id <= ?1"),
params![prune_up_to],
)
.await?;
}
Ok(())
}
async fn decode_row(
conn: &Connection,
infos: &mut HashMap<String, TableInfo>,
row: &turso::Row,
table: &str,
change_type: ChangeType,
) -> anyhow::Result<ChangeEvent> {
if !infos.contains_key(table) {
let info = cached_table_info(conn, table).await?;
infos.insert(table.to_string(), info);
}
let info = infos.get(table).expect("inserted above");
let change_id = row.get_value(0)?.as_integer().copied().unwrap_or_default();
let change_time = row.get_value(1)?.as_integer().copied().unwrap_or_default();
let rowid = row.get_value(4)?.as_integer().copied().unwrap_or_default();
let before = row
.get_value(5)?
.as_blob()
.map(|b| RowRecord::from_blob(b, info))
.transpose()?;
let after = row
.get_value(6)?
.as_blob()
.map(|b| RowRecord::from_blob(b, info))
.transpose()?;
Ok(ChangeEvent {
table: table.to_string(),
change_id,
change_time,
change_type,
pk: resolve_pk(info, rowid, before.as_ref(), after.as_ref(), change_type),
before,
after,
})
}
fn broadcast_event(event: &ChangeEvent) {
if event.table == "tickets" {
let _ = ticket_sender().send(event.clone());
return;
}
let Some(map) = TABLES.get() else {
return;
};
let Ok(map) = map.lock() else {
return;
};
if let Some(tx) = map.get(&event.table) {
let _ = tx.send(event.clone());
}
}
fn resolve_pk(
info: &TableInfo,
rowid: i64,
before: Option<&RowRecord>,
after: Option<&RowRecord>,
change_type: ChangeType,
) -> Pk {
let record = match change_type {
ChangeType::Delete => before,
_ => after,
};
if let Some(rec) = record
&& info.pk_ordinals.len() == 1
{
let idx = info.pk_ordinals[0];
if let Some(v) = rec.values.get(idx).and_then(Option::as_ref) {
return match v {
CdcValue::Text(t) => Pk::Text(t.clone()),
CdcValue::Integer(i) => Pk::Integer(*i),
_ => Pk::Integer(rowid),
};
}
}
Pk::Integer(rowid)
}
async fn cached_table_info(conn: &Connection, table: &str) -> anyhow::Result<TableInfo> {
let cached = TABLE_INFO
.get()
.and_then(|m| m.lock().ok())
.and_then(|g| g.get(table).cloned());
if let Some(info) = cached {
let fresh_count = conn
.query(&format!("PRAGMA table_info({table})"), ())
.await?
.len();
if fresh_count == info.columns.len() {
return Ok(info);
}
}
let info = TableInfo::query(conn, table).await?;
let map = TABLE_INFO.get_or_init(|| Mutex::new(HashMap::new()));
map.lock()
.unwrap_poison()
.insert(table.to_string(), info.clone());
Ok(info)
}
fn value_ref_to_cdc(v: &ValueRef<'_>) -> CdcValue {
if matches!(v, ValueRef::Null) {
return CdcValue::Null;
}
if let Some(t) = v.to_text() {
return CdcValue::Text(t.to_string());
}
if let Some(b) = v.to_blob() {
return CdcValue::Blob(b.to_vec());
}
if let Some(i) = v.as_int() {
return CdcValue::Integer(i);
}
CdcValue::Real(v.as_float())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn drain_defaults_to_v2_schema_and_decodes_pk() {
let tmp = tempfile::TempDir::new().unwrap();
let conn = crate::db::open_with_schema(
&tmp.path().join("cdc.db"),
"CREATE TABLE IF NOT EXISTS t (id TEXT PRIMARY KEY, phase TEXT NOT NULL, updated_at TEXT NOT NULL);",
)
.await
.unwrap();
enable_capture(&conn).await.unwrap();
let mut rx = subscribe("t");
conn.execute(
"INSERT INTO t (id, phase, updated_at) VALUES ('mahbot-t', 'backlog', 'now')",
(),
)
.await
.unwrap();
drain_once(&conn).await.unwrap();
let event = rx.recv().await.expect("change event");
assert_eq!(event.table, "t");
assert_eq!(event.change_type, ChangeType::Insert);
assert_eq!(event.pk, Pk::Text("mahbot-t".into()));
let after = event.after.as_ref().expect("after record");
assert_eq!(after.get("phase"), Some(&CdcValue::Text("backlog".into())));
let remaining = conn
.query(&format!("SELECT COUNT(*) FROM {CDC_TABLE}"), ())
.await
.unwrap();
assert_eq!(remaining[0].get_value(0).unwrap().as_integer(), Some(&0));
}
#[test]
fn dedicated_cdc_senders_match_registry_channels() {
init_workspace_tx();
init_users_tx();
init_user_channels_tx();
let map = TABLES.get().expect("registry").lock().unwrap();
for (name, slot) in [
("workspaces", workspace_sender_lock()),
("users", users_sender_lock()),
("user_channels", user_channels_sender_lock()),
] {
let registered = map.get(name).expect("registered in TABLES");
let dedicated = slot.get().expect("warmed");
assert!(
registered.same_channel(dedicated),
"{name}: registry and dedicated sender must be the same channel \
or the drainer prunes rows without broadcasting"
);
}
}
}