1use std::borrow::Cow;
20use std::collections::HashSet;
21use std::sync::{Arc, Mutex};
22
23use fallible_streaming_iterator::FallibleStreamingIterator;
24use rusqlite::hooks::Action;
25use rusqlite::session::{ChangesetItem, ChangesetIter, ConflictAction, ConflictType};
26use rusqlite::types::{Value, ValueRef};
27use rusqlite::{Connection, OptionalExtension};
28use tracing::warn;
29
30use super::conflict::{
31 arbitrate_row_conflict, compare_lww_stamps, IncomingTimestampPolicy, LwwComparison, TableSchema,
32};
33use crate::changeset::{value_ref_to_string, UpdateValue};
34use crate::changeset_identity::validate_changeset_row_identities;
35use crate::gate::Changegroup;
36use crate::store::store_session::replay_sql::ReplaySql;
37use crate::{quote_ident, ChangesetIdentityError, DbError};
38use coven_protocol::hlc::Timestamp;
39#[cfg(any(test, feature = "test-utils"))]
40use coven_protocol::synced_schema::SyncedTable;
41
42use super::MergeMaterializationTransaction;
43
44#[path = "recorded_changeset.rs"]
45mod recorded_changeset;
46#[cfg(test)]
47#[path = "recorded_changeset_tests.rs"]
48mod recorded_changeset_tests;
49#[cfg(test)]
50#[path = "three_way_tests.rs"]
51mod three_way_tests;
52
53#[cfg_attr(
55 not(any(test, feature = "test-utils")),
56 allow(unreachable_pub),
57 doc = "Public when the `test-utils` feature exposes changeset application."
58)]
59pub struct ApplyResult {
60 #[cfg(any(test, feature = "test-utils"))]
64 pub had_fk_violations: bool,
65 pub constraint_conflict_tables: Vec<String>,
68 #[cfg(any(test, feature = "test-utils"))]
71 pub winning_rows: Vec<WinningRow>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct WinningRow {
76 pub table: String,
77 pub row_id: String,
78 pub row_stamp: Option<String>,
79}
80
81#[derive(Clone, Debug)]
82struct IncomingRow {
83 table: String,
84 row_id: String,
85 row_stamp: Option<String>,
86}
87
88pub struct ValidatedChangeset<B> {
93 bytes: B,
94 schema: Arc<TableSchema>,
95}
96
97impl<B: AsRef<[u8]>> ValidatedChangeset<B> {
98 pub fn new(bytes: B, schema: Arc<TableSchema>) -> Result<Self, ChangesetIdentityError> {
99 validate_changeset_row_identities(bytes.as_ref(), schema.synced_tables())?;
100 Ok(Self { bytes, schema })
101 }
102
103 pub fn bytes(&self) -> &[u8] {
104 self.bytes.as_ref()
105 }
106
107 pub fn schema(&self) -> &TableSchema {
108 &self.schema
109 }
110
111 pub fn validate_subset<C: AsRef<[u8]>>(
112 &self,
113 bytes: C,
114 ) -> Result<ValidatedChangeset<C>, ChangesetIdentityError> {
115 ValidatedChangeset::new(bytes, self.schema.clone())
116 }
117}
118
119#[cfg(any(test, feature = "test-utils"))]
127pub fn resolve_and_apply_changeset(
128 conn: &Connection,
129 store_dir: &coven_foundation::store_dir::StoreDir,
130 bytes: &[u8],
131 tables: &[SyncedTable],
132 receiver_wall_ms: u64,
133) -> Result<ApplyResult, DbError> {
134 let schema = Arc::new(TableSchema::from_db(conn, tables)?);
135 resolve_and_apply_changeset_with_schema(conn, store_dir, bytes, schema, receiver_wall_ms)
136}
137
138#[cfg(any(test, feature = "test-utils"))]
155pub(crate) fn resolve_and_apply_changeset_with_schema(
156 conn: &Connection,
157 store_dir: &coven_foundation::store_dir::StoreDir,
158 bytes: &[u8],
159 schema: Arc<TableSchema>,
160 receiver_wall_ms: u64,
161) -> Result<ApplyResult, DbError> {
162 let changeset = ValidatedChangeset::new(bytes, schema).map_err(DbError::from)?;
163 let tx = conn.unchecked_transaction().map_err(DbError::from)?;
164 let result = MergeMaterializationTransaction::from_store(
165 crate::store::store_session::StoreTransaction::new(&tx, store_dir),
166 )
167 .apply_changeset(
168 changeset,
169 IncomingTimestampPolicy::Received { receiver_wall_ms },
170 )?;
171 if result.had_fk_violations || !result.constraint_conflict_tables.is_empty() {
172 tx.rollback().map_err(DbError::from)?;
173 } else {
174 tx.commit().map_err(DbError::from)?;
175 }
176 Ok(result)
177}
178
179impl MergeMaterializationTransaction<'_, '_> {
180 pub(crate) fn apply_changeset<B: AsRef<[u8]>>(
181 &self,
182 changeset: ValidatedChangeset<B>,
183 timestamp_policy: IncomingTimestampPolicy,
184 ) -> Result<ApplyResult, DbError> {
185 let conn = self.store.transaction;
186 let ValidatedChangeset { bytes, schema } = changeset;
187 let bytes = bytes.as_ref();
188 #[cfg(any(test, feature = "test-utils"))]
189 let incoming_rows = incoming_rows(bytes, &schema)?;
190
191 let constraint_conflict_tables = Arc::new(Mutex::new(Vec::new()));
192 let (prepared_bytes, merged_updates) =
193 prepare_column_merges(conn, bytes, &schema, timestamp_policy)?;
194
195 let closure_constraint_conflict_tables = constraint_conflict_tables.clone();
196 let closure_schema = schema.clone();
197 ReplaySql::begin(conn)?.run(|| {
198 conn.apply_strm(
199 &mut prepared_bytes.as_ref(),
200 None::<fn(&str) -> bool>,
201 move |conflict_type, item| {
202 if conflict_type == ConflictType::SQLITE_CHANGESET_FOREIGN_KEY {
206 return ConflictAction::SQLITE_CHANGESET_OMIT;
207 }
208 let (table, op_code) = match item.op() {
211 Ok(op) => (op.table_name().to_string(), op.code()),
212 Err(error) => {
213 warn!(error = %error, "failed to read changeset conflict operation; aborting apply");
214 return ConflictAction::SQLITE_CHANGESET_ABORT;
215 }
216 };
217 if conflict_type == ConflictType::SQLITE_CHANGESET_CONSTRAINT {
218 warn!(
219 table = %table,
220 "changeset hit a non-retryable SQLite constraint conflict; rejecting changeset"
221 );
222 match closure_constraint_conflict_tables.lock() {
223 Ok(mut tables) => tables.push(table),
224 Err(error) => {
225 warn!(error = %error, "failed to record changeset constraint conflict; aborting apply");
226 return ConflictAction::SQLITE_CHANGESET_ABORT;
227 }
228 }
229 return ConflictAction::SQLITE_CHANGESET_OMIT;
230 }
231 if conflict_type == ConflictType::SQLITE_CHANGESET_DATA
232 && op_code == Action::SQLITE_UPDATE
233 {
234 match update_pk_key(&item, &table).map(|pk| {
235 merged_updates.contains(&RowKey {
236 table: table.clone(),
237 pk,
238 })
239 }) {
240 Ok(true) => return ConflictAction::SQLITE_CHANGESET_REPLACE,
241 Ok(false) => {}
242 Err(error) => {
243 warn!(table, error = %error, "failed to read merged UPDATE primary key; aborting apply");
244 return ConflictAction::SQLITE_CHANGESET_ABORT;
245 }
246 }
247 }
248 arbitrate_row_conflict(
249 conflict_type,
250 item,
251 &table,
252 &closure_schema,
253 timestamp_policy,
254 )
255 },
256 )
257 .map_err(DbError::from)
258 })?;
259 #[cfg(any(test, feature = "test-utils"))]
260 let had_fk_violations = self.has_foreign_key_violations()?;
261 let constraint_conflict_tables = constraint_conflict_tables
262 .lock()
263 .map_err(|_| {
264 DbError::Message("constraint conflict table collection is poisoned".to_string())
265 })?
266 .clone();
267 #[cfg(any(test, feature = "test-utils"))]
268 let winning_rows = resolve_winning_rows(conn, &schema, incoming_rows)?;
269
270 Ok(ApplyResult {
271 #[cfg(any(test, feature = "test-utils"))]
272 had_fk_violations,
273 constraint_conflict_tables,
274 #[cfg(any(test, feature = "test-utils"))]
275 winning_rows,
276 })
277 }
278
279 pub(crate) fn current_winning_rows<B: AsRef<[u8]>>(
280 &self,
281 schema: &TableSchema,
282 changeset: B,
283 ) -> Result<Vec<WinningRow>, DbError> {
284 resolve_winning_rows(
285 self.store.transaction,
286 schema,
287 incoming_rows(changeset.as_ref(), schema)?,
288 )
289 }
290
291 pub(crate) fn apply_changeset_strict<B: AsRef<[u8]>>(
292 &self,
293 changeset: ValidatedChangeset<B>,
294 blob_decls: &crate::BlobDecls,
295 ) -> Result<(), DbError> {
296 let bytes = changeset.bytes();
297 let old_changes = crate::walk_old_changeset(bytes).map_err(DbError::Changeset)?;
298 let new_changes = crate::walk_changeset(bytes).map_err(DbError::Changeset)?;
299 let old_exact_bindings = super::exact_blob_bindings_on(self.store.transaction)?;
300 let obsolete = crate::local_blob_cleanup_intents::intents_from_changes(
301 blob_decls,
302 &old_changes,
303 &new_changes,
304 )?;
305 self.store
306 .transaction
307 .apply_strm(
308 &mut &bytes[..],
309 None::<fn(&str) -> bool>,
310 |_conflict_type, _item| ConflictAction::SQLITE_CHANGESET_ABORT,
311 )
312 .map_err(DbError::from)?;
313 for intent in obsolete {
314 super::record_obsolete_copy_intents_from_bindings_on(
315 self.store.transaction,
316 blob_decls,
317 &intent,
318 &old_exact_bindings,
319 )?;
320 }
321 Ok(())
322 }
323}
324
325fn incoming_rows(bytes: &[u8], schema: &TableSchema) -> Result<Vec<IncomingRow>, DbError> {
326 if bytes.is_empty() {
327 return Ok(Vec::new());
328 }
329 let input: &mut dyn std::io::Read = &mut &bytes[..];
330 let mut iter = ChangesetIter::start_strm(&input).map_err(DbError::from)?;
331 let mut rows = Vec::new();
332 while let Some(item) = iter.next().map_err(DbError::from)? {
333 let op = item.op().map_err(DbError::from)?;
334 let table = op.table_name();
335 let updated_at = schema.updated_at(table).ok_or_else(|| {
336 DbError::Message(format!("changeset contains undeclared table {table:?}"))
337 })?;
338 let (id_side, stamp_side) = match op.code() {
339 Action::SQLITE_INSERT => (UpdateValue::New, Some(UpdateValue::New)),
340 Action::SQLITE_UPDATE => (UpdateValue::Old, Some(UpdateValue::New)),
341 Action::SQLITE_DELETE => (UpdateValue::Old, None),
342 code => {
343 return Err(DbError::Message(format!(
344 "changeset for {table:?} contains unsupported operation {code:?}"
345 )));
346 }
347 };
348 let row_id = required_text_changeset_value(item, table, 0, id_side, "row id")?;
349 let row_stamp = stamp_side
350 .map(|side| required_text_changeset_value(item, table, updated_at, side, "row stamp"))
351 .transpose()?;
352 rows.push(IncomingRow {
353 table: table.to_string(),
354 row_id,
355 row_stamp,
356 });
357 }
358 Ok(rows)
359}
360
361fn required_text_changeset_value(
362 item: &ChangesetItem,
363 table: &str,
364 column: usize,
365 side: UpdateValue,
366 field: &str,
367) -> Result<String, DbError> {
368 let value = changeset_value(item, column, side)?.ok_or_else(|| {
369 DbError::Message(format!("changeset for {table:?} has no {side:?} {field}"))
370 })?;
371 let Value::Text(value) = value else {
372 return Err(DbError::Message(format!(
373 "changeset for {table:?} has non-TEXT {side:?} {field}"
374 )));
375 };
376 Ok(value)
377}
378
379fn resolve_winning_rows(
380 conn: &Connection,
381 schema: &TableSchema,
382 incoming: Vec<IncomingRow>,
383) -> Result<Vec<WinningRow>, DbError> {
384 let mut winners = Vec::new();
385 for row in incoming {
386 let columns = schema.columns(&row.table).ok_or_else(|| {
387 DbError::Message(format!("synced table {:?} has no column map", row.table))
388 })?;
389 let updated_at = schema.updated_at(&row.table).ok_or_else(|| {
390 DbError::Message(format!(
391 "synced table {:?} has no _updated_at column index",
392 row.table
393 ))
394 })?;
395 let sql = format!(
396 "SELECT {} FROM {} WHERE {} = ?1",
397 quote_ident(&columns[updated_at]),
398 quote_ident(&row.table),
399 quote_ident(&columns[0])
400 );
401 let live_stamp = conn
402 .query_row(&sql, [&row.row_id], |result| result.get::<_, String>(0))
403 .optional()
404 .map_err(DbError::from)?;
405 let incoming_won = match (&row.row_stamp, &live_stamp) {
406 (None, None) => true,
407 (Some(expected), Some(actual)) => expected == actual,
408 _ => false,
409 };
410 if incoming_won {
411 winners.push(WinningRow {
412 table: row.table,
413 row_id: row.row_id,
414 row_stamp: row.row_stamp,
415 });
416 }
417 }
418 Ok(winners)
419}
420
421#[derive(Clone, Debug, Eq, PartialEq, Hash)]
422struct RowKey {
423 table: String,
424 pk: String,
425}
426
427struct UpdateColumn {
428 index: usize,
429 base: Value,
430 incoming: Value,
431}
432
433struct IncomingUpdate {
434 table: String,
435 pk: String,
436 columns: Vec<UpdateColumn>,
437 incoming_updated_at: Timestamp,
438 incoming_updated_at_value: Value,
439}
440
441fn prepare_column_merges<'bytes>(
442 conn: &Connection,
443 bytes: &'bytes [u8],
444 schema: &TableSchema,
445 timestamp_policy: IncomingTimestampPolicy,
446) -> Result<(Cow<'bytes, [u8]>, HashSet<RowKey>), DbError> {
447 if bytes.is_empty() {
448 return Ok((Cow::Borrowed(bytes), HashSet::new()));
449 }
450 let input: &mut dyn std::io::Read = &mut &bytes[..];
451 let mut iter = ChangesetIter::start_strm(&input)?;
452 let mut handled = HashSet::new();
453 let mut encoder = None;
454 while let Some(item) = iter.next()? {
455 let Some(update) = incoming_update(item, schema)? else {
456 continue;
457 };
458 if prepare_losing_update(
459 conn,
460 schema,
461 &update,
462 timestamp_policy,
463 item.op()?.indirect(),
464 &mut encoder,
465 bytes,
466 )? {
467 handled.insert(RowKey {
468 table: update.table,
469 pk: update.pk,
470 });
471 }
472 }
473 let prepared = match encoder {
474 Some(encoder) => Cow::Owned(encoder.output()?),
475 None => Cow::Borrowed(bytes),
476 };
477 Ok((prepared, handled))
478}
479
480fn incoming_update(
481 item: &ChangesetItem,
482 schema: &TableSchema,
483) -> Result<Option<IncomingUpdate>, DbError> {
484 let op = item.op().map_err(DbError::from)?;
485 if op.code() != Action::SQLITE_UPDATE {
486 return Ok(None);
487 }
488
489 let table = op.table_name();
490 let Some(updated_at) = schema.updated_at(table) else {
491 warn!(
492 table,
493 "UPDATE changeset table is not in the local synced schema"
494 );
495 return Ok(None);
496 };
497
498 let Some(incoming_updated_at_value) = changeset_value(item, updated_at, UpdateValue::New)?
499 else {
500 warn!(table, "UPDATE changeset has no incoming _updated_at value");
501 return Ok(None);
502 };
503 let Some(incoming_updated_at) = timestamp_from_value(&incoming_updated_at_value) else {
504 warn!(
505 table,
506 "UPDATE changeset has an incoming _updated_at value that does not parse"
507 );
508 return Ok(None);
509 };
510
511 let pk = update_pk_key(item, table)?;
512
513 let columns = update_columns(item, updated_at)?;
514 if let Some(blob) = schema.blob_columns(table) {
515 let edits_blob = columns.iter().any(|column| {
516 blob.iter().any(|index| index == column.index) && column.base != column.incoming
517 });
518 if edits_blob {
519 for index in blob.iter().filter(|index| *index != 0) {
520 if !columns.iter().any(|column| column.index == index) {
521 return Err(DbError::Message(format!(
522 "blob UPDATE for {table} omits content column {index}"
523 )));
524 }
525 }
526 }
527 }
528
529 Ok(Some(IncomingUpdate {
530 table: table.to_string(),
531 pk,
532 columns,
533 incoming_updated_at,
534 incoming_updated_at_value,
535 }))
536}
537
538fn update_columns(item: &ChangesetItem, updated_at: usize) -> Result<Vec<UpdateColumn>, DbError> {
539 let op = item.op()?;
540 let table = op.table_name();
541 let mut columns = Vec::new();
542 for index in 0..op.number_of_columns() as usize {
543 if index == 0 || index == updated_at {
544 continue;
545 }
546 let base = changeset_value(item, index, UpdateValue::Old)?;
547 let incoming = changeset_value(item, index, UpdateValue::New)?;
548 match (base, incoming) {
549 (Some(base), Some(incoming)) => columns.push(UpdateColumn {
550 index,
551 base,
552 incoming,
553 }),
554 (None, None) => {}
555 _ => {
556 return Err(DbError::Message(format!(
557 "UPDATE changeset for {table} has only one side for column {index}"
558 )));
559 }
560 }
561 }
562
563 Ok(columns)
564}
565
566fn prepare_losing_update(
567 conn: &Connection,
568 schema: &TableSchema,
569 update: &IncomingUpdate,
570 timestamp_policy: IncomingTimestampPolicy,
571 indirect: bool,
572 encoder: &mut Option<Changegroup>,
573 bytes: &[u8],
574) -> Result<bool, DbError> {
575 let columns = schema.columns(&update.table).ok_or_else(|| {
576 DbError::Message(format!("synced table {} has no column map", update.table))
577 })?;
578 let updated_at = schema.updated_at(&update.table).ok_or_else(|| {
579 DbError::Message(format!(
580 "synced table {} has no _updated_at column",
581 update.table
582 ))
583 })?;
584 if update.columns.iter().any(|c| c.index >= columns.len()) || updated_at >= columns.len() {
585 return Err(DbError::Message(format!(
586 "UPDATE changeset for {} names a column outside the local schema",
587 update.table
588 )));
589 }
590 let sql = format!(
591 "SELECT {} FROM {} WHERE {} = ?1",
592 columns
593 .iter()
594 .map(|column| quote_ident(column))
595 .collect::<Vec<_>>()
596 .join(", "),
597 quote_ident(&update.table),
598 quote_ident(&columns[0]),
599 );
600 let local = conn
601 .query_row(&sql, [&update.pk], |row| {
602 (0..columns.len())
603 .map(|index| row.get::<_, Value>(index))
604 .collect::<rusqlite::Result<Vec<_>>>()
605 })
606 .optional()?;
607 let Some(local) = local else { return Ok(false) };
608 let local_stamp = timestamp_from_value(&local[updated_at]).ok_or_else(|| {
609 DbError::Message(format!(
610 "local row in {} has no parseable _updated_at",
611 update.table
612 ))
613 })?;
614 match compare_lww_stamps(
615 &update.table,
616 update.incoming_updated_at.clone(),
617 local_stamp,
618 timestamp_policy,
619 ) {
620 LwwComparison::IncomingWins | LwwComparison::IncomingGrossFuture => return Ok(false),
621 LwwComparison::LocalWins => {}
622 }
623 let mut incoming = local.clone();
627 let mut merged = local.clone();
628 incoming[updated_at] = update.incoming_updated_at_value.clone();
629 let blob = schema.blob_columns(&update.table);
630 let merge_blob = blob.is_none_or(|blob| {
633 update
634 .columns
635 .iter()
636 .filter(|column| blob.iter().any(|index| index == column.index))
637 .all(|column| local[column.index] == column.base)
638 });
639 for column in &update.columns {
640 incoming[column.index] = column.incoming.clone();
641 let belongs_to_blob =
642 blob.is_some_and(|blob| blob.iter().any(|index| index == column.index));
643 if (belongs_to_blob && merge_blob)
644 || (!belongs_to_blob && local[column.index] == column.base)
645 {
646 merged[column.index] = column.incoming.clone();
647 }
648 }
649 if merged == local {
650 return Ok(false);
651 }
652 let encoder = match encoder {
653 Some(encoder) => encoder,
654 slot @ None => {
655 let group = Changegroup::new()?;
656 unsafe { group.set_schema(conn.handle()) }?;
659 group.add_changeset(bytes)?;
660 slot.insert(group)
661 }
662 };
663 let (old, new): (Vec<_>, Vec<_>) = incoming
664 .into_iter()
665 .zip(merged)
666 .enumerate()
667 .map(|(index, (incoming, merged))| {
668 if index == 0 {
669 (Some(incoming), None)
670 } else if incoming != merged {
671 (Some(incoming), Some(merged))
672 } else {
673 (None, None)
674 }
675 })
676 .unzip();
677 encoder.add_update(&update.table, &old, &new, indirect)?;
678 Ok(true)
679}
680
681fn changeset_value(
682 item: &ChangesetItem,
683 column: usize,
684 side: UpdateValue,
685) -> Result<Option<Value>, DbError> {
686 let value = match side {
687 UpdateValue::Old => item.old_value(column),
688 UpdateValue::New => item.new_value(column),
689 };
690 match value {
691 Ok(value) => Value::try_from(value).map(Some).map_err(|error| {
692 DbError::context(
693 format!("changeset {side:?} value conversion failed for column {column}"),
694 error,
695 )
696 }),
697 Err(rusqlite::Error::InvalidColumnIndex(_)) => Ok(None),
698 Err(error) => Err(DbError::context(
699 format!("changeset {side:?} value read failed for column {column}"),
700 error,
701 )),
702 }
703}
704
705fn update_pk_key(item: &ChangesetItem, table: &str) -> Result<String, DbError> {
706 match item.old_value(0) {
707 Ok(value) => text_id_from_value_ref(table, value),
708 Err(rusqlite::Error::InvalidColumnIndex(_)) => Err(DbError::Message(format!(
709 "UPDATE changeset for {table} has no old-side primary key"
710 ))),
711 Err(error) => Err(DbError::context(
712 format!("UPDATE changeset for {table} primary key read failed"),
713 error,
714 )),
715 }
716}
717
718fn text_id_from_value_ref(table: &str, value: ValueRef<'_>) -> Result<String, DbError> {
719 let ValueRef::Text(bytes) = value else {
720 return Err(DbError::Message(format!(
721 "UPDATE changeset for {table} primary key is not TEXT"
722 )));
723 };
724 std::str::from_utf8(bytes)
725 .map(str::to_owned)
726 .map_err(|error| {
727 DbError::context(
728 format!("UPDATE changeset for {table} primary key is not UTF-8"),
729 error,
730 )
731 })
732}
733
734fn timestamp_from_value(value: &Value) -> Option<Timestamp> {
735 value_ref_to_string(ValueRef::from(value)).and_then(|s| Timestamp::parse(&s))
736}