1use std::collections::BTreeMap;
2use std::io::Write as _;
3use std::path::PathBuf;
4
5use rusqlite::{Connection, OptionalExtension};
6
7use crate::BlobDecls;
8use crate::PublicationBlob;
9use crate::WriteId;
10use crate::{
11 audience_moves, capture_routing_changes, partition_outbound,
12 validate_scoped_foreign_key_audiences, AudienceMove, AudiencePartition, Gates, RoutingChanges,
13};
14use crate::{capture_changeset, *};
15
16use coven_protocol::blob::Provenance;
17
18use coven_protocol::synced_schema::SyncedTable;
19
20use coven_keys::encryption::EncryptionService;
21use coven_protocol::write::WriteReceipt;
22
23use super::payload_store::PayloadStore;
24use super::verified_store_authority::VerifiedStoreAuthority;
25use super::*;
26
27#[path = "host_write_blob_transaction.rs"]
28mod blob_transaction;
29pub use blob_transaction::HostWriteBlobTransaction;
30
31pub type StagedAudienceBlobRollback = Box<dyn FnOnce(DbError) -> DbError + Send>;
34
35fn rollback_staged_audience_blobs(
36 rollback: Option<StagedAudienceBlobRollback>,
37 error: DbError,
38) -> DbError {
39 match rollback {
40 Some(rollback) => rollback(error),
41 None => error,
42 }
43}
44
45pub trait AudienceBlobMoveStaging: Send + Sync {
49 fn stage_audience_move_blobs_on(
50 &self,
51 transaction: &mut HostWriteBlobTransaction<'_, '_>,
52 facts: &mut StoreWriteBlobFacts,
53 moves: &[AudienceMove],
54 ) -> Result<StagedAudienceBlobRollback, DbError>;
55}
56
57enum AudienceBlobMoveMaterialization<'a> {
58 Host(&'a dyn AudienceBlobMoveStaging),
59 PreparedTransition,
60}
61
62pub(crate) struct CapturedStoreWriteTransaction<'connection, 'operation> {
63 transaction: rusqlite::Transaction<'connection>,
64 store_dir: &'operation coven_foundation::store_dir::StoreDir,
67 synced_tables: &'operation [SyncedTable],
68 gates: &'operation Gates,
69 blob_decls: &'operation BlobDecls,
70 routing: StoreWriteRouting<'operation>,
71 blob_materialization: Option<AudienceBlobMoveMaterialization<'operation>>,
72 verified_authority: &'operation mut VerifiedStoreAuthority,
73 write_id: WriteId,
74}
75
76impl StoreSession<'_> {
77 fn prepare_store_write(&self) -> Result<Option<PreparedStoreWrite>, DbError> {
78 let stored = self
79 .conn
80 .query_row(
81 "SELECT write_id, base, blob_facts FROM store_writes
82 WHERE status = '\"pending\"'
83 AND ordinal = (
84 SELECT MIN(ordinal) FROM store_writes
85 WHERE status != '\"local_only\"'
86 AND json_extract(status, '$.published') IS NULL
87 AND json_extract(status, '$.resolved') IS NULL
88 )
89 AND NOT EXISTS (
90 SELECT 1 FROM store_writes WHERE prepared IS NOT NULL
91 )
92 ORDER BY ordinal LIMIT 1",
93 [],
94 |row| {
95 Ok((
96 row.get::<_, String>(0)?,
97 row.get::<_, Option<String>>(1)?,
98 row.get::<_, Option<String>>(2)?,
99 ))
100 },
101 )
102 .optional()
103 .map_err(DbError::from)?;
104 let Some((write_id, base, blob_facts)) = stored else {
105 return Ok(None);
106 };
107 let (Some(base), Some(blob_facts)) = (base, blob_facts) else {
108 return Err(DbError::Message(format!(
109 "pending write {write_id} carries no commit base or blob facts"
110 )));
111 };
112 let records = crate::store::store_session::StoreRecords::new(self.conn, self.store_dir);
113 let partitions = records.store_write_partitions(&write_id)?;
114 let write_id = WriteId::from_generated(write_id);
115 let effective_base = records.effective_store_write_base(&write_id, &base)?;
116 let effective_blob_facts = match records.rebased_store_write(&write_id)? {
117 Some(rebased) => rebased.blob_facts,
118 None => serde_json::from_str(&blob_facts)
119 .map_err(|error| DbError::context("pending write blob facts", error))?,
120 };
121 Ok(Some(PreparedStoreWrite {
122 write_id,
123 partitions,
124 base: effective_base,
125 blob_facts: effective_blob_facts,
126 }))
127 }
128}
129
130fn deleted_rows(captured: &[u8]) -> Result<std::collections::HashSet<(String, String)>, DbError> {
137 Ok(crate::walk_changeset(captured)
138 .map_err(DbError::Changeset)?
139 .into_iter()
140 .filter(|change| matches!(change.op, coven_foundation::changeset::ChangeOp::Delete))
141 .filter_map(|change| change.pk().map(|id| (change.table.clone(), id.to_string())))
142 .collect())
143}
144
145impl StoreDatabase {
146 fn drain_host_change_journal_on(
147 session: &mut rusqlite::session::Session<'_>,
148 ) -> Result<Vec<u8>, DbError> {
149 capture_changeset(session)
150 }
151
152 fn drain_host_change_journal(
156 session: &mut rusqlite::session::Session<'_>,
157 synced_tables: &[SyncedTable],
158 connection: &Connection,
159 blob_decls: &BlobDecls,
160 ) -> Result<Vec<u8>, DbError> {
161 let captured = Self::drain_host_change_journal_on(session)?;
162 crate::changeset_identity::validate_captured_row_identities(&captured, synced_tables)?;
163 blob_decls.complete_blob_changeset(connection, &captured)
164 }
165
166 pub fn invert_changeset(changeset: &[u8]) -> Result<Vec<u8>, DbError> {
167 if changeset.is_empty() {
168 return Ok(Vec::new());
169 }
170 let mut inverse = Vec::new();
171 rusqlite::session::invert_strm(&mut &changeset[..], &mut inverse).map_err(DbError::from)?;
172 Ok(inverse)
173 }
174
175 fn capture_store_write_blob_facts_on(
176 tx: &rusqlite::Transaction<'_>,
177 changeset: &[u8],
178 blob_decls: &BlobDecls,
179 ) -> Result<StoreWriteBlobFacts, DbError> {
180 let changes = crate::walk_changeset(changeset)
181 .map_err(|error| DbError::context("read Store write blobs", error))?;
182 let mut facts = BTreeMap::new();
183 for change in changes {
184 let Some(publication) = blob_decls
185 .publication_blob_from_change(tx, &change)
186 .map_err(|error| DbError::context("capture Store write blob", error))?
187 else {
188 continue;
189 };
190 let fact = Self::capture_store_write_blob_fact_on(tx, publication)?;
191 let key = fact.identity_key();
192 if let Some(prior) = facts.insert(key.clone(), fact.clone()) {
193 if prior != fact {
194 return Err(DbError::Message(format!(
195 "Store write gives row {}/{}/{} at {} conflicting blob facts",
196 key.0, key.1, key.2, key.3
197 )));
198 }
199 }
200 }
201 Ok(StoreWriteBlobFacts {
202 blobs: facts.into_values().collect(),
203 })
204 }
205
206 pub(super) fn capture_store_write_blob_fact_on(
207 tx: &rusqlite::Transaction<'_>,
208 publication: PublicationBlob,
209 ) -> Result<StoreWriteBlobFact, DbError> {
210 let plaintext_hash = publication.plaintext_hash.parse().map_err(|error| {
211 DbError::context(
212 format!(
213 "capture Store write blob {}/{} plaintext hash",
214 publication.blob.namespace, publication.blob.id
215 ),
216 error,
217 )
218 })?;
219 let external_path = if publication.blob.provenance == Provenance::UserProvided {
220 tx.query_row(
221 "SELECT path FROM local_blob_refs
222 WHERE table_name = ?1 AND row_id = ?2 AND column_name = ?3
223 AND row_stamp = ?4 AND namespace = ?5 AND blob_id = ?6",
224 rusqlite::params![
225 publication.table,
226 publication.row_id,
227 publication.column,
228 publication.row_stamp,
229 publication.blob.namespace,
230 publication.blob.id,
231 ],
232 |row| row.get::<_, String>(0),
233 )
234 .optional()
235 .map_err(DbError::from)?
236 .map(PathBuf::from)
237 } else {
238 None
239 };
240 let previous = previous_row_blob_for_write_on(
241 tx,
242 &publication.table,
243 &publication.row_id,
244 &publication.row_stamp,
245 &publication.column,
246 &publication.blob,
247 publication.plaintext_size,
248 plaintext_hash,
249 )?;
250 Ok(StoreWriteBlobFact {
251 table: publication.table,
252 row_id: publication.row_id,
253 row_stamp: publication.row_stamp,
254 column: publication.column,
255 blob: publication.blob,
256 plaintext_size: publication.plaintext_size,
257 plaintext_hash,
258 external_path,
259 previous,
260 audience_move: None,
261 })
262 }
263
264 pub(super) fn capture_audience_move_blob_facts_on(
265 tx: &rusqlite::Transaction<'_>,
266 moves: &[AudienceMove],
267 blob_decls: &BlobDecls,
268 captured: StoreWriteBlobFacts,
269 ) -> Result<StoreWriteBlobFacts, DbError> {
270 let mut facts = captured
271 .blobs
272 .into_iter()
273 .map(|fact| (fact.identity_key(), fact))
274 .collect::<BTreeMap<_, _>>();
275 for audience_move in moves {
276 for (table, row_id) in &audience_move.rows {
277 let Some(publication) = blob_decls
278 .publication_blob_for_row(tx, table, row_id)
279 .map_err(|error| {
280 DbError::context(
281 format!("capture audience-move blob {table}/{row_id}"),
282 error,
283 )
284 })?
285 else {
286 continue;
287 };
288 let fact = Self::capture_store_write_blob_fact_on(tx, publication)?;
289 let key = fact.identity_key();
290 if let Some(prior) = facts.get(&key) {
291 if prior != &fact {
292 return Err(DbError::Message(format!(
293 "audience move gives row {}/{}/{} at {} conflicting blob facts",
294 key.0, key.1, key.2, key.3
295 )));
296 }
297 } else {
298 facts.insert(key, fact);
299 }
300 }
301 }
302 Ok(StoreWriteBlobFacts {
303 blobs: facts.into_values().collect(),
304 })
305 }
306
307 pub(super) fn advance_moved_blob_row_stamps_on(
319 tx: &rusqlite::Transaction<'_>,
320 moves: &[AudienceMove],
321 blob_decls: &BlobDecls,
322 ) -> Result<bool, DbError> {
323 let mut advanced = false;
324 for audience_move in moves {
325 for (table, row_id) in &audience_move.rows {
326 let carries_blob = blob_decls
327 .publication_blob_for_row(tx, table, row_id)
328 .map_err(|error| {
329 DbError::context(
330 format!("read audience-move blob row {table}/{row_id}"),
331 error,
332 )
333 })?
334 .is_some();
335 if !carries_blob {
336 continue;
337 }
338 let sql = format!(
339 "UPDATE {} SET {} = ?1 WHERE {} = ?2 AND {} < ?1",
340 crate::quote_ident(table),
341 crate::quote_ident("_updated_at"),
342 crate::quote_ident("id"),
343 crate::quote_ident("_updated_at"),
344 );
345 let updated = tx
346 .execute(&sql, rusqlite::params![audience_move.stamp, row_id])
347 .map_err(DbError::from)?;
348 advanced |= updated > 0;
349 }
350 }
351 Ok(advanced)
352 }
353
354 fn store_write_routing<'a>(
355 has_scoped_graph: bool,
356 routing_encryption: Option<&'a EncryptionService>,
357 ) -> Result<StoreWriteRouting<'a>, DbError> {
358 if !has_scoped_graph {
359 return Ok(StoreWriteRouting::Unscoped);
360 }
361 routing_encryption
362 .map(StoreWriteRouting::MergeScoped)
363 .ok_or_else(|| {
364 DbError::Message(
365 "scoped write requires the Store generation-1 routing key".to_string(),
366 )
367 })
368 }
369
370 pub fn validate_store_write_routing(
371 &self,
372 routing_encryption: Option<&EncryptionService>,
373 ) -> Result<(), DbError> {
374 Self::store_write_routing(self.has_scoped_graph(), routing_encryption).map(drop)
375 }
376
377 pub async fn prepare_store_write(&self) -> Result<Option<PreparedStoreWrite>, DbError> {
378 self.call_store(|session| session.prepare_store_write())
379 .await
380 }
381}
382
383pub(crate) fn capture_partition_blob_facts_on(
384 tx: &rusqlite::Transaction<'_>,
385 partitions: &[AudiencePartition],
386 blob_decls: &BlobDecls,
387) -> Result<StoreWriteBlobFacts, DbError> {
388 let mut facts = BTreeMap::new();
389 for partition in partitions {
390 for fact in
391 StoreDatabase::capture_store_write_blob_facts_on(tx, &partition.changeset, blob_decls)?
392 .blobs
393 {
394 let key = fact.identity_key();
395 if let Some(prior) = facts.insert(key.clone(), fact.clone()) {
396 if prior != fact {
397 return Err(DbError::Message(format!(
398 "audience partitions give row {}/{}/{} at {} conflicting blob facts",
399 key.0, key.1, key.2, key.3
400 )));
401 }
402 }
403 }
404 }
405 Ok(StoreWriteBlobFacts {
406 blobs: facts.into_values().collect(),
407 })
408}
409
410impl<'connection, 'operation> CapturedStoreWriteTransaction<'connection, 'operation> {
411 #[allow(clippy::too_many_arguments)]
412 pub(crate) fn begin_host(
413 connection: &'connection Connection,
414 store_dir: &'operation coven_foundation::store_dir::StoreDir,
415 synced_tables: &'operation [SyncedTable],
416 gates: &'operation Gates,
417 blob_decls: &'operation BlobDecls,
418 routing_encryption: Option<&'operation EncryptionService>,
419 blob_staging: Option<&'operation dyn AudienceBlobMoveStaging>,
420 verified_authority: &'operation mut VerifiedStoreAuthority,
421 write_id: WriteId,
422 ) -> Result<Self, DbError> {
423 Self::begin(
424 connection,
425 store_dir,
426 synced_tables,
427 gates,
428 blob_decls,
429 routing_encryption,
430 blob_staging.map(AudienceBlobMoveMaterialization::Host),
431 verified_authority,
432 write_id,
433 )
434 }
435
436 pub(crate) fn begin_prepared_blob_transition(
437 connection: &'connection Connection,
438 store_dir: &'operation coven_foundation::store_dir::StoreDir,
439 synced_tables: &'operation [SyncedTable],
440 gates: &'operation Gates,
441 blob_decls: &'operation BlobDecls,
442 routing_encryption: Option<&'operation EncryptionService>,
443 verified_authority: &'operation mut VerifiedStoreAuthority,
444 write_id: WriteId,
445 ) -> Result<Self, DbError> {
446 Self::begin(
447 connection,
448 store_dir,
449 synced_tables,
450 gates,
451 blob_decls,
452 routing_encryption,
453 Some(AudienceBlobMoveMaterialization::PreparedTransition),
454 verified_authority,
455 write_id,
456 )
457 }
458
459 #[allow(clippy::too_many_arguments)]
460 fn begin(
461 connection: &'connection Connection,
462 store_dir: &'operation coven_foundation::store_dir::StoreDir,
463 synced_tables: &'operation [SyncedTable],
464 gates: &'operation Gates,
465 blob_decls: &'operation BlobDecls,
466 routing_encryption: Option<&'operation EncryptionService>,
467 blob_materialization: Option<AudienceBlobMoveMaterialization<'operation>>,
468 verified_authority: &'operation mut VerifiedStoreAuthority,
469 write_id: WriteId,
470 ) -> Result<Self, DbError> {
471 let routing =
472 StoreDatabase::store_write_routing(gates.has_scoped_graph(), routing_encryption)?;
473 let transaction = connection.unchecked_transaction().map_err(DbError::from)?;
474 Ok(Self {
475 transaction,
476 store_dir,
477 synced_tables,
478 gates,
479 blob_decls,
480 routing,
481 blob_materialization,
482 verified_authority,
483 write_id,
484 })
485 }
486
487 pub(crate) fn execute_host<R, E>(
488 self,
489 mut staged: super::host_write_operation::StagedBlobBatch,
490 deleted: Vec<coven_protocol::blob::BlobRef>,
491 sql: super::host_write_operation::HostSql<R, E>,
492 stamper: coven_protocol::hlc::UpdatedAtStamper,
493 ) -> Result<WriteReceipt<R>, super::host_write_operation::HostWriteError<E>> {
494 use super::host_sql_transaction::HostSqlAuthorization;
495 use super::host_write_operation::HostWriteError;
496
497 let blob_decls = self.blob_decls;
498 let store_dir = self.store_dir;
499 let synced_tables = self.synced_tables;
500 let gates = self.gates;
501 let result = self.execute(|transaction| -> Result<R, HostWriteError<E>> {
502 let cleanup_intents = deleted
503 .iter()
504 .map(|blob| {
505 blob_decls
506 .row_for_blob_in_namespace(transaction, &blob.namespace, &blob.id)
507 .map_err(HostWriteError::BlobDeclaration)
508 .map(|row| match row {
509 Some((table, row_id)) => {
510 crate::local_blob_cleanup_intents::LocalBlobCleanupIntent::for_row(
511 &blob.namespace,
512 &blob.id,
513 table,
514 row_id,
515 )
516 }
517 None => {
518 crate::local_blob_cleanup_intents::LocalBlobCleanupIntent::local(
519 &blob.namespace,
520 &blob.id,
521 )
522 }
523 })
524 })
525 .collect::<Result<Vec<_>, _>>()?;
526
527 staged.publish(|namespace, id| {
528 match blob_decls.row_for_blob_in_namespace(transaction, namespace, id) {
529 Ok(Some(_)) => {
530 return Err(HostWriteError::BlobAlreadyReferenced {
531 namespace: namespace.to_string(),
532 id: id.to_string(),
533 });
534 }
535 Ok(None) => {}
536 Err(error) => return Err(HostWriteError::BlobDeclaration(error)),
537 }
538 let leased = transaction
539 .query_row(
540 "SELECT EXISTS(\
541 SELECT 1 FROM store_write_blob_leases \
542 WHERE namespace = ?1 AND blob_id = ?2\
543 ) OR EXISTS(\
544 SELECT 1 FROM retained_replay_blob_leases \
545 WHERE namespace = ?1 AND blob_id = ?2\
546 )",
547 (namespace, id),
548 |row| row.get::<_, bool>(0),
549 )
550 .map_err(DbError::from)?;
551 if leased {
552 return Err(HostWriteError::BlobOwnedByPendingWrite {
553 namespace: namespace.to_string(),
554 id: id.to_string(),
555 });
556 }
557 Ok(())
558 })?;
559
560 let host_sql = HostSqlAuthorization::begin(transaction)?;
561 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
562 host_sql.run_observing_write(|| {
563 sql(super::SqlContext::new(
564 transaction,
565 stamper,
566 synced_tables,
567 gates,
568 ))
569 })
570 })) {
571 Ok((Ok(value), true)) => {
572 for (blob, intent) in deleted.iter().zip(&cleanup_intents) {
573 let _ = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
574 if blob_decls
575 .blob_id_is_referenced(transaction, &blob.namespace, &blob.id)
576 .map_err(DbError::from)?
577 {
578 return Err(HostWriteError::BlobStillReferenced {
579 namespace: blob.namespace.clone(),
580 id: blob.id.clone(),
581 });
582 }
583 super::local_blob_cleanup::record_obsolete_copy_intents_on(
584 transaction,
585 blob_decls,
586 intent,
587 )?;
588 }
589 Ok(value)
590 }
591 Ok((Ok(_), false)) => Err(HostWriteError::from(DbError::ReadOnlyWriteTransaction)),
592 Ok((Err(error), _)) => Err(HostWriteError::Host(error)),
593 Err(_) => Err(HostWriteError::WriteClosurePanicked),
594 }
595 });
596
597 match result {
598 Ok(receipt) => {
599 staged.commit();
600 Ok(receipt)
601 }
602 Err(error) => Err(staged.rollback(error)),
603 }
604 }
605
606 #[allow(clippy::too_many_arguments)]
607 pub(crate) fn execute_make_remote(
608 self,
609 root_table: String,
610 root_id: String,
611 gate_column: String,
612 stamp: String,
613 rows: Vec<coven_protocol::blob::RowBlobRef>,
614 publication_write_id: WriteId,
615 ) -> Result<WriteReceipt<()>, DbError> {
616 self.execute(|transaction| {
617 super::blob_transitions::write_gate(
618 transaction,
619 &root_table,
620 &gate_column,
621 true,
622 &stamp,
623 &root_id,
624 )
625 .map_err(DbError::from)?;
626 let external_blobs = ExternalBlobRecords::new(transaction);
627 for reference in &rows {
628 if reference.blob().provenance == Provenance::UserProvided {
629 external_blobs.clear(reference)?;
630 }
631 }
632 Database::mark_make_remote_publishing_on(
633 transaction,
634 &root_table,
635 &root_id,
636 &publication_write_id,
637 )
638 })
639 }
640
641 #[allow(clippy::too_many_arguments)]
642 pub(crate) fn execute_make_local(
643 self,
644 root_table: String,
645 root_id: String,
646 gate_column: String,
647 stamp: String,
648 materialized: Vec<super::blob_transitions::MaterializedLocalBlob>,
649 ) -> Result<WriteReceipt<()>, DbError> {
650 let gates = self.gates;
651 let synced_tables = self.synced_tables;
652 self.execute(|transaction| {
653 let remote = Database::row_blob_refs_for_root_on(
654 transaction,
655 gates,
656 synced_tables,
657 &root_table,
658 &root_id,
659 )?;
660 if remote.len() != materialized.len()
661 || remote.iter().zip(&materialized).any(|(current, local)| {
662 !super::blob_transitions::same_row_blob_version(current, &local.remote)
663 || current.authority() != local.remote.authority()
664 || current.stored() != Some(&local.stored)
665 })
666 {
667 return Err(DbError::Message(format!(
668 "make_local root {root_table:?}/{root_id:?} changed while its blobs were materialized"
669 )));
670 }
671 super::blob_transitions::write_gate(
672 transaction,
673 &root_table,
674 &gate_column,
675 false,
676 &stamp,
677 &root_id,
678 )
679 .map_err(DbError::from)?;
680
681 for local in &materialized {
682 let reference = &local.remote;
683 if reference.table() == root_table && reference.row_id() == root_id {
684 continue;
685 }
686 let sql = format!(
687 "UPDATE {} SET _updated_at = ?1 WHERE id = ?2 AND _updated_at = ?3",
688 crate::quote_ident(reference.table())
689 );
690 let updated = transaction
691 .execute(
692 &sql,
693 rusqlite::params![stamp, reference.row_id(), reference.row_stamp()],
694 )
695 .map_err(DbError::from)?;
696 if updated != 1 {
697 return Err(DbError::Message(format!(
698 "make_local row {:?}/{:?} changed before restamping",
699 reference.table(),
700 reference.row_id()
701 )));
702 }
703 }
704 let local_rows = Database::row_blob_refs_for_root_on(
705 transaction,
706 gates,
707 synced_tables,
708 &root_table,
709 &root_id,
710 )?;
711 if local_rows.len() != materialized.len() {
712 return Err(DbError::Message(format!(
713 "make_local root {root_table:?}/{root_id:?} changed while its blobs were materialized"
714 )));
715 }
716 let cloud_outbox = CloudOutboxRecords::new(transaction);
717 let external_blobs = ExternalBlobRecords::new(transaction);
718 for (local, materialized) in local_rows.iter().zip(&materialized) {
719 if local.table() != materialized.remote.table()
720 || local.row_id() != materialized.remote.row_id()
721 || local.column() != materialized.remote.column()
722 || local.row_stamp() != stamp
723 || local.blob() != materialized.remote.blob()
724 || local.plaintext_size() != materialized.remote.plaintext_size()
725 || local.plaintext_hash() != materialized.remote.plaintext_hash()
726 || local.authority() != &coven_protocol::blob::RowBlobAuthority::Local
727 || local.stored().is_some()
728 {
729 return Err(DbError::Message(format!(
730 "make_local row {:?}/{:?}/{:?} changed while its blob was materialized",
731 materialized.remote.table(),
732 materialized.remote.row_id(),
733 materialized.remote.column()
734 )));
735 }
736 if let Some(path) = &materialized.destination {
737 external_blobs.register(local, path)?;
738 }
739 cloud_outbox.enqueue_delete(&materialized.stored, &stamp)?;
740 }
741 Ok(())
742 })
743 }
744
745 fn execute<R, E>(
746 self,
747 f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<R, E>,
748 ) -> Result<WriteReceipt<R>, E>
749 where
750 E: From<DbError>,
751 {
752 let Self {
753 transaction: tx,
754 store_dir,
755 synced_tables,
756 gates,
757 blob_decls,
758 routing,
759 blob_materialization,
760 verified_authority,
761 write_id,
762 } = self;
763 (|| {
764 let mut journal = rusqlite::session::Session::new(&tx)
765 .map_err(|error| DbError::context("failed to create capture session", error))
766 .map_err(E::from)?;
767 for table in synced_tables {
768 journal
769 .attach(Some(table.name()))
770 .map_err(|error| {
771 DbError::context(
772 format!("failed to attach synced table {} to session", table.name()),
773 error,
774 )
775 })
776 .map_err(E::from)?;
777 }
778 if gates.has_scoped_graph() {
779 for table in ["_coven_audience", "_coven_row_routes"] {
780 journal
781 .attach(Some(table))
782 .map_err(DbError::from)
783 .map_err(E::from)?;
784 }
785 }
786 let value = f(&tx)?;
787 let mut captured = StoreDatabase::drain_host_change_journal(
788 &mut journal,
789 synced_tables,
790 &tx,
791 blob_decls,
792 )
793 .map_err(E::from)?;
794 crate::Database::cancel_transitions_for_deleted_roots_on(
799 &tx,
800 &deleted_rows(&captured).map_err(E::from)?,
801 )
802 .map_err(E::from)?;
803 validate_scoped_foreign_key_audiences(&tx, gates)
804 .map_err(DbError::from)
805 .map_err(E::from)?;
806 if matches!(
815 blob_materialization,
816 Some(AudienceBlobMoveMaterialization::Host(_))
817 ) {
818 let moves = audience_moves(&tx, &captured, gates)
819 .map_err(DbError::from)
820 .map_err(E::from)?;
821 if StoreDatabase::advance_moved_blob_row_stamps_on(&tx, &moves, blob_decls)
822 .map_err(E::from)?
823 {
824 captured = StoreDatabase::drain_host_change_journal(
825 &mut journal,
826 synced_tables,
827 &tx,
828 blob_decls,
829 )
830 .map_err(E::from)?;
831 }
832 }
833 blob_decls
834 .validate_changed_rows(&tx, &captured)
835 .map_err(DbError::from)
836 .map_err(E::from)?;
837 let routing_key = match routing {
838 StoreWriteRouting::MergeScoped(encryption) => {
839 let store_root_hash = StoreTransaction::new(&tx, store_dir)
840 .required_root_authority(verified_authority)
841 .map_err(E::from)?
842 .store_root_hash;
843 Some(
844 coven_protocol::circle::derive_row_routing_key(encryption, store_root_hash)
845 .map_err(|error| {
846 E::from(DbError::context("derive row routing key", error))
847 })?,
848 )
849 }
850 StoreWriteRouting::Unscoped => None,
851 };
852 let partitioned =
853 partition_captured_write_on(&tx, &captured, gates, routing_key.as_ref())
854 .map_err(E::from)?;
855 if partitioned.partitions.is_empty() && partitioned.moves.is_empty() {
867 drop(journal);
868 tx.commit().map_err(DbError::from).map_err(E::from)?;
869 return Ok(WriteReceipt {
870 value,
871 write_id,
872 status: coven_protocol::write::WriteStatus::LocalOnly,
873 });
874 }
875 let mut blob_facts =
876 capture_partition_blob_facts_on(&tx, &partitioned.partitions, blob_decls)
877 .map_err(E::from)?;
878 blob_facts = StoreDatabase::capture_audience_move_blob_facts_on(
879 &tx,
880 &partitioned.moves,
881 blob_decls,
882 blob_facts,
883 )
884 .map_err(E::from)?;
885 let moved_blob_exists = blob_facts.blobs.iter().any(|fact| {
886 partitioned.moves.iter().any(|audience_move| {
887 audience_move
888 .rows
889 .contains(&(fact.table.clone(), fact.row_id.clone()))
890 })
891 });
892 let staged_files = match (moved_blob_exists, &blob_materialization) {
893 (false, _) => None,
894 (true, Some(AudienceBlobMoveMaterialization::Host(staging))) => {
895 let mut created_payload_files = Vec::new();
896 let mut blob_transaction = HostWriteBlobTransaction::new(
897 crate::store::store_session::StoreTransaction::new(&tx, store_dir),
898 verified_authority,
899 &mut created_payload_files,
900 );
901 let staged = staging.stage_audience_move_blobs_on(
902 &mut blob_transaction,
903 &mut blob_facts,
904 &partitioned.moves,
905 );
906 match staged {
907 Ok(rollback) => {
908 let directory = store_dir.clone();
909 Some(Box::new(move |error| {
910 blob_transaction::rollback_captured_payload_files(
911 &directory,
912 created_payload_files,
913 rollback(error),
914 )
915 }) as StagedAudienceBlobRollback)
916 }
917 Err(error) => {
918 return Err(E::from(
919 blob_transaction::rollback_captured_payload_files(
920 store_dir,
921 created_payload_files,
922 error,
923 ),
924 ));
925 }
926 }
927 }
928 (true, Some(AudienceBlobMoveMaterialization::PreparedTransition)) => {
929 record_prepared_transition_local_blob_moves(
930 &mut blob_facts,
931 &partitioned.moves,
932 )
933 .map_err(E::from)?;
934 None
935 }
936 (true, None) => {
937 return Err(E::from(DbError::Message(
938 "BlobMoveRequiresMaterialization: audience move staging is unavailable"
939 .to_string(),
940 )));
941 }
942 };
943 let changeset_hash = match (|| -> Result<ObjectHash, DbError> {
944 let captured = StoreDatabase::drain_host_change_journal(
945 &mut journal,
946 synced_tables,
947 &tx,
948 blob_decls,
949 )?;
950 let mut changeset_writer =
951 crate::store::store_session::StoreTransaction::new(&tx, store_dir)
952 .payload_writer();
953 changeset_writer
954 .write_all(&captured)
955 .map_err(|error| DbError::context("write captured changeset", error))?;
956 Ok(changeset_writer.commit()?.0)
957 })() {
958 Ok(hash) => hash,
959 Err(error) => {
960 return Err(E::from(rollback_staged_audience_blobs(staged_files, error)));
961 }
962 };
963 drop(journal);
964 let committed = (|| {
965 let base = StoreWriteBase {
966 dependencies:
967 crate::store::materialized_commit_index::materialized_frontier_on(
968 &tx, None,
969 )?,
970 };
971 let status = crate::store::store_session::StoreTransaction::new(&tx, store_dir)
972 .insert_store_write(
973 &write_id,
974 &partitioned.partitions,
975 changeset_hash,
976 &base,
977 &blob_facts,
978 )?;
979 tx.commit().map_err(DbError::from)?;
980 Ok::<_, DbError>(status)
981 })();
982 let status = match committed {
983 Ok(status) => status,
984 Err(error) => {
985 return Err(E::from(rollback_staged_audience_blobs(staged_files, error)));
986 }
987 };
988 Ok(WriteReceipt {
989 value,
990 write_id,
991 status,
992 })
993 })()
994 }
995}
996
997pub(crate) fn record_prepared_transition_local_blob_moves(
998 facts: &mut StoreWriteBlobFacts,
999 moves: &[AudienceMove],
1000) -> Result<(), DbError> {
1001 let moved_rows = audience_moves_by_row(moves)?;
1002 for fact in &mut facts.blobs {
1003 let Some(audience_move) = moved_rows.get(&(fact.table.clone(), fact.row_id.clone())) else {
1004 continue;
1005 };
1006 if audience_move.destination == coven_protocol::circle::Audience::Local {
1007 fact.audience_move = Some(StoreWriteBlobMoveMaterialization::Local);
1008 }
1009 }
1010 Ok(())
1011}
1012
1013pub fn audience_moves_by_row(
1014 moves: &[AudienceMove],
1015) -> Result<BTreeMap<(String, String), &AudienceMove>, DbError> {
1016 let mut moved_rows = BTreeMap::new();
1017 for audience_move in moves {
1018 for row in &audience_move.rows {
1019 if let Some(prior) = moved_rows.insert(row.clone(), audience_move) {
1020 if prior.source != audience_move.source
1021 || prior.destination != audience_move.destination
1022 {
1023 return Err(DbError::Message(format!(
1024 "row {}/{} belongs to conflicting audience moves",
1025 row.0, row.1
1026 )));
1027 }
1028 }
1029 }
1030 }
1031 Ok(moved_rows)
1032}
1033
1034fn partition_captured_write_on(
1037 transaction: &rusqlite::Transaction<'_>,
1038 captured: &[u8],
1039 gates: &Gates,
1040 routing_key: Option<&coven_protocol::circle::RowRoutingKey>,
1041) -> Result<crate::gate::PartitionedAudienceWrite, DbError> {
1042 let routing = if gates.has_scoped_graph() {
1043 let key = routing_key.ok_or_else(|| {
1044 DbError::Message("scoped capture requires the Store routing key".to_string())
1045 })?;
1046 capture_routing_changes(transaction, captured, gates, key)
1047 .map_err(|error| DbError::context("capture scoped routing changes", error))?
1048 } else {
1049 RoutingChanges::empty()
1050 };
1051 partition_outbound(transaction, captured, &routing, gates)
1052 .map_err(|error| DbError::context("partition captured write", error))
1053}