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}