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}