Skip to main content

agentdb/
sync.rs

1//! # Sync Module
2//!
3//! Hybrid Logical Clock (HLC) based sync engine for multi-node AgentDB replication.
4//!
5//! The sync engine tracks mutations to any table via an operation log, packs
6//! timestamps as `(physical_ms << 16) | logical` so they sort correctly across
7//! nodes, and applies remote ops with a pluggable conflict strategy.
8
9use crate::db::AgentDB;
10use crate::error::{AgentDbError, Result};
11use rusqlite::{params, OptionalExtension};
12use serde::{Deserialize, Serialize};
13use std::sync::{Arc, Mutex};
14use uuid::Uuid;
15
16// ── Hybrid Logical Clock ──────────────────────────────────────────────────────
17
18/// Hybrid Logical Clock that combines physical milliseconds with a monotonic
19/// logical counter.
20///
21/// Timestamps are packed as a single `i64`:
22/// `(physical_ms << 16) | (logical & 0xFFFF)`
23pub struct HybridClock {
24    physical: i64,
25    logical: u32,
26    #[allow(dead_code)]
27    node_id: String,
28}
29
30impl HybridClock {
31    /// Create a new HLC seeded from the current wall-clock time.
32    pub fn new(node_id: &str) -> Self {
33        Self {
34            physical: now_ms(),
35            logical: 0,
36            node_id: node_id.to_string(),
37        }
38    }
39
40    /// Advance the clock and return a packed HLC timestamp.
41    ///
42    /// Guarantees that successive calls always produce strictly increasing values.
43    pub fn now(&mut self) -> i64 {
44        let wall = now_ms();
45        if wall > self.physical {
46            self.physical = wall;
47            self.logical = 0;
48        } else {
49            // Wall time hasn't moved — bump the logical counter.
50            self.logical += 1;
51        }
52        pack(self.physical, self.logical)
53    }
54
55    /// Merge the clock with a remote timestamp, advancing past it if necessary.
56    pub fn update(&mut self, remote_ts: i64) {
57        let (r_phys, r_log) = unpack(remote_ts);
58        let wall = now_ms();
59        let new_phys = wall.max(self.physical).max(r_phys);
60        if new_phys == self.physical && new_phys == r_phys {
61            self.logical = self.logical.max(r_log) + 1;
62        } else if new_phys == self.physical {
63            self.logical += 1;
64        } else if new_phys == r_phys {
65            self.logical = r_log + 1;
66        } else {
67            self.logical = 0;
68        }
69        self.physical = new_phys;
70    }
71}
72
73#[inline]
74fn pack(physical_ms: i64, logical: u32) -> i64 {
75    (physical_ms << 16) | (logical as i64 & 0xFFFF)
76}
77
78#[inline]
79fn unpack(ts: i64) -> (i64, u32) {
80    let phys = ts >> 16;
81    let log = (ts & 0xFFFF) as u32;
82    (phys, log)
83}
84
85fn now_ms() -> i64 {
86    std::time::SystemTime::now()
87        .duration_since(std::time::UNIX_EPOCH)
88        .unwrap_or_default()
89        .as_millis() as i64
90}
91
92// ── Core types ────────────────────────────────────────────────────────────────
93
94/// The kind of mutation being recorded.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "lowercase")]
97pub enum OpType {
98    Insert,
99    Update,
100    Delete,
101}
102
103impl OpType {
104    fn as_str(&self) -> &'static str {
105        match self {
106            OpType::Insert => "insert",
107            OpType::Update => "update",
108            OpType::Delete => "delete",
109        }
110    }
111
112    fn from_str(s: &str) -> Result<Self> {
113        match s {
114            "insert" => Ok(OpType::Insert),
115            "update" => Ok(OpType::Update),
116            "delete" => Ok(OpType::Delete),
117            other => Err(AgentDbError::InvalidArgument(format!(
118                "unknown op_type: {other}"
119            ))),
120        }
121    }
122}
123
124/// A single recorded mutation (insert, update, or delete) on a row.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SyncOp {
127    /// Globally unique operation identifier (UUID v4).
128    pub op_id: String,
129    /// Packed HLC timestamp (`(physical_ms << 16) | logical`).
130    pub hlc_ts: i64,
131    /// ID of the node that generated this operation.
132    pub node_id: String,
133    /// Name of the table that was mutated.
134    pub table_name: String,
135    /// Primary key of the affected row.
136    pub record_id: String,
137    /// Kind of mutation.
138    pub op_type: OpType,
139    /// The new row data (JSON); absent for deletes.
140    pub payload: Option<serde_json::Value>,
141}
142
143/// Snapshot of a known peer's sync state.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PeerStatus {
146    /// Unique identifier of the peer node.
147    pub peer_id: String,
148    /// The highest HLC timestamp that was last successfully synced to this peer.
149    pub last_synced_hlc: i64,
150    /// Optional network endpoint (URL, socket address, etc.).
151    pub endpoint: Option<String>,
152    /// Number of local ops whose `hlc_ts` is greater than `last_synced_hlc`.
153    pub ops_pending: usize,
154}
155
156/// Summary of a completed `apply_remote_ops` call.
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct SyncResult {
159    /// Number of ops that were written / won their conflict.
160    pub applied: usize,
161    /// Number of ops that collided with an existing op for the same row.
162    pub conflicts: usize,
163    /// Number of ops that lost their conflict and were not written.
164    pub skipped: usize,
165}
166
167/// Conflict resolution strategy for `apply_remote_ops`.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub enum ConflictStrategy {
170    /// The op with the higher HLC timestamp (later writer) wins.
171    LastWriterWins,
172    /// The op with the lower HLC timestamp (earlier writer) wins.
173    FirstWriterWins,
174}
175
176// ── SyncEngine ────────────────────────────────────────────────────────────────
177
178/// The primary entry-point for the sync layer.
179///
180/// Call [`SyncEngine::new`] once per database, then use
181/// [`record_mutation`](SyncEngine::record_mutation) after every write and
182/// [`apply_remote_ops`](SyncEngine::apply_remote_ops) when you receive a
183/// batch from a peer.
184pub struct SyncEngine {
185    conn: Arc<Mutex<rusqlite::Connection>>,
186    clock: HybridClock,
187    node_id: String,
188    strategy: ConflictStrategy,
189}
190
191impl SyncEngine {
192    /// Open the sync layer on top of an existing [`AgentDB`] connection.
193    ///
194    /// Creates `_adb_sync_log` and `_adb_sync_peers` if they don't exist yet.
195    pub fn new(db: &AgentDB, node_id: &str) -> Result<Self> {
196        // Clone the Arc so we share the same underlying connection pool.
197        let conn = db.conn_arc();
198        {
199            let c = conn.lock().unwrap();
200            c.execute_batch(
201                "
202                CREATE TABLE IF NOT EXISTS _adb_sync_log (
203                    op_id       TEXT PRIMARY KEY,
204                    hlc_ts      INTEGER NOT NULL,
205                    node_id     TEXT NOT NULL,
206                    table_name  TEXT NOT NULL,
207                    record_id   TEXT NOT NULL,
208                    op_type     TEXT NOT NULL CHECK(op_type IN ('insert','update','delete')),
209                    payload     TEXT,
210                    created_at  TEXT DEFAULT (datetime('now'))
211                );
212                CREATE INDEX IF NOT EXISTS idx_sync_log_hlc
213                    ON _adb_sync_log(hlc_ts);
214                CREATE INDEX IF NOT EXISTS idx_sync_log_row
215                    ON _adb_sync_log(table_name, record_id);
216
217                CREATE TABLE IF NOT EXISTS _adb_sync_peers (
218                    peer_id         TEXT PRIMARY KEY,
219                    last_synced_hlc INTEGER NOT NULL DEFAULT 0,
220                    endpoint        TEXT,
221                    updated_at      TEXT DEFAULT (datetime('now'))
222                );
223                ",
224            )?;
225        }
226        Ok(Self {
227            conn,
228            clock: HybridClock::new(node_id),
229            node_id: node_id.to_string(),
230            strategy: ConflictStrategy::LastWriterWins,
231        })
232    }
233
234    /// Record a mutation in the sync log and return its `op_id`.
235    pub fn record_mutation(
236        &mut self,
237        table: &str,
238        record_id: &str,
239        op: OpType,
240        payload: Option<serde_json::Value>,
241    ) -> Result<String> {
242        let op_id = Uuid::new_v4().to_string();
243        let hlc_ts = self.clock.now();
244        let payload_str = payload.as_ref().map(|v| v.to_string());
245        let conn = self.conn.lock().unwrap();
246        conn.execute(
247            "INSERT INTO _adb_sync_log
248                 (op_id, hlc_ts, node_id, table_name, record_id, op_type, payload)
249             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
250            params![
251                op_id,
252                hlc_ts,
253                self.node_id,
254                table,
255                record_id,
256                op.as_str(),
257                payload_str,
258            ],
259        )?;
260        Ok(op_id)
261    }
262
263    /// Return all ops with `hlc_ts > since_hlc`, ordered by timestamp.
264    pub fn get_ops_since(&self, since_hlc: i64) -> Result<Vec<SyncOp>> {
265        let conn = self.conn.lock().unwrap();
266        let mut stmt = conn.prepare(
267            "SELECT op_id, hlc_ts, node_id, table_name, record_id, op_type, payload
268             FROM _adb_sync_log
269             WHERE hlc_ts > ?1
270             ORDER BY hlc_ts ASC",
271        )?;
272        let rows = stmt.query_map(params![since_hlc], parse_sync_op)?;
273        rows.map(|r| r.map_err(AgentDbError::Sqlite)).collect()
274    }
275
276    /// Apply a batch of ops received from a remote peer.
277    ///
278    /// For each op:
279    /// - If no existing op exists for the same `(table_name, record_id)` →
280    ///   insert it (counts as `applied`).
281    /// - If a conflict exists → apply the configured [`ConflictStrategy`]:
282    ///   the winning op is inserted/updated; `conflicts` is always incremented;
283    ///   the losing op increments `skipped`.
284    pub fn apply_remote_ops(&mut self, ops: Vec<SyncOp>) -> Result<SyncResult> {
285        let mut result = SyncResult::default();
286
287        for op in ops {
288            // Advance our clock past every incoming timestamp.
289            self.clock.update(op.hlc_ts);
290
291            let existing = {
292                let conn = self.conn.lock().unwrap();
293                conn.query_row(
294                    "SELECT op_id, hlc_ts FROM _adb_sync_log
295                     WHERE table_name = ?1 AND record_id = ?2
296                     LIMIT 1",
297                    params![op.table_name, op.record_id],
298                    |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
299                )
300                .optional()?
301            };
302
303            match existing {
304                None => {
305                    // No conflict — just insert.
306                    self.insert_op(&op)?;
307                    result.applied += 1;
308                }
309                Some((existing_op_id, existing_hlc)) => {
310                    result.conflicts += 1;
311                    let incoming_wins = match self.strategy {
312                        ConflictStrategy::LastWriterWins => op.hlc_ts > existing_hlc,
313                        ConflictStrategy::FirstWriterWins => op.hlc_ts < existing_hlc,
314                    };
315                    if incoming_wins {
316                        // Replace the existing op.
317                        let conn = self.conn.lock().unwrap();
318                        conn.execute(
319                            "DELETE FROM _adb_sync_log WHERE op_id = ?1",
320                            params![existing_op_id],
321                        )?;
322                        drop(conn);
323                        self.insert_op(&op)?;
324                        result.applied += 1;
325                    } else {
326                        result.skipped += 1;
327                    }
328                }
329            }
330        }
331
332        Ok(result)
333    }
334
335    /// Register a peer node, optionally with a network endpoint.
336    pub fn add_peer(&self, peer_id: &str, endpoint: Option<&str>) -> Result<()> {
337        let conn = self.conn.lock().unwrap();
338        conn.execute(
339            "INSERT INTO _adb_sync_peers (peer_id, last_synced_hlc, endpoint)
340             VALUES (?1, 0, ?2)
341             ON CONFLICT(peer_id) DO UPDATE SET
342                 endpoint   = excluded.endpoint,
343                 updated_at = datetime('now')",
344            params![peer_id, endpoint],
345        )?;
346        Ok(())
347    }
348
349    /// Return the sync status of every registered peer.
350    pub fn sync_status(&self) -> Result<Vec<PeerStatus>> {
351        let conn = self.conn.lock().unwrap();
352        let mut stmt = conn.prepare(
353            "SELECT peer_id, last_synced_hlc, endpoint
354             FROM _adb_sync_peers
355             ORDER BY peer_id",
356        )?;
357        let rows = stmt.query_map([], |row| {
358            Ok((
359                row.get::<_, String>(0)?,
360                row.get::<_, i64>(1)?,
361                row.get::<_, Option<String>>(2)?,
362            ))
363        })?;
364
365        let mut statuses = Vec::new();
366        for row in rows {
367            let (peer_id, last_synced_hlc, endpoint) = row.map_err(AgentDbError::Sqlite)?;
368            // Count how many local ops have not yet been synced to this peer.
369            let ops_pending: i64 = conn.query_row(
370                "SELECT COUNT(*) FROM _adb_sync_log WHERE hlc_ts > ?1",
371                params![last_synced_hlc],
372                |r| r.get(0),
373            )?;
374            statuses.push(PeerStatus {
375                peer_id,
376                last_synced_hlc,
377                endpoint,
378                ops_pending: ops_pending as usize,
379            });
380        }
381        Ok(statuses)
382    }
383
384    /// Change the conflict resolution strategy (default: `LastWriterWins`).
385    pub fn set_strategy(&mut self, strategy: ConflictStrategy) {
386        self.strategy = strategy;
387    }
388
389    // ── Internal helpers ──────────────────────────────────────────────────────
390
391    fn insert_op(&self, op: &SyncOp) -> Result<()> {
392        let payload_str = op.payload.as_ref().map(|v| v.to_string());
393        let conn = self.conn.lock().unwrap();
394        conn.execute(
395            "INSERT OR REPLACE INTO _adb_sync_log
396                 (op_id, hlc_ts, node_id, table_name, record_id, op_type, payload)
397             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
398            params![
399                op.op_id,
400                op.hlc_ts,
401                op.node_id,
402                op.table_name,
403                op.record_id,
404                op.op_type.as_str(),
405                payload_str,
406            ],
407        )?;
408        Ok(())
409    }
410}
411
412// ── Row parser ────────────────────────────────────────────────────────────────
413
414fn parse_sync_op(row: &rusqlite::Row) -> rusqlite::Result<SyncOp> {
415    let op_type_str: String = row.get(5)?;
416    let payload_str: Option<String> = row.get(6)?;
417    let payload = payload_str.and_then(|s| serde_json::from_str(&s).ok());
418    let op_type = OpType::from_str(&op_type_str).map_err(|_| {
419        rusqlite::Error::FromSqlConversionFailure(
420            5,
421            rusqlite::types::Type::Text,
422            Box::new(std::fmt::Error),
423        )
424    })?;
425    Ok(SyncOp {
426        op_id: row.get(0)?,
427        hlc_ts: row.get(1)?,
428        node_id: row.get(2)?,
429        table_name: row.get(3)?,
430        record_id: row.get(4)?,
431        op_type,
432        payload,
433    })
434}