feat(core): add atomic receive history cleanup

This commit is contained in:
2026-07-12 04:15:11 +02:00
parent 5580ae7fe9
commit 757966379b
5 changed files with 231 additions and 0 deletions

View File

@@ -27,6 +27,8 @@ pub(crate) struct Repository {
pool: SqlitePool, pool: SqlitePool,
#[cfg(test)] #[cfg(test)]
fail_next_write: Arc<AtomicBool>, fail_next_write: Arc<AtomicBool>,
#[cfg(test)]
fail_receive_history_after_dependants: Arc<AtomicBool>,
} }
pub(crate) struct TransferUpsert<'a> { pub(crate) struct TransferUpsert<'a> {
@@ -80,6 +82,8 @@ impl Repository {
pool, pool,
#[cfg(test)] #[cfg(test)]
fail_next_write: Arc::new(AtomicBool::new(false)), fail_next_write: Arc::new(AtomicBool::new(false)),
#[cfg(test)]
fail_receive_history_after_dependants: Arc::new(AtomicBool::new(false)),
}; };
repository.ensure_schema().await?; repository.ensure_schema().await?;
Ok(repository) Ok(repository)
@@ -482,6 +486,12 @@ impl Repository {
self.fail_next_write.store(true, Ordering::SeqCst); self.fail_next_write.store(true, Ordering::SeqCst);
} }
#[cfg(test)]
pub(crate) fn fail_receive_history_after_dependants(&self) {
self.fail_receive_history_after_dependants
.store(true, Ordering::SeqCst);
}
#[cfg(test)] #[cfg(test)]
fn maybe_fail_write(&self) -> Result<()> { fn maybe_fail_write(&self) -> Result<()> {
if self.fail_next_write.swap(false, Ordering::SeqCst) { if self.fail_next_write.swap(false, Ordering::SeqCst) {
@@ -759,6 +769,60 @@ impl Repository {
Ok(()) Ok(())
} }
pub(crate) async fn delete_receive_history(&self) -> Result<u64> {
self.maybe_fail_write()?;
let mut transaction = self.pool.begin().await?;
// Delete dependants before their transfer rows. Keep the terminal-state
// predicate on every statement so receive work that is still active and
// every send record remain outside this transaction's scope.
sqlx::query(
r#"
DELETE FROM receiver_requests
WHERE transfer_id IN (
SELECT transfer_id
FROM transfers
WHERE direction = 'receive'
AND status IN ('done', 'failed', 'cancelled')
)
"#,
)
.execute(&mut *transaction)
.await?;
sqlx::query(
r#"
DELETE FROM transfer_events
WHERE transfer_id IN (
SELECT transfer_id
FROM transfers
WHERE direction = 'receive'
AND status IN ('done', 'failed', 'cancelled')
)
"#,
)
.execute(&mut *transaction)
.await?;
#[cfg(test)]
if self
.fail_receive_history_after_dependants
.swap(false, Ordering::SeqCst)
{
anyhow::bail!("injected receive history failure after dependant deletion");
}
let deleted = sqlx::query(
r#"
DELETE FROM transfers
WHERE direction = 'receive'
AND status IN ('done', 'failed', 'cancelled')
"#,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(deleted.rows_affected())
}
pub(crate) async fn list_events( pub(crate) async fn list_events(
&self, &self,
transfer_id: Option<u64>, transfer_id: Option<u64>,

View File

@@ -250,6 +250,12 @@ impl VnidropCore {
.map_err(VnidropError::transfer) .map_err(VnidropError::transfer)
} }
pub fn delete_receive_history(&self) -> Result<u64, VnidropError> {
self.runtime
.block_on(self.inner.delete_receive_history())
.map_err(VnidropError::repository)
}
pub fn set_transfer_access_mode( pub fn set_transfer_access_mode(
&self, &self,
transfer_id: u64, transfer_id: u64,
@@ -992,9 +998,20 @@ impl CoreInner {
.await .await
.retain(|_, id| *id != transfer_id); .retain(|_, id| *id != transfer_id);
self.access_policy.remove_transfer(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await;
// Events are persisted asynchronously. Drain events emitted before this
// request so none can be written back after the transfer is deleted.
self.event_hub.flush().await;
self.repository.delete_transfer(transfer_id).await self.repository.delete_transfer(transfer_id).await
} }
async fn delete_receive_history(&self) -> Result<u64> {
// Transfer events are persisted on a background task. Drain everything
// emitted before this request so cleared history cannot be reinserted
// after the repository transaction commits.
self.event_hub.flush().await;
self.repository.delete_receive_history().await
}
async fn set_transfer_access_mode( async fn set_transfer_access_mode(
&self, &self,
transfer_id: u64, transfer_id: u64,

View File

@@ -585,6 +585,149 @@ async fn deleting_transfer_removes_related_history_transactionally() {
.is_empty()); .is_empty());
assert!(repository.delete_transfer(88).await.is_err()); assert!(repository.delete_transfer(88).await.is_err());
} }
#[tokio::test]
async fn deleting_receive_history_only_removes_terminal_receives_and_dependants() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
let records = [
(100, TransferDirection::Receive, TransferStatus::Done),
(101, TransferDirection::Receive, TransferStatus::Failed),
(102, TransferDirection::Receive, TransferStatus::Cancelled),
(103, TransferDirection::Receive, TransferStatus::Receiving),
(104, TransferDirection::Send, TransferStatus::Done),
(105, TransferDirection::Send, TransferStatus::Sharing),
];
for (transfer_id, direction, status) in records {
repository
.insert_transfer(transfer(transfer_id, direction, status))
.await
.unwrap();
let request_id = format!("request-{transfer_id}");
repository
.insert_receiver_request(ReceiverRequestInsert {
id: &request_id,
transfer_id,
remote_endpoint_id: "receiver",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "1.0",
})
.await
.unwrap();
repository
.insert_event(
&CoreEvent {
id: format!("event-{transfer_id}"),
timestamp: transfer_id as i64,
scope: "transfer".to_string(),
transfer_id: Some(transfer_id),
direction: Some(direction.as_str().to_string()),
phase: "test".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
},
500,
)
.await
.unwrap();
}
assert_eq!(repository.delete_receive_history().await.unwrap(), 3);
let remaining = repository.list_transfers().await.unwrap();
assert_eq!(remaining.len(), 3);
for transfer_id in [103, 104, 105] {
assert!(remaining
.iter()
.any(|transfer| transfer.transfer_id == transfer_id));
assert_eq!(
repository
.list_receiver_requests(transfer_id)
.await
.unwrap()
.len(),
1
);
assert_eq!(
repository
.list_events(Some(transfer_id), 500)
.await
.unwrap()
.len(),
1
);
}
for transfer_id in [100, 101, 102] {
assert!(repository
.list_receiver_requests(transfer_id)
.await
.unwrap()
.is_empty());
assert!(repository
.list_events(Some(transfer_id), 500)
.await
.unwrap()
.is_empty());
}
assert_eq!(repository.delete_receive_history().await.unwrap(), 0);
}
#[tokio::test]
async fn receive_history_mid_transaction_failure_preserves_all_related_rows() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.insert_transfer(transfer(
106,
TransferDirection::Receive,
TransferStatus::Done,
))
.await
.unwrap();
repository
.insert_receiver_request(ReceiverRequestInsert {
id: "request-preserved",
transfer_id: 106,
remote_endpoint_id: "receiver",
transfer_name: "demo",
receiver_name: None,
receiver_device_name: None,
app_version: "1.0",
})
.await
.unwrap();
repository
.insert_event(
&CoreEvent {
id: "event-preserved".to_string(),
timestamp: 1,
scope: "transfer".to_string(),
transfer_id: Some(106),
direction: Some("receive".to_string()),
phase: "test".to_string(),
kind: "created".to_string(),
data_json: "{}".to_string(),
},
500,
)
.await
.unwrap();
repository.fail_receive_history_after_dependants();
assert!(repository.delete_receive_history().await.is_err());
assert_eq!(repository.list_transfers().await.unwrap().len(), 1);
assert_eq!(
repository.list_receiver_requests(106).await.unwrap().len(),
1
);
assert_eq!(
repository.list_events(Some(106), 500).await.unwrap().len(),
1
);
}
use std::str::FromStr; use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};

View File

@@ -146,6 +146,7 @@ interface CoreGateway {
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit> suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit> suspend fun delete(transferId: ULong): Result<Unit>
suspend fun clearReceiveHistory(): Result<ULong>
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit> suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit> suspend fun refresh(): Result<Unit>

View File

@@ -173,6 +173,12 @@ class CoreRepository(
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
} }
override suspend fun clearReceiveHistory(): Result<ULong> = runCore {
val deleted = requireCore().deleteReceiveHistory()
refreshSnapshot()
deleted
}
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore { override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
} }