mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(core): add atomic receive history cleanup
This commit is contained in:
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user