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    tables: HashMap<String, TableColumns>,
49    synced_tables: Vec<SyncedTable>,
50}
51
52struct TableColumns {
53    updated_at: usize,
54    names: Vec<String>,
55    blob: Option<crate::blob_declarations::BlobColumns>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub(crate) enum LwwComparison {
60    IncomingWins,
61    LocalWins,
62    IncomingGrossFuture,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum IncomingTimestampPolicy {
67    Received { receiver_wall_ms: u64 },
68    LocallyAuthored,
69}
70
71impl IncomingTimestampPolicy {
72    pub fn received_wall_ms(self) -> Option<u64> {
73        match self {
74            Self::Received { receiver_wall_ms } => Some(receiver_wall_ms),
75            Self::LocallyAuthored => None,
76        }
77    }
78}
79
80impl TableSchema {
81    pub(crate) fn for_apply(
82        conn: &Connection,
83        synced_tables: &[SyncedTable],
84        gates: &crate::Gates,
85    ) -> Result<Self, DbError> {
86        let mut tables = synced_tables.to_vec();
87        if gates.has_scoped_graph() {
88            for table in ["_coven_audience", "_coven_row_routes"] {
89                tables.push(SyncedTable::new(
90                    table,
91                    coven_protocol::synced_schema::RowIdentity::SharedKey,
92                ));
93            }
94        }
95        Self::from_db(conn, &tables)
96    }
97
98    /// Build schema info by querying `PRAGMA table_info` for each synced table.
99    /// A registered table that has no `_updated_at` column is a host integration
100    /// error and surfaces as `Err`.
101    pub(crate) fn from_db(
102        conn: &Connection,
103        synced_tables: &[SyncedTable],
104    ) -> Result<Self, DbError> {
105        let mut tables = HashMap::new();
106
107        for synced_table in synced_tables {
108            let table = synced_table.name();
109            let columns = table_columns(conn, table).map_err(DbError::from)?;
110            let updated_at = columns.iter().position(|name| name == "_updated_at");
111            let updated_at = updated_at.ok_or_else(|| {
112                DbError::Message(format!("synced table {table} has no _updated_at column"))
113            })?;
114            let blob = synced_table
115                .blob()
116                .map(|declaration| {
117                    crate::blob_declarations::BlobColumns::resolve(table, declaration, &columns)
118                })
119                .transpose()
120                .map_err(DbError::from)?;
121            tables.insert(
122                table.to_string(),
123                TableColumns {
124                    updated_at,
125                    names: columns,
126                    blob,
127                },
128            );
129        }
130
131        Ok(TableSchema {
132            tables,
133            synced_tables: synced_tables.to_vec(),
134        })
135    }
136
137    /// The `_updated_at` column index for a table, or `None` if the table was not
138    /// in the synced set passed to `from_db`. Incoming apply rejects an entire
139    /// changeset containing an undeclared table before premerge or row arbitration.
140    pub fn updated_at(&self, table: &str) -> Option<usize> {
141        self.tables.get(table).map(|columns| columns.updated_at)
142    }
143
144    pub fn columns(&self, table: &str) -> Option<&[String]> {
145        self.tables
146            .get(table)
147            .map(|columns| columns.names.as_slice())
148    }
149
150    pub(crate) fn blob_columns(
151        &self,
152        table: &str,
153    ) -> Option<&crate::blob_declarations::BlobColumns> {
154        self.tables
155            .get(table)
156            .and_then(|columns| columns.blob.as_ref())
157    }
158
159    pub fn synced_tables(&self) -> &[SyncedTable] {
160        &self.synced_tables
161    }
162}
163
164pub(crate) fn compare_lww_stamps(
165    table: &str,
166    incoming: Timestamp,
167    local: Timestamp,
168    timestamp_policy: IncomingTimestampPolicy,
169) -> LwwComparison {
170    if let Some(receiver_wall_ms) = timestamp_policy.received_wall_ms() {
171        if !incoming.is_within_future_bound(receiver_wall_ms) {
172            warn!(
173                table,
174                incoming = %incoming,
175                receiver_wall_ms,
176                "incoming _updated_at is grossly beyond the offline-skew allowance, \
177                 refusing to let it win; keeping local"
178            );
179            return LwwComparison::IncomingGrossFuture;
180        }
181    }
182    if incoming > local {
183        LwwComparison::IncomingWins
184    } else {
185        LwwComparison::LocalWins
186    }
187}
188
189/// Arbitrate one conflicting changeset row: pick the winning row, or omit.
190///
191/// Rules:
192/// - **DATA** (same row, both sides edited): incoming DELETE removes the row;
193///   otherwise compare `_updated_at`. Newer wins.
194/// - **NOTFOUND** (row deleted locally, incoming UPDATE): OMIT (delete wins).
195/// - **CONFLICT** (row exists, incoming INSERT): compare `_updated_at`. Newer wins.
196///
197/// FOREIGN_KEY conflicts never reach here — the transaction owner resolves them before
198/// calling this, because that conflict type's iterator does not expose the row.
199/// CONSTRAINT conflicts are also handled by the transaction owner so the caller can
200/// surface the table and roll back the entire changeset.
201///
202/// For DATA/CONFLICT, the incoming `_updated_at` is read from the side the op
203/// records — `item.new_value(uat)` for an INSERT/UPDATE, `item.old_value(uat)` for
204/// a DELETE (which has no "new" side) — and `item.conflict(uat)` is the existing
205/// local one; either can be absent (an unchanged column in an UPDATE) → `None` →
206/// OMIT (keep local). Incoming DELETE conflicts are remove-wins because a hard
207/// delete carries only the row's pre-delete stamp and cannot be reconstructed
208/// from a later partial UPDATE. Non-delete conflicts parse both stamps as HLC
209/// [`Timestamp`]s; an unparseable value keeps local. A grossly-future incoming
210/// stamp — beyond `receiver_wall_ms` + [`coven_protocol::hlc::MAX_FUTURE_SKEW_MS`] — is
211/// refused (kept local) so a broken clock can't win every conflict.
212pub(crate) fn arbitrate_row_conflict(
213    conflict_type: ConflictType,
214    item: ChangesetItem,
215    table: &str,
216    schema: &TableSchema,
217    timestamp_policy: IncomingTimestampPolicy,
218) -> ConflictAction {
219    match conflict_type {
220        ConflictType::SQLITE_CHANGESET_DATA | ConflictType::SQLITE_CHANGESET_CONFLICT => {
221            let Some(uat) = schema.updated_at(table) else {
222                // Incoming apply rejects an entire changeset containing an
223                // undeclared table before this closure. This arm covers a direct
224                // caller supplying a schema inconsistent with the item.
225                warn!(
226                    table,
227                    "conflict on a table not in this device's synced set, omitting the row"
228                );
229                return ConflictAction::SQLITE_CHANGESET_OMIT;
230            };
231            // Read each side's `_updated_at` and parse it to an HLC `Timestamp`. A
232            // rusqlite error reading the column (an API failure on a known column,
233            // genuinely exceptional) is logged distinctly from a value that is simply
234            // absent or doesn't parse — the latter falls through to the `_` arm below.
235            let read_stamp = |v: Result<rusqlite::types::ValueRef, rusqlite::Error>, side: &str| {
236                match v {
237                    Ok(value) => value_ref_to_string(value).and_then(|s| Timestamp::parse(&s)),
238                    Err(e) => {
239                        warn!(table, side, error = %e, "failed to read _updated_at column for conflict resolution");
240                        None
241                    }
242                }
243            };
244            // The incoming `_updated_at` lives on whichever side the op records: a
245            // DELETE carries only its old values (no "new" side), an INSERT/UPDATE
246            // carry the new value. Reading `new_value` for a DELETE returns None,
247            // which would keep local and leave a DELETE whose row diverges from the
248            // peer's copy unapplied — a zombie (the exact strand gate retract fixes,
249            // where the retracted root's flip bumped its gate column + `_updated_at`
250            // so its synthetic DELETE no longer matches the peer's pre-flip row).
251            let incoming_code = match item.op() {
252                Ok(op) => op.code(),
253                Err(error) => {
254                    warn!(
255                        table,
256                        error = %error,
257                        "failed to read changeset operation for conflict resolution; aborting apply"
258                    );
259                    return ConflictAction::SQLITE_CHANGESET_ABORT;
260                }
261            };
262            let incoming_is_delete = incoming_code == Action::SQLITE_DELETE;
263            if incoming_is_delete {
264                return ConflictAction::SQLITE_CHANGESET_REPLACE;
265            }
266            let incoming_raw = item.new_value(uat);
267            let incoming = read_stamp(incoming_raw, "incoming");
268            let local = read_stamp(item.conflict(uat), "local");
269
270            match (incoming, local) {
271                (Some(inc), Some(loc)) => {
272                    match compare_lww_stamps(table, inc, loc, timestamp_policy) {
273                        LwwComparison::IncomingWins => ConflictAction::SQLITE_CHANGESET_REPLACE,
274                        LwwComparison::LocalWins | LwwComparison::IncomingGrossFuture => {
275                            ConflictAction::SQLITE_CHANGESET_OMIT
276                        }
277                    }
278                }
279                _ => {
280                    warn!(
281                        table,
282                        "conflict without parseable _updated_at values, keeping local"
283                    );
284                    ConflictAction::SQLITE_CHANGESET_OMIT
285                }
286            }
287        }
288
289        // Row was deleted locally, incoming changeset has an UPDATE. Delete wins.
290        ConflictType::SQLITE_CHANGESET_NOTFOUND => ConflictAction::SQLITE_CHANGESET_OMIT,
291
292        // FOREIGN_KEY and CONSTRAINT are filtered out in `apply`; `ConflictType`
293        // is also `#[non_exhaustive]` (an `UNKNOWN` sentinel for codes outside
294        // the five SQLite documents). None reach a well-formed apply here, so
295        // keep local.
296        _ => {
297            warn!(table, "unexpected changeset conflict type, keeping local");
298            ConflictAction::SQLITE_CHANGESET_OMIT
299        }
300    }
301}