2026-04-07 02:50:19 +08:00
|
|
|
use crate::error::SqlxResultExt;
|
2026-03-24 15:12:56 +08:00
|
|
|
use crate::DataLayerError;
|
2026-07-15 23:47:19 +08:00
|
|
|
use crate::{DatabaseRecordId, PostgresTransactionOptions, PostgresTransactionRunner};
|
2026-04-12 20:45:47 +08:00
|
|
|
use futures_util::{FutureExt, TryStreamExt};
|
2026-03-24 15:12:56 +08:00
|
|
|
use sqlx::query_scalar;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub struct PostgresLeaseClaimOptions {
|
|
|
|
|
pub batch_size: usize,
|
|
|
|
|
pub lease_ms: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PostgresLeaseClaimOptions {
|
|
|
|
|
pub fn validate(&self) -> Result<(), DataLayerError> {
|
|
|
|
|
if self.batch_size == 0 {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres lease batch_size must be positive".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if self.lease_ms == 0 {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres lease lease_ms must be positive".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct PostgresLeaseClaimSpec {
|
|
|
|
|
pub table: &'static str,
|
|
|
|
|
pub id_column: &'static str,
|
|
|
|
|
pub lease_owner_column: &'static str,
|
|
|
|
|
pub lease_expires_at_column: &'static str,
|
|
|
|
|
pub eligibility_predicate_sql: &'static str,
|
|
|
|
|
pub order_by_sql: &'static str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
|
|
|
pub struct PostgresLeaseRunnerConfig {
|
|
|
|
|
pub statement_timeout_ms: Option<u64>,
|
|
|
|
|
pub lock_timeout_ms: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PostgresLeaseRunnerConfig {
|
|
|
|
|
pub fn validate(&self) -> Result<(), DataLayerError> {
|
|
|
|
|
if matches!(self.statement_timeout_ms, Some(0)) {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres lease statement_timeout_ms must be positive".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if matches!(self.lock_timeout_ms, Some(0)) {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres lease lock_timeout_ms must be positive".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct PostgresLeaseRunner {
|
|
|
|
|
transaction_runner: PostgresTransactionRunner,
|
|
|
|
|
config: PostgresLeaseRunnerConfig,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PostgresLeaseRunner {
|
|
|
|
|
pub fn new(
|
|
|
|
|
transaction_runner: PostgresTransactionRunner,
|
|
|
|
|
config: PostgresLeaseRunnerConfig,
|
|
|
|
|
) -> Result<Self, DataLayerError> {
|
|
|
|
|
config.validate()?;
|
|
|
|
|
Ok(Self {
|
|
|
|
|
transaction_runner,
|
|
|
|
|
config,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn config(&self) -> PostgresLeaseRunnerConfig {
|
|
|
|
|
self.config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn transaction_runner(&self) -> &PostgresTransactionRunner {
|
|
|
|
|
&self.transaction_runner
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn claim_ids(
|
|
|
|
|
&self,
|
|
|
|
|
spec: &PostgresLeaseClaimSpec,
|
|
|
|
|
options: PostgresLeaseClaimOptions,
|
|
|
|
|
owner: &str,
|
|
|
|
|
) -> Result<Vec<DatabaseRecordId>, DataLayerError> {
|
|
|
|
|
validate_lease_owner(owner)?;
|
|
|
|
|
let sql = build_postgres_lease_claim_sql(spec, options)?;
|
|
|
|
|
let owner = owner.trim().to_string();
|
|
|
|
|
let lease_ms = i64::try_from(options.lease_ms).map_err(|_| {
|
|
|
|
|
DataLayerError::InvalidInput("postgres lease lease_ms exceeds i64 range".to_string())
|
|
|
|
|
})?;
|
|
|
|
|
let tx_options = PostgresTransactionOptions {
|
|
|
|
|
statement_timeout_ms: self.config.statement_timeout_ms,
|
|
|
|
|
lock_timeout_ms: self.config.lock_timeout_ms,
|
|
|
|
|
..PostgresTransactionOptions::read_write()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.transaction_runner
|
|
|
|
|
.run(tx_options, |tx| {
|
|
|
|
|
async move {
|
2026-04-12 20:45:47 +08:00
|
|
|
let mut rows = query_scalar::<_, String>(&sql)
|
2026-03-24 15:12:56 +08:00
|
|
|
.bind(owner)
|
|
|
|
|
.bind(lease_ms)
|
2026-04-12 20:45:47 +08:00
|
|
|
.fetch(&mut **tx);
|
|
|
|
|
let mut ids = Vec::new();
|
|
|
|
|
while let Some(id) = rows.try_next().await.map_postgres_err()? {
|
|
|
|
|
ids.push(DatabaseRecordId(id));
|
|
|
|
|
}
|
|
|
|
|
Ok(ids)
|
2026-03-24 15:12:56 +08:00
|
|
|
}
|
|
|
|
|
.boxed()
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn release_ids(
|
|
|
|
|
&self,
|
|
|
|
|
spec: &PostgresLeaseClaimSpec,
|
|
|
|
|
ids: &[DatabaseRecordId],
|
|
|
|
|
owner: &str,
|
|
|
|
|
) -> Result<Vec<DatabaseRecordId>, DataLayerError> {
|
|
|
|
|
validate_lease_owner(owner)?;
|
|
|
|
|
if ids.is_empty() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
let sql = build_postgres_lease_release_sql(spec)?;
|
|
|
|
|
let owner = owner.trim().to_string();
|
|
|
|
|
let ids = ids.iter().map(|id| id.0.clone()).collect::<Vec<_>>();
|
|
|
|
|
let tx_options = PostgresTransactionOptions {
|
|
|
|
|
statement_timeout_ms: self.config.statement_timeout_ms,
|
|
|
|
|
lock_timeout_ms: self.config.lock_timeout_ms,
|
|
|
|
|
..PostgresTransactionOptions::read_write()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.transaction_runner
|
|
|
|
|
.run(tx_options, |tx| {
|
|
|
|
|
async move {
|
2026-04-12 20:45:47 +08:00
|
|
|
let mut rows = query_scalar::<_, String>(&sql)
|
2026-03-24 15:12:56 +08:00
|
|
|
.bind(ids)
|
|
|
|
|
.bind(owner)
|
2026-04-12 20:45:47 +08:00
|
|
|
.fetch(&mut **tx);
|
|
|
|
|
let mut released = Vec::new();
|
|
|
|
|
while let Some(id) = rows.try_next().await.map_postgres_err()? {
|
|
|
|
|
released.push(DatabaseRecordId(id));
|
|
|
|
|
}
|
|
|
|
|
Ok(released)
|
2026-03-24 15:12:56 +08:00
|
|
|
}
|
|
|
|
|
.boxed()
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn renew_ids(
|
|
|
|
|
&self,
|
|
|
|
|
spec: &PostgresLeaseClaimSpec,
|
|
|
|
|
ids: &[DatabaseRecordId],
|
|
|
|
|
owner: &str,
|
|
|
|
|
lease_ms: u64,
|
|
|
|
|
) -> Result<Vec<DatabaseRecordId>, DataLayerError> {
|
|
|
|
|
validate_lease_owner(owner)?;
|
|
|
|
|
if lease_ms == 0 {
|
|
|
|
|
return Err(DataLayerError::InvalidInput(
|
|
|
|
|
"postgres lease lease_ms must be positive".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if ids.is_empty() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
let sql = build_postgres_lease_renew_sql(spec)?;
|
|
|
|
|
let owner = owner.trim().to_string();
|
|
|
|
|
let lease_ms = i64::try_from(lease_ms).map_err(|_| {
|
|
|
|
|
DataLayerError::InvalidInput("postgres lease lease_ms exceeds i64 range".to_string())
|
|
|
|
|
})?;
|
|
|
|
|
let ids = ids.iter().map(|id| id.0.clone()).collect::<Vec<_>>();
|
|
|
|
|
let tx_options = PostgresTransactionOptions {
|
|
|
|
|
statement_timeout_ms: self.config.statement_timeout_ms,
|
|
|
|
|
lock_timeout_ms: self.config.lock_timeout_ms,
|
|
|
|
|
..PostgresTransactionOptions::read_write()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.transaction_runner
|
|
|
|
|
.run(tx_options, |tx| {
|
|
|
|
|
async move {
|
2026-04-12 20:45:47 +08:00
|
|
|
let mut rows = query_scalar::<_, String>(&sql)
|
2026-03-24 15:12:56 +08:00
|
|
|
.bind(ids)
|
|
|
|
|
.bind(owner)
|
|
|
|
|
.bind(lease_ms)
|
2026-04-12 20:45:47 +08:00
|
|
|
.fetch(&mut **tx);
|
|
|
|
|
let mut renewed = Vec::new();
|
|
|
|
|
while let Some(id) = rows.try_next().await.map_postgres_err()? {
|
|
|
|
|
renewed.push(DatabaseRecordId(id));
|
|
|
|
|
}
|
|
|
|
|
Ok(renewed)
|
2026-03-24 15:12:56 +08:00
|
|
|
}
|
|
|
|
|
.boxed()
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn build_postgres_lease_claim_sql(
|
|
|
|
|
spec: &PostgresLeaseClaimSpec,
|
|
|
|
|
options: PostgresLeaseClaimOptions,
|
|
|
|
|
) -> Result<String, DataLayerError> {
|
|
|
|
|
options.validate()?;
|
|
|
|
|
validate_lease_spec(spec)?;
|
|
|
|
|
|
|
|
|
|
Ok(format!(
|
|
|
|
|
"WITH claimable AS (\
|
|
|
|
|
SELECT {id_column} \
|
|
|
|
|
FROM {table} \
|
|
|
|
|
WHERE ({eligibility_predicate_sql}) \
|
|
|
|
|
AND ({lease_expires_at_column} IS NULL OR {lease_expires_at_column} <= NOW()) \
|
|
|
|
|
ORDER BY {order_by_sql} \
|
|
|
|
|
FOR UPDATE SKIP LOCKED \
|
|
|
|
|
LIMIT {batch_size}\
|
|
|
|
|
) \
|
|
|
|
|
UPDATE {table} AS target \
|
|
|
|
|
SET {lease_owner_column} = $1, \
|
|
|
|
|
{lease_expires_at_column} = NOW() + ($2::bigint * INTERVAL '1 millisecond') \
|
|
|
|
|
FROM claimable \
|
|
|
|
|
WHERE target.{id_column} = claimable.{id_column} \
|
|
|
|
|
RETURNING target.{id_column}",
|
|
|
|
|
id_column = spec.id_column,
|
|
|
|
|
table = spec.table,
|
|
|
|
|
eligibility_predicate_sql = spec.eligibility_predicate_sql,
|
|
|
|
|
lease_expires_at_column = spec.lease_expires_at_column,
|
|
|
|
|
order_by_sql = spec.order_by_sql,
|
|
|
|
|
batch_size = options.batch_size,
|
|
|
|
|
lease_owner_column = spec.lease_owner_column,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn build_postgres_lease_release_sql(
|
|
|
|
|
spec: &PostgresLeaseClaimSpec,
|
|
|
|
|
) -> Result<String, DataLayerError> {
|
|
|
|
|
validate_lease_spec(spec)?;
|
|
|
|
|
|
|
|
|
|
Ok(format!(
|
|
|
|
|
"UPDATE {table} \
|
|
|
|
|
SET {lease_owner_column} = NULL, \
|
|
|
|
|
{lease_expires_at_column} = NULL \
|
|
|
|
|
WHERE {id_column} = ANY($1) \
|
|
|
|
|
AND {lease_owner_column} = $2 \
|
|
|
|
|
RETURNING {id_column}",
|
|
|
|
|
table = spec.table,
|
|
|
|
|
id_column = spec.id_column,
|
|
|
|
|
lease_owner_column = spec.lease_owner_column,
|
|
|
|
|
lease_expires_at_column = spec.lease_expires_at_column,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn build_postgres_lease_renew_sql(
|
|
|
|
|
spec: &PostgresLeaseClaimSpec,
|
|
|
|
|
) -> Result<String, DataLayerError> {
|
|
|
|
|
validate_lease_spec(spec)?;
|
|
|
|
|
|
|
|
|
|
Ok(format!(
|
|
|
|
|
"UPDATE {table} \
|
|
|
|
|
SET {lease_expires_at_column} = NOW() + ($3::bigint * INTERVAL '1 millisecond') \
|
|
|
|
|
WHERE {id_column} = ANY($1) \
|
|
|
|
|
AND {lease_owner_column} = $2 \
|
|
|
|
|
RETURNING {id_column}",
|
|
|
|
|
table = spec.table,
|
|
|
|
|
id_column = spec.id_column,
|
|
|
|
|
lease_owner_column = spec.lease_owner_column,
|
|
|
|
|
lease_expires_at_column = spec.lease_expires_at_column,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_lease_spec(spec: &PostgresLeaseClaimSpec) -> Result<(), DataLayerError> {
|
|
|
|
|
for (field, value) in [
|
|
|
|
|
("table", spec.table),
|
|
|
|
|
("id_column", spec.id_column),
|
|
|
|
|
("lease_owner_column", spec.lease_owner_column),
|
|
|
|
|
("lease_expires_at_column", spec.lease_expires_at_column),
|
|
|
|
|
("eligibility_predicate_sql", spec.eligibility_predicate_sql),
|
|
|
|
|
("order_by_sql", spec.order_by_sql),
|
|
|
|
|
] {
|
|
|
|
|
if value.trim().is_empty() {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(format!(
|
|
|
|
|
"postgres lease {field} cannot be empty"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_lease_owner(owner: &str) -> Result<(), DataLayerError> {
|
|
|
|
|
if owner.trim().is_empty() {
|
|
|
|
|
return Err(DataLayerError::InvalidInput(
|
|
|
|
|
"postgres lease owner cannot be empty".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{
|
|
|
|
|
build_postgres_lease_claim_sql, build_postgres_lease_release_sql,
|
|
|
|
|
build_postgres_lease_renew_sql, PostgresLeaseClaimOptions, PostgresLeaseClaimSpec,
|
|
|
|
|
PostgresLeaseRunner, PostgresLeaseRunnerConfig,
|
|
|
|
|
};
|
2026-07-15 23:47:19 +08:00
|
|
|
use crate::{PostgresPoolConfig, PostgresPoolFactory, PostgresTransactionRunner};
|
2026-03-24 15:12:56 +08:00
|
|
|
|
|
|
|
|
fn sample_spec() -> PostgresLeaseClaimSpec {
|
|
|
|
|
PostgresLeaseClaimSpec {
|
|
|
|
|
table: "video_tasks",
|
|
|
|
|
id_column: "id",
|
|
|
|
|
lease_owner_column: "lease_owner",
|
|
|
|
|
lease_expires_at_column: "lease_expires_at",
|
|
|
|
|
eligibility_predicate_sql: "status IN ('submitted', 'processing')",
|
|
|
|
|
order_by_sql: "updated_at ASC",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn builds_skip_locked_claim_sql() {
|
|
|
|
|
let sql = build_postgres_lease_claim_sql(
|
|
|
|
|
&sample_spec(),
|
|
|
|
|
PostgresLeaseClaimOptions {
|
|
|
|
|
batch_size: 16,
|
|
|
|
|
lease_ms: 15_000,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("claim SQL should build");
|
|
|
|
|
|
|
|
|
|
assert!(sql.contains("FOR UPDATE SKIP LOCKED"));
|
|
|
|
|
assert!(sql.contains("LIMIT 16"));
|
|
|
|
|
assert!(sql.contains("lease_owner = $1"));
|
|
|
|
|
assert!(sql.contains("NOW() + ($2::bigint * INTERVAL '1 millisecond')"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn builds_release_sql() {
|
|
|
|
|
let sql =
|
|
|
|
|
build_postgres_lease_release_sql(&sample_spec()).expect("release SQL should build");
|
|
|
|
|
|
|
|
|
|
assert!(sql.contains("id = ANY($1)"));
|
|
|
|
|
assert!(sql.contains("lease_owner = $2"));
|
|
|
|
|
assert!(sql.contains("lease_expires_at = NULL"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn builds_renew_sql() {
|
|
|
|
|
let sql = build_postgres_lease_renew_sql(&sample_spec()).expect("renew SQL should build");
|
|
|
|
|
|
|
|
|
|
assert!(sql.contains("id = ANY($1)"));
|
|
|
|
|
assert!(sql.contains("lease_owner = $2"));
|
|
|
|
|
assert!(sql.contains("NOW() + ($3::bigint * INTERVAL '1 millisecond')"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn lease_runner_reuses_transaction_runner() {
|
|
|
|
|
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
|
|
|
|
database_url: "postgres://localhost/aether".to_string(),
|
|
|
|
|
min_connections: 1,
|
|
|
|
|
max_connections: 4,
|
|
|
|
|
acquire_timeout_ms: 1_000,
|
|
|
|
|
idle_timeout_ms: 5_000,
|
|
|
|
|
max_lifetime_ms: 30_000,
|
|
|
|
|
statement_cache_capacity: 64,
|
|
|
|
|
require_ssl: false,
|
|
|
|
|
})
|
|
|
|
|
.expect("factory should build");
|
|
|
|
|
let pool = factory.connect_lazy().expect("lazy pool should build");
|
|
|
|
|
let transaction_runner = PostgresTransactionRunner::new(pool);
|
|
|
|
|
|
|
|
|
|
let lease_runner = PostgresLeaseRunner::new(
|
|
|
|
|
transaction_runner.clone(),
|
|
|
|
|
PostgresLeaseRunnerConfig {
|
|
|
|
|
statement_timeout_ms: Some(2_000),
|
|
|
|
|
lock_timeout_ms: Some(500),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("lease runner should build");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
lease_runner.config(),
|
|
|
|
|
PostgresLeaseRunnerConfig {
|
|
|
|
|
statement_timeout_ms: Some(2_000),
|
|
|
|
|
lock_timeout_ms: Some(500),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
let _runner_ref = lease_runner.transaction_runner();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn release_and_renew_empty_ids_short_circuit() {
|
|
|
|
|
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
|
|
|
|
database_url: "postgres://localhost/aether".to_string(),
|
|
|
|
|
min_connections: 1,
|
|
|
|
|
max_connections: 4,
|
|
|
|
|
acquire_timeout_ms: 1_000,
|
|
|
|
|
idle_timeout_ms: 5_000,
|
|
|
|
|
max_lifetime_ms: 30_000,
|
|
|
|
|
statement_cache_capacity: 64,
|
|
|
|
|
require_ssl: false,
|
|
|
|
|
})
|
|
|
|
|
.expect("factory should build");
|
|
|
|
|
let pool = factory.connect_lazy().expect("lazy pool should build");
|
|
|
|
|
let runner = PostgresLeaseRunner::new(
|
|
|
|
|
PostgresTransactionRunner::new(pool),
|
|
|
|
|
PostgresLeaseRunnerConfig::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect("lease runner should build");
|
|
|
|
|
|
|
|
|
|
assert!(runner
|
|
|
|
|
.release_ids(&sample_spec(), &[], "worker-1")
|
|
|
|
|
.await
|
|
|
|
|
.expect("empty release should succeed")
|
|
|
|
|
.is_empty());
|
|
|
|
|
assert!(runner
|
|
|
|
|
.renew_ids(&sample_spec(), &[], "worker-1", 5_000)
|
|
|
|
|
.await
|
|
|
|
|
.expect("empty renew should succeed")
|
|
|
|
|
.is_empty());
|
|
|
|
|
}
|
|
|
|
|
}
|