Don't duplicate events

This commit is contained in:
Andras Schmelczer 2026-09-19 22:10:38 +01:00
commit 45a6b1c115
7 changed files with 75 additions and 100 deletions

View file

@ -1,6 +1,6 @@
use super::{ use super::{
Database, Transaction, Database, Transaction,
models::{EventRecord, FileManifest, StoredDocumentVersion, VaultUpdateId}, models::{FileManifest, StoredDocumentVersion, VaultUpdateId},
}; };
use anyhow::Result; use anyhow::Result;
@ -59,13 +59,4 @@ impl Database {
Ok(()) Ok(())
} }
pub async fn write_event(tx: &mut Transaction<'_>, event: &EventRecord) -> Result<()> {
sqlx::query("UPDATE events SET event_json = ? WHERE event_id = ?")
.bind(serde_json::to_string(event)?)
.bind(event.event_id)
.execute(&mut **tx)
.await?;
Ok(())
}
} }

View file

@ -1,23 +1,28 @@
use super::{ use super::{
Database, Transaction, Database, Transaction,
models::{ models::{
DocumentId, DocumentVersionWithoutContent, EventBatch, FileManifest, StoredDocumentVersion, DocumentId, DocumentVersionWithoutContent, EventBatch, EventRecord, FileManifest,
VaultId, VaultSnapshot, VaultUpdateId, StoredDocumentVersion, VaultEvent, VaultId, VaultSnapshot, VaultUpdateId,
}, },
}; };
use anyhow::{Result, ensure}; use anyhow::{Context as _, Result, ensure};
impl Database { impl Database {
pub async fn get_request_event( pub async fn get_request_event(
tx: &mut Transaction<'_>, tx: &mut Transaction<'_>,
request_id: uuid::Uuid, request_id: uuid::Uuid,
) -> Result<Option<(Vec<u8>, String)>> { ) -> Result<Option<(Vec<u8>, VaultEvent)>> {
Ok(sqlx::query_as( let row: Option<(Vec<u8>, VaultUpdateId)> =
"SELECT request_fingerprint, event_json FROM events WHERE request_id = ?", sqlx::query_as("SELECT request_fingerprint, event_id FROM events WHERE request_id = ?")
) .bind(request_id.to_string())
.bind(request_id.to_string()) .fetch_optional(&mut **tx)
.fetch_optional(&mut **tx) .await?;
.await?) match row {
Some((fingerprint, event_id)) => {
Ok(Some((fingerprint, Self::event_by_id(tx, event_id).await?)))
}
None => Ok(None),
}
} }
pub async fn get_latest_document_version( pub async fn get_latest_document_version(
@ -70,6 +75,24 @@ impl Database {
return Ok(FileManifest::default()); return Ok(FileManifest::default());
}; };
Self::file_manifest_by_id(tx, file_manifest_id)
.await?
.context("Latest file manifest is missing")
}
async fn file_manifest_by_id(
tx: &mut Transaction<'_>,
file_manifest_id: VaultUpdateId,
) -> Result<Option<FileManifest>> {
let exists = sqlx::query_scalar::<_, VaultUpdateId>(
"SELECT file_manifest_id FROM file_manifests WHERE file_manifest_id = ?",
)
.bind(file_manifest_id)
.fetch_optional(&mut **tx)
.await?;
if exists.is_none() {
return Ok(None);
}
let entries = sqlx::query_as::<_, (uuid::fmt::Hyphenated, String)>( let entries = sqlx::query_as::<_, (uuid::fmt::Hyphenated, String)>(
"SELECT document_id, path FROM file_manifest_entries WHERE file_manifest_id = ?", "SELECT document_id, path FROM file_manifest_entries WHERE file_manifest_id = ?",
) )
@ -80,10 +103,28 @@ impl Database {
.map(|(document_id, path)| (document_id.into_uuid(), path)) .map(|(document_id, path)| (document_id.into_uuid(), path))
.collect(); .collect();
Ok(FileManifest { Ok(Some(FileManifest {
file_manifest_id, file_manifest_id,
entries, entries,
}) }))
}
async fn event_by_id(tx: &mut Transaction<'_>, event_id: VaultUpdateId) -> Result<VaultEvent> {
let document = sqlx::query_as::<_, DocumentVersionWithoutContent>(
"SELECT vault_update_id, document_id, updated_date, user_id, device_id,
length(content) AS content_size
FROM documents WHERE vault_update_id = ?",
)
.bind(event_id)
.fetch_optional(&mut **tx)
.await?;
if let Some(document) = document {
return Ok(VaultEvent::Content { document });
}
let file_manifest = Self::file_manifest_by_id(tx, event_id)
.await?
.context("Event has no normalized content or file manifest")?;
Ok(VaultEvent::FileManifest { file_manifest })
} }
pub async fn latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> { pub async fn latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> {
@ -126,17 +167,21 @@ impl Database {
"Event cursor is outside this vault's history" "Event cursor is outside this vault's history"
); );
let rows: Vec<String> = sqlx::query_scalar( let rows: Vec<(VaultUpdateId, String)> = sqlx::query_as(
"SELECT event_json FROM events WHERE event_id > ? ORDER BY event_id", "SELECT event_id, request_id FROM events WHERE event_id > ? ORDER BY event_id",
) )
.bind(after) .bind(after)
.fetch_all(&mut *tx) .fetch_all(&mut *tx)
.await?; .await?;
let events = rows let mut events = Vec::with_capacity(rows.len());
.iter() for (event_id, request_id) in rows {
.map(|json| serde_json::from_str(json)) events.push(EventRecord {
.collect::<Result<_, _>>()?; event_id,
request_id: request_id.parse()?,
event: Self::event_by_id(&mut tx, event_id).await?,
});
}
Ok(EventBatch { Ok(EventBatch {
head_event_id, head_event_id,

View file

@ -1,5 +1,5 @@
use super::*; use super::*;
use models::{DocumentId, EventRecord, FileManifest, StoredDocumentVersion, VaultEvent}; use models::{DocumentId, FileManifest, StoredDocumentVersion};
fn config(directory: &tempfile::TempDir) -> DatabaseConfig { fn config(directory: &tempfile::TempDir) -> DatabaseConfig {
DatabaseConfig { DatabaseConfig {
@ -32,18 +32,6 @@ async fn insert_content(
Database::insert_document_version(&mut tx, &version) Database::insert_document_version(&mut tx, &version)
.await .await
.unwrap(); .unwrap();
Database::write_event(
&mut tx,
&EventRecord {
event_id: version.vault_update_id,
request_id,
event: VaultEvent::Content {
document: (&version).into(),
},
},
)
.await
.unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();
version version
} }
@ -133,18 +121,6 @@ async fn snapshot_returns_latest_referenced_metadata_and_byte_lengths() {
Database::insert_file_manifest(&mut tx, &manifest) Database::insert_file_manifest(&mut tx, &manifest)
.await .await
.unwrap(); .unwrap();
Database::write_event(
&mut tx,
&EventRecord {
event_id: manifest.file_manifest_id,
request_id,
event: VaultEvent::FileManifest {
file_manifest: manifest.clone(),
},
},
)
.await
.unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();
let snapshot = database.vault_snapshot(&vault).await.unwrap(); let snapshot = database.vault_snapshot(&vault).await.unwrap();

View file

@ -4,7 +4,7 @@ use crate::{
AppState, AppState,
database::{ database::{
Database, Database,
models::{DocumentId, EventRecord, StoredDocumentVersion, VaultEvent}, models::{DocumentId, StoredDocumentVersion, VaultEvent},
}, },
}, },
config::user_config::User, config::user_config::User,
@ -123,18 +123,6 @@ pub async fn put_file_content(
let response = DocumentUpdateResponse::Accepted((&version).into()); let response = DocumentUpdateResponse::Accepted((&version).into());
let event = EventRecord {
event_id: version.vault_update_id,
request_id: push.request_id,
event: VaultEvent::Content {
document: version.into(),
},
};
Database::write_event(&mut tx, &event)
.await
.map_err(server_error)?;
tx.commit() tx.commit()
.await .await
.map_err(|error| server_error(error.into()))?; .map_err(|error| server_error(error.into()))?;
@ -494,7 +482,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn failed_event_commit_rolls_back_content_and_allows_retrying_the_request() { async fn failed_normalized_event_write_rolls_back_and_allows_retrying_the_request() {
let vault = TestVault::new().await; let vault = TestVault::new().await;
let mut tx = vault let mut tx = vault
.state .state
@ -502,7 +490,7 @@ mod tests {
.create_write_transaction(&vault.vault) .create_write_transaction(&vault.vault)
.await .await
.unwrap(); .unwrap();
sqlx::query("CREATE TRIGGER reject_event BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'simulated event write failure'); END") sqlx::query("CREATE TRIGGER reject_event BEFORE INSERT ON documents BEGIN SELECT RAISE(ABORT, 'simulated event write failure'); END")
.execute(&mut *tx).await.unwrap(); .execute(&mut *tx).await.unwrap();
tx.commit().await.unwrap(); tx.commit().await.unwrap();

View file

@ -12,7 +12,7 @@ use crate::{
AppState, AppState,
database::{ database::{
Database, Database,
models::{EventRecord, FileManifest, VaultEvent}, models::{FileManifest, VaultEvent},
}, },
}, },
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, client_error, server_error},
@ -91,16 +91,6 @@ pub async fn put_file_manifest(
file_manifest_id: file_manifest.file_manifest_id, file_manifest_id: file_manifest.file_manifest_id,
}; };
let event = EventRecord {
event_id: file_manifest.file_manifest_id,
request_id: push.request_id,
event: VaultEvent::FileManifest { file_manifest },
};
Database::write_event(&mut tx, &event)
.await
.map_err(server_error)?;
tx.commit() tx.commit()
.await .await
.map_err(|error| server_error(error.into()))?; .map_err(|error| server_error(error.into()))?;

View file

@ -1,10 +1,7 @@
use anyhow::anyhow; use anyhow::anyhow;
use crate::{ use crate::{
app_state::database::{ app_state::database::{Database, Transaction, models::VaultEvent},
Database, Transaction,
models::{EventRecord, VaultEvent},
},
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, client_error, server_error},
}; };
@ -13,7 +10,7 @@ pub(super) async fn find_already_processed_event(
request_id: uuid::Uuid, request_id: uuid::Uuid,
fingerprint: &[u8], fingerprint: &[u8],
) -> Result<Option<VaultEvent>, SyncServerError> { ) -> Result<Option<VaultEvent>, SyncServerError> {
let Some((stored_fingerprint, event_json)) = Database::get_request_event(tx, request_id) let Some((stored_fingerprint, event)) = Database::get_request_event(tx, request_id)
.await .await
.map_err(server_error)? .map_err(server_error)?
else { else {
@ -26,7 +23,5 @@ pub(super) async fn find_already_processed_event(
))); )));
} }
serde_json::from_str::<EventRecord>(&event_json) Ok(Some(event))
.map(|record| Some(record.event))
.map_err(|error| server_error(error.into()))
} }

View file

@ -19,10 +19,7 @@ pub fn validate_file_manifest(entries: &FileManifestEntries) -> Result<()> {
let mut nodes: BTreeMap<String, (String, bool)> = BTreeMap::new(); let mut nodes: BTreeMap<String, (String, bool)> = BTreeMap::new();
for path in entries.values() { for path in entries.values() {
ensure!( ensure!(!path.is_empty(), "Path must not be empty");
!path.is_empty() && path.len() <= 240,
"Path must contain 1–240 UTF-8 bytes"
);
ensure!( ensure!(
path.nfc().collect::<String>() == *path, path.nfc().collect::<String>() == *path,
@ -136,16 +133,9 @@ mod tests {
} }
#[test] #[test]
fn enforces_the_path_length_limit_in_utf8_bytes() { fn does_not_impose_a_cross_platform_total_path_limit() {
for path in ["a".repeat(240), "é".repeat(120)] { for path in ["a".repeat(300), "é".repeat(200)] {
validate_file_manifest(&manifest(&[&path])).unwrap(); validate_file_manifest(&manifest(&[&path])).unwrap();
let too_long = format!("{path}a");
assert!(
validate_file_manifest(&manifest(&[&too_long])).is_err(),
"accepted a path over 240 bytes: {too_long:?}"
);
} }
} }