use std::{
collections::{BTreeMap, BTreeSet},
num::NonZeroU32,
};
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, Row as _, Transaction};
use thiserror::Error;
use uuid::Uuid;
use crate::horizon::{FULL_RESYNC_CURSOR, PullCursorError, check_pull_cursor};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TombstoneTable {
name: &'static str,
select: &'static str,
delete: &'static str,
}
impl TombstoneTable {
#[must_use]
pub const fn new(name: &'static str, select: &'static str, delete: &'static str) -> Self {
Self {
name,
select,
delete,
}
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct PurgeBatch {
pub selected: usize,
pub deleted: u64,
}
impl PurgeBatch {
#[must_use]
pub fn may_have_more(self, limit: NonZeroU32) -> bool {
self.deleted > 0 && self.selected >= limit_as_usize(limit)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PurgedTable {
pub table: &'static str,
pub deleted: u64,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum PurgeError {
#[error(
"tombstone selector for {table} returned {selected} rows above the batch limit {limit}"
)]
BatchLimitExceeded {
table: &'static str,
selected: usize,
limit: NonZeroU32,
},
#[error("tombstone purge database operation failed")]
Database(#[from] sqlx::Error),
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum PullGuardError {
#[error(transparent)]
Cursor(#[from] PullCursorError),
#[error("pull guard database operation failed")]
Database(#[from] sqlx::Error),
}
struct Candidate {
id: Uuid,
owner_id: Uuid,
revision: i64,
}
pub async fn purge_tombstone_batch(
transaction: &mut Transaction<'_, Postgres>,
table: &TombstoneTable,
cutoff: DateTime<Utc>,
limit: NonZeroU32,
) -> Result<PurgeBatch, PurgeError> {
let candidates = select_candidates(transaction, table, cutoff, limit).await?;
let selected = candidates.len();
let locked_owners = lock_owner_counters(transaction, &candidates).await?;
let purged = candidates
.into_iter()
.filter(|candidate| locked_owners.contains(&candidate.owner_id))
.collect::<Vec<_>>();
if purged.is_empty() {
return Ok(PurgeBatch {
selected,
deleted: 0,
});
}
let ids = purged
.iter()
.map(|candidate| candidate.id)
.collect::<Vec<_>>();
let deleted = sqlx::query(table.delete)
.bind(&ids)
.execute(&mut **transaction)
.await?
.rows_affected();
raise_horizons(transaction, &purged).await?;
Ok(PurgeBatch { selected, deleted })
}
pub async fn purge_tombstones(
pool: &PgPool,
tables: &[TombstoneTable],
cutoff: DateTime<Utc>,
limit: NonZeroU32,
) -> Result<Vec<PurgedTable>, PurgeError> {
let mut report = Vec::with_capacity(tables.len());
for table in tables {
let deleted = drain_table(pool, table, cutoff, limit).await?;
report.push(PurgedTable {
table: table.name,
deleted,
});
}
Ok(report)
}
pub async fn purge_horizon(
transaction: &mut Transaction<'_, Postgres>,
owner_id: Uuid,
) -> Result<Option<i64>, sqlx::Error> {
sqlx::query_scalar("SELECT horizon_revision FROM sync_purge_horizons WHERE owner_id = $1")
.bind(owner_id)
.fetch_optional(&mut **transaction)
.await
}
pub async fn guard_pull_cursor(
transaction: &mut Transaction<'_, Postgres>,
owner_id: Uuid,
cursor: i64,
) -> Result<(), PullGuardError> {
check_pull_cursor(cursor, FULL_RESYNC_CURSOR)?;
sqlx::query("SELECT 1 FROM sync_revisions WHERE owner_id = $1 FOR KEY SHARE")
.bind(owner_id)
.execute(&mut **transaction)
.await?;
let horizon = purge_horizon(transaction, owner_id)
.await?
.unwrap_or(FULL_RESYNC_CURSOR);
check_pull_cursor(cursor, horizon)?;
Ok(())
}
async fn drain_table(
pool: &PgPool,
table: &TombstoneTable,
cutoff: DateTime<Utc>,
limit: NonZeroU32,
) -> Result<u64, PurgeError> {
let mut deleted = 0_u64;
loop {
let mut transaction = pool.begin().await?;
let batch = purge_tombstone_batch(&mut transaction, table, cutoff, limit).await?;
transaction.commit().await?;
deleted = deleted.saturating_add(batch.deleted);
if !batch.may_have_more(limit) {
return Ok(deleted);
}
}
}
async fn select_candidates(
transaction: &mut Transaction<'_, Postgres>,
table: &TombstoneTable,
cutoff: DateTime<Utc>,
limit: NonZeroU32,
) -> Result<Vec<Candidate>, PurgeError> {
let rows = sqlx::query(table.select)
.bind(cutoff)
.bind(i64::from(limit.get()))
.fetch_all(&mut **transaction)
.await?;
if rows.len() > limit_as_usize(limit) {
return Err(PurgeError::BatchLimitExceeded {
table: table.name,
selected: rows.len(),
limit,
});
}
let mut candidates = Vec::with_capacity(rows.len());
for row in rows {
candidates.push(Candidate {
id: row.try_get("id")?,
owner_id: row.try_get("owner_id")?,
revision: row.try_get("revision")?,
});
}
Ok(candidates)
}
async fn lock_owner_counters(
transaction: &mut Transaction<'_, Postgres>,
candidates: &[Candidate],
) -> Result<BTreeSet<Uuid>, sqlx::Error> {
if candidates.is_empty() {
return Ok(BTreeSet::new());
}
let owners = candidates
.iter()
.map(|candidate| candidate.owner_id)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let locked: Vec<Uuid> = sqlx::query_scalar(
"SELECT owner_id FROM sync_revisions
WHERE owner_id = ANY($1)
ORDER BY owner_id
FOR UPDATE SKIP LOCKED",
)
.bind(&owners)
.fetch_all(&mut **transaction)
.await?;
Ok(locked.into_iter().collect())
}
async fn raise_horizons(
transaction: &mut Transaction<'_, Postgres>,
purged: &[Candidate],
) -> Result<(), sqlx::Error> {
let mut horizons = BTreeMap::<Uuid, i64>::new();
for candidate in purged {
horizons
.entry(candidate.owner_id)
.and_modify(|horizon| *horizon = (*horizon).max(candidate.revision))
.or_insert(candidate.revision);
}
let (owners, revisions): (Vec<Uuid>, Vec<i64>) = horizons.into_iter().unzip();
sqlx::query(
"INSERT INTO sync_purge_horizons AS horizon (owner_id, horizon_revision)
SELECT owner_id, horizon_revision
FROM UNNEST($1::uuid[], $2::bigint[]) AS purged (owner_id, horizon_revision)
ON CONFLICT (owner_id) DO UPDATE
SET horizon_revision = EXCLUDED.horizon_revision, updated_at = now()
WHERE horizon.horizon_revision < EXCLUDED.horizon_revision",
)
.bind(&owners)
.bind(&revisions)
.execute(&mut **transaction)
.await?;
Ok(())
}
fn limit_as_usize(limit: NonZeroU32) -> usize {
usize::try_from(limit.get()).unwrap_or(usize::MAX)
}