Skip to main content

coven_database/store/store_session/merge_materialization_transaction/
conflict.rs

1//! Row arbitration for changeset application: when an incoming row collides with
2//! the local one, decide which whole row wins.
3//!
4//! The arbiter compares each side's `_updated_at`: both are parsed as HLC
5//! [`Timestamp`]s and the greater one wins — the later writer of the row (the parsed
6//! order equals the lexicographic order of the string form, but parsing also lets
7//! the receiver reject a stamp it can't trust). An incoming DELETE is remove-wins:
8//! a hard delete carries only the row's pre-delete stamp and cannot be
9//! reconstructed from a later partial UPDATE, so the delete always wins and the row
10//! stays gone. The `_updated_at` column index is looked up dynamically from the
11//! schema so adding columns to the end of a table is safe.
12//!
13//! This is row-level: the losing row is dropped whole. Column-level survival of
14//! concurrent edits to *different* columns of one row is handled upstream by the
15//! premerge in [`super::MergeMaterializationTransaction::apply_changeset`], before the changeset reaches this arbiter — the
16//! arbiter only picks a winner for the collisions the premerge did not fold in.
17//!
18//! A member is trusted to author valid changesets, so this is robustness, not a
19//! security boundary: a buggy client or a device with a grossly-wrong wall clock
20//! can stamp a row far in the future — a value that would beat every honest stamp
21//! and win every conflict forever — so the receiver bounds an incoming stamp to
22//! its own wall clock plus an offline allowance
23//! ([`coven_protocol::hlc::MAX_FUTURE_SKEW_MS`]) and refuses to let a grossly-future one win
24//! (the matching refusal to let it ratchet the clock lives in the pull's HLC
25//! advance — a rejected stamp never becomes an applied row there either).
26//!
27//! The decision runs inside the transaction owner's changeset application,
28//! which is `Fn(ConflictType, ChangesetItem) -> ConflictAction + Send + 'static`.
29//! This module provides the per-table column map (moved owned into the closure)
30//! and the pure per-row decision.
31
32use std::collections::HashMap;
33
34use rusqlite::hooks::Action;
35use rusqlite::session::{ChangesetItem, ConflictAction, ConflictType};
36use rusqlite::Connection;
37use tracing::warn;
38
39use crate::changeset::value_ref_to_string;
40use crate::{table_columns, DbError};
41use coven_protocol::hlc::Timestamp;
42use coven_protocol::synced_schema::SyncedTable;
43
44/// Schema info for all synced tables: maps table name to column indices. Built
45/// once before an apply and moved (owned) into the conflict closure, which must
46/// be `'static`.
47pub struct TableSchema {
48    updated_at_by_table: HashMap<String, usize>,
49    columns_by_table: HashMap<String, Vec<String>>,
50    synced_tables: Vec<SyncedTable>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(crate) enum LwwComparison {
55    IncomingWins,
56    LocalWins,
57    IncomingGrossFuture,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum IncomingTimestampPolicy {
62    Received { receiver_wall_ms: u64 },
63    LocallyAuthored,
64}
65
66impl IncomingTimestampPolicy {
67    pub fn received_wall_ms(self) -> Option<u64> {
68        match self {
69            Self::Received { receiver_wall_ms } => Some(receiver_wall_ms),
70            Self::LocallyAuthored => None,
71        }
72    }
73}
74
75impl TableSchema {
76    pub(crate) fn for_apply(
77        conn: &Connection,
78        synced_tables: &[SyncedTable],
79        gates: &crate::Gates,
80    ) -> Result<Self, DbError> {
81        let mut tables = synced_tables.to_vec();
82        if gates.has_scoped_graph() {
83            for table in ["_coven_audience", "_coven_row_routes"] {
84                tables.push(SyncedTable::new(
85                    table,
86                    coven_protocol::synced_schema::RowIdentity::SharedKey,
87                ));
88            }
89        }
90        Self::from_db(conn, &tables)
91    }
92
93    /// Build schema info by querying `PRAGMA table_info` for each synced table.
94    /// A registered table that has no `_updated_at` column is a host integration
95    /// error and surfaces as `Err`.
96    pub(crate) fn from_db(
97        conn: &Connection,
98        synced_tables: &[SyncedTable],
99    ) -> Result<Self, DbError> {
100        let mut updated_at_by_table = HashMap::new();
101        let mut columns_by_table = HashMap::new();
102
103        for synced_table in synced_tables {
104            let table = synced_table.name();
105            let columns = table_columns(conn, table).map_err(DbError::from)?;
106            let updated_at = columns.iter().position(|name| name == "_updated_at");
107            let updated_at = updated_at.ok_or_else(|| {
108                DbError::Message(format!("synced table {table} has no _updated_at column"))
109            })?;
110            updated_at_by_table.insert(table.to_string(), updated_at);
111            columns_by_table.insert(table.to_string(), columns);
112        }
113
114        Ok(TableSchema {
115            updated_at_by_table,
116            columns_by_table,
117            synced_tables: synced_tables.to_vec(),
118        })
119    }
120
121    /// The `_updated_at` column index for a table, or `None` if the table was not
122    /// in the synced set passed to `from_db`. Incoming apply rejects an entire
123    /// changeset containing an undeclared table before premerge or row arbitration.
124    pub fn updated_at(&self, table: &str) -> Option<usize> {
125        self.updated_at_by_table.get(table).copied()
126    }
127
128    pub fn columns(&self, table: &str) -> Option<&[String]> {
129        self.columns_by_table.get(table).map(Vec::as_slice)
130    }
131
132    pub fn synced_tables(&self) -> &[SyncedTable] {
133        &self.synced_tables
134    }
135}
136
137pub(crate) fn compare_lww_stamps(
138    table: &str,
139    incoming: Timestamp,
140    local: Timestamp,
141    timestamp_policy: IncomingTimestampPolicy,
142) -> LwwComparison {
143    if let Some(receiver_wall_ms) = timestamp_policy.received_wall_ms() {
144        if !incoming.is_within_future_bound(receiver_wall_ms) {
145            warn!(
146                table,
147                incoming = %incoming,
148                receiver_wall_ms,
149                "incoming _updated_at is grossly beyond the offline-skew allowance, \
150                 refusing to let it win; keeping local"
151            );
152            return LwwComparison::IncomingGrossFuture;
153        }
154    }
155    if incoming > local {
156        LwwComparison::IncomingWins
157    } else {
158        LwwComparison::LocalWins
159    }
160}
161
162/// Arbitrate one conflicting changeset row: pick the winning row, or omit.
163///
164/// Rules:
165/// - **DATA** (same row, both sides edited): incoming DELETE removes the row;
166///   otherwise compare `_updated_at`. Newer wins.
167/// - **NOTFOUND** (row deleted locally, incoming UPDATE): OMIT (delete wins).
168/// - **CONFLICT** (row exists, incoming INSERT): compare `_updated_at`. Newer wins.
169///
170/// FOREIGN_KEY conflicts never reach here — the transaction owner resolves them before
171/// calling this, because that conflict type's iterator does not expose the row.
172/// CONSTRAINT conflicts are also handled by the transaction owner so the caller can
173/// surface the table and roll back the entire changeset.
174///
175/// For DATA/CONFLICT, the incoming `_updated_at` is read from the side the op
176/// records — `item.new_value(uat)` for an INSERT/UPDATE, `item.old_value(uat)` for
177/// a DELETE (which has no "new" side) — and `item.conflict(uat)` is the existing
178/// local one; either can be absent (an unchanged column in an UPDATE) → `None` →
179/// OMIT (keep local). Incoming DELETE conflicts are remove-wins because a hard
180/// delete carries only the row's pre-delete stamp and cannot be reconstructed
181/// from a later partial UPDATE. Non-delete conflicts parse both stamps as HLC
182/// [`Timestamp`]s; an unparseable value keeps local. A grossly-future incoming
183/// stamp — beyond `receiver_wall_ms` + [`coven_protocol::hlc::MAX_FUTURE_SKEW_MS`] — is
184/// refused (kept local) so a broken clock can't win every conflict.
185pub(crate) fn arbitrate_row_conflict(
186    conflict_type: ConflictType,
187    item: ChangesetItem,
188    table: &str,
189    schema: &TableSchema,
190    timestamp_policy: IncomingTimestampPolicy,
191) -> ConflictAction {
192    match conflict_type {
193        ConflictType::SQLITE_CHANGESET_DATA | ConflictType::SQLITE_CHANGESET_CONFLICT => {
194            let Some(uat) = schema.updated_at(table) else {
195                // Incoming apply rejects an entire changeset containing an
196                // undeclared table before this closure. This arm covers a direct
197                // caller supplying a schema inconsistent with the item.
198                warn!(
199                    table,
200                    "conflict on a table not in this device's synced set, omitting the row"
201                );
202                return ConflictAction::SQLITE_CHANGESET_OMIT;
203            };
204            // Read each side's `_updated_at` and parse it to an HLC `Timestamp`. A
205            // rusqlite error reading the column (an API failure on a known column,
206            // genuinely exceptional) is logged distinctly from a value that is simply
207            // absent or doesn't parse — the latter falls through to the `_` arm below.
208            let read_stamp = |v: Result<rusqlite::types::ValueRef, rusqlite::Error>, side: &str| {
209                match v {
210                    Ok(value) => value_ref_to_string(value).and_then(|s| Timestamp::parse(&s)),
211                    Err(e) => {
212                        warn!(table, side, error = %e, "failed to read _updated_at column for conflict resolution");
213                        None
214                    }
215                }
216            };
217            // The incoming `_updated_at` lives on whichever side the op records: a
218            // DELETE carries only its old values (no "new" side), an INSERT/UPDATE
219            // carry the new value. Reading `new_value` for a DELETE returns None,
220            // which would keep local and leave a DELETE whose row diverges from the
221            // peer's copy unapplied — a zombie (the exact strand gate retract fixes,
222            // where the retracted root's flip bumped its gate column + `_updated_at`
223            // so its synthetic DELETE no longer matches the peer's pre-flip row).
224            let incoming_code = match item.op() {
225                Ok(op) => op.code(),
226                Err(error) => {
227                    warn!(
228                        table,
229                        error = %error,
230                        "failed to read changeset operation for conflict resolution; aborting apply"
231                    );
232                    return ConflictAction::SQLITE_CHANGESET_ABORT;
233                }
234            };
235            let incoming_is_delete = incoming_code == Action::SQLITE_DELETE;
236            if incoming_is_delete {
237                return ConflictAction::SQLITE_CHANGESET_REPLACE;
238            }
239            let incoming_raw = item.new_value(uat);
240            let incoming = read_stamp(incoming_raw, "incoming");
241            let local = read_stamp(item.conflict(uat), "local");
242
243            match (incoming, local) {
244                (Some(inc), Some(loc)) => {
245                    match compare_lww_stamps(table, inc, loc, timestamp_policy) {
246                        LwwComparison::IncomingWins => ConflictAction::SQLITE_CHANGESET_REPLACE,
247                        LwwComparison::LocalWins | LwwComparison::IncomingGrossFuture => {
248                            ConflictAction::SQLITE_CHANGESET_OMIT
249                        }
250                    }
251                }
252                _ => {
253                    warn!(
254                        table,
255                        "conflict without parseable _updated_at values, keeping local"
256                    );
257                    ConflictAction::SQLITE_CHANGESET_OMIT
258                }
259            }
260        }
261
262        // Row was deleted locally, incoming changeset has an UPDATE. Delete wins.
263        ConflictType::SQLITE_CHANGESET_NOTFOUND => ConflictAction::SQLITE_CHANGESET_OMIT,
264
265        // FOREIGN_KEY and CONSTRAINT are filtered out in `apply`; `ConflictType`
266        // is also `#[non_exhaustive]` (an `UNKNOWN` sentinel for codes outside
267        // the five SQLite documents). None reach a well-formed apply here, so
268        // keep local.
269        _ => {
270            warn!(table, "unexpected changeset conflict type, keeping local");
271            ConflictAction::SQLITE_CHANGESET_OMIT
272        }
273    }
274}