Files
ystp/src/services/quota.rs
2026-07-26 04:53:09 +08:00

983 lines
31 KiB
Rust

use crate::error::{AppError, ErrorCode};
use crate::services::billing::BillingContext;
use crate::state::AppState;
use chrono::{DateTime, Duration, NaiveDate, Utc};
use sqlx::{FromRow, Postgres, Transaction};
use std::net::IpAddr;
use uuid::Uuid;
#[derive(Debug, Clone, Copy)]
pub struct UserUsageBalance {
pub used_units: i64,
pub included_units: i64,
pub bonus_units: i64,
pub redeemed_units: i64,
pub total_units: i64,
pub remaining_units: i64,
}
#[derive(Debug, FromRow)]
struct UsagePeriodRow {
used_units: i32,
bonus_units: i32,
grant_used_units: i32,
}
#[derive(Debug, FromRow)]
struct AvailableGrantRow {
id: Uuid,
expires_at: DateTime<Utc>,
}
pub async fn user_usage_balance(
state: &AppState,
billing: &BillingContext,
) -> Result<UserUsageBalance, AppError> {
let usage = sqlx::query_as::<_, UsagePeriodRow>(
r#"
SELECT used_units, bonus_units, grant_used_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
.unwrap_or(UsagePeriodRow {
used_units: 0,
bonus_units: 0,
grant_used_units: 0,
});
let redeemed_units: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE(SUM(remaining_units), 0)::bigint
FROM unit_grants
WHERE user_id = $1
AND starts_at <= NOW()
AND expires_at > NOW()
AND remaining_units > 0
"#,
)
.bind(billing.user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换额度失败").with_source(err))?;
Ok(calculate_user_balance(
billing.plan.included_units_per_period,
usage.used_units,
usage.bonus_units,
usage.grant_used_units,
redeemed_units,
))
}
pub async fn ensure_user_units(
state: &AppState,
billing: &BillingContext,
needed_units: i32,
) -> Result<(), AppError> {
if needed_units <= 0 {
return Ok(());
}
let balance = user_usage_balance(state, billing).await?;
if balance.remaining_units < i64::from(needed_units) {
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
}
Ok(())
}
pub async fn consume_user_unit(
tx: &mut Transaction<'_, Postgres>,
billing: &BillingContext,
bytes_in: u64,
bytes_out: u64,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
"#,
)
.bind(billing.user_id)
.bind(billing.subscription_id)
.bind(billing.period_start)
.bind(billing.period_end)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?;
let usage = sqlx::query_as::<_, UsagePeriodRow>(
r#"
SELECT used_units, bonus_units, grant_used_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
FOR UPDATE
"#,
)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.fetch_one(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用量周期失败").with_source(err))?;
let plan_used = usage.used_units.saturating_sub(usage.grant_used_units);
let plan_capacity = billing
.plan
.included_units_per_period
.saturating_add(usage.bonus_units);
let plan_available = plan_used < plan_capacity;
let grant = sqlx::query_as::<_, AvailableGrantRow>(
r#"
SELECT id, expires_at
FROM unit_grants
WHERE user_id = $1
AND starts_at <= NOW()
AND expires_at > NOW()
AND remaining_units > 0
ORDER BY expires_at ASC, created_at ASC
FOR UPDATE
LIMIT 1
"#,
)
.bind(billing.user_id)
.fetch_optional(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换额度失败").with_source(err))?;
let use_grant = should_consume_grant(
plan_available,
grant.as_ref().map(|grant| grant.expires_at),
billing.period_end,
);
if !plan_available && !use_grant {
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
}
if use_grant {
let grant_id = grant
.ok_or_else(|| AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"))?
.id;
let updated = sqlx::query(
r#"
UPDATE unit_grants
SET remaining_units = remaining_units - 1,
updated_at = NOW()
WHERE id = $1
AND remaining_units > 0
AND expires_at > NOW()
"#,
)
.bind(grant_id)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "扣减兑换额度失败").with_source(err))?;
if updated.rows_affected() != 1 {
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
}
}
sqlx::query(
r#"
UPDATE usage_periods
SET used_units = used_units + 1,
grant_used_units = grant_used_units + $1,
bytes_in = bytes_in + $2,
bytes_out = bytes_out + $3,
updated_at = NOW()
WHERE user_id = $4 AND period_start = $5 AND period_end = $6
"#,
)
.bind(use_grant as i32)
.bind(bytes_in as i64)
.bind(bytes_out as i64)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "记录用量失败").with_source(err))?;
Ok(())
}
fn calculate_user_balance(
included_units: i32,
used_units: i32,
bonus_units: i32,
grant_used_units: i32,
redeemed_units: i64,
) -> UserUsageBalance {
let base_capacity = i64::from(included_units.saturating_add(bonus_units));
let base_used = i64::from(used_units.saturating_sub(grant_used_units));
let base_remaining = base_capacity.saturating_sub(base_used).max(0);
let remaining_units = base_remaining.saturating_add(redeemed_units.max(0));
let used_units = i64::from(used_units.max(0));
UserUsageBalance {
used_units,
included_units: i64::from(included_units),
bonus_units: i64::from(bonus_units),
redeemed_units: redeemed_units.max(0),
total_units: used_units.saturating_add(remaining_units),
remaining_units,
}
}
fn should_consume_grant(
plan_available: bool,
grant_expires_at: Option<DateTime<Utc>>,
plan_expires_at: DateTime<Utc>,
) -> bool {
grant_expires_at.is_some_and(|expires_at| !plan_available || expires_at <= plan_expires_at)
}
pub async fn consume_anonymous_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
units: u32,
) -> Result<(), AppError> {
reserve_anonymous_units(state, session_id, ip, units)
.await
.map(|_| ())
}
pub async fn reserve_anonymous_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
units: u32,
) -> Result<NaiveDate, AppError> {
let date = utc8_date();
if units == 0 {
return Ok(date);
}
let session_key = anonymous_session_key(session_id, date);
let ip_key = anonymous_ip_key(ip, date);
let mut conn = state.redis.clone();
let limit = crate::services::settings::runtime_policy(state)
.await?
.rate_limits
.anonymous_units_per_day as i64;
let ttl_seconds = 48 * 60 * 60;
let inc = units as i64;
let script = redis::Script::new(
r#"
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local inc = tonumber(ARGV[3])
local v1 = tonumber(redis.call('GET', KEYS[1]) or '0')
local v2 = tonumber(redis.call('GET', KEYS[2]) or '0')
if v1 + inc > limit or v2 + inc > limit then
return -1
end
v1 = redis.call('INCRBY', KEYS[1], inc)
v2 = redis.call('INCRBY', KEYS[2], inc)
if v1 == inc then redis.call('EXPIRE', KEYS[1], ttl) end
if v2 == inc then redis.call('EXPIRE', KEYS[2], ttl) end
return v1
"#,
);
let new_value: i64 = script
.key(session_key)
.key(ip_key)
.arg(limit)
.arg(ttl_seconds)
.arg(inc)
.invoke_async(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "匿名配额检查失败").with_source(err))?;
if new_value < 0 {
return Err(AppError::new(
ErrorCode::QuotaExceeded,
format!("匿名试用次数已用完(每日 {limit} 次)"),
));
}
Ok(date)
}
pub async fn refund_anonymous_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
units: u32,
) -> Result<(), AppError> {
refund_anonymous_units_for_date(state, session_id, ip, utc8_date(), units).await
}
async fn refund_anonymous_units_for_date(
state: &AppState,
session_id: &str,
ip: IpAddr,
date: NaiveDate,
units: u32,
) -> Result<(), AppError> {
if units == 0 {
return Ok(());
}
let session_key = anonymous_session_key(session_id, date);
let ip_key = anonymous_ip_key(ip, date);
let mut conn = state.redis.clone();
let script = redis::Script::new(
r#"
local dec = tonumber(ARGV[1])
local function refund(key)
local current = tonumber(redis.call('GET', key) or '0')
if current <= 0 then return 0 end
return redis.call('DECRBY', key, math.min(current, dec))
end
refund(KEYS[1])
refund(KEYS[2])
return 1
"#,
);
let _: i64 = script
.key(session_key)
.key(ip_key)
.arg(units as i64)
.invoke_async(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "退还匿名配额失败").with_source(err))?;
Ok(())
}
pub async fn refund_anonymous_reservation_once(
state: &AppState,
task_id: Uuid,
session_id: &str,
ip: IpAddr,
date: NaiveDate,
units: u32,
) -> Result<(), AppError> {
if units == 0 {
return Ok(());
}
let session_key = anonymous_session_key(session_id, date);
let ip_key = anonymous_ip_key(ip, date);
let marker_key = format!("anon_quota_refund:{task_id}");
let mut conn = state.redis.clone();
let script = redis::Script::new(
r#"
local dec = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
if redis.call('EXISTS', KEYS[3]) == 1 then
return 0
end
local function refund(key)
local current = tonumber(redis.call('GET', key) or '0')
if current <= 0 then return 0 end
return redis.call('DECRBY', key, math.min(current, dec))
end
refund(KEYS[1])
refund(KEYS[2])
redis.call('SET', KEYS[3], '1', 'EX', ttl)
return 1
"#,
);
let _: i64 = script
.key(session_key)
.key(ip_key)
.key(marker_key)
.arg(units as i64)
.arg(48 * 60 * 60)
.invoke_async(&mut conn)
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "退还匿名任务配额失败").with_source(err)
})?;
Ok(())
}
#[derive(Debug, FromRow)]
struct AnonymousTaskReservationRow {
anonymous_units_reserved: i32,
anonymous_quota_date: Option<NaiveDate>,
total_files: i32,
consumed_units: i32,
session_id: Option<String>,
client_ip: Option<String>,
created_at: DateTime<Utc>,
}
pub async fn settle_anonymous_task_reservation(
state: &AppState,
task_id: Uuid,
) -> Result<Option<u32>, AppError> {
let mut tx = state.db.begin().await.map_err(|err| {
AppError::new(ErrorCode::Internal, "开启匿名配额结算事务失败").with_source(err)
})?;
let row = sqlx::query_as::<_, AnonymousTaskReservationRow>(
r#"
SELECT anonymous_units_reserved,
anonymous_quota_date,
total_files,
COALESCE((
SELECT COUNT(*)::integer
FROM task_files f
WHERE f.task_id = tasks.id
AND f.status = 'completed'
AND f.compressed_size < f.original_size
AND NOT (
-- NULL means the caller did not request the explicit 100% passthrough.
COALESCE(tasks.compression_rate = 100, false)
AND f.original_format = f.output_format
AND tasks.max_width IS NULL
AND tasks.max_height IS NULL
)
), 0) AS consumed_units,
session_id,
host(client_ip) AS client_ip,
created_at
FROM tasks
WHERE id = $1
FOR UPDATE
"#,
)
.bind(task_id)
.fetch_optional(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询匿名任务配额失败").with_source(err))?;
let Some(row) = row else {
tx.rollback().await.ok();
return Ok(None);
};
if row.anonymous_units_reserved <= 0 {
tx.rollback().await.ok();
return Ok(Some(0));
}
let refundable = refundable_reserved_units(
row.anonymous_units_reserved,
row.total_files,
row.consumed_units,
);
if refundable > 0 {
let session_id = row
.session_id
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "匿名任务缺少 session_id"))?;
let ip = row
.client_ip
.as_deref()
.and_then(|value| value.parse::<IpAddr>().ok())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "匿名任务缺少 client_ip"))?;
let date = row
.anonymous_quota_date
.unwrap_or_else(|| (row.created_at + Duration::hours(8)).date_naive());
refund_anonymous_reservation_once(state, task_id, session_id, ip, date, refundable).await?;
}
sqlx::query("UPDATE tasks SET anonymous_units_reserved = 0 WHERE id = $1")
.bind(task_id)
.execute(&mut *tx)
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "完成匿名配额结算失败").with_source(err)
})?;
tx.commit().await.map_err(|err| {
AppError::new(ErrorCode::Internal, "提交匿名配额结算失败").with_source(err)
})?;
Ok(Some(refundable))
}
pub(crate) fn output_consumes_unit(
compression_rate: Option<u8>,
same_format: bool,
has_resize: bool,
has_target_size: bool,
original_size: u64,
output_size: u64,
) -> bool {
let is_unmetered_passthrough =
compression_rate == Some(100) && same_format && !has_resize && !has_target_size;
!is_unmetered_passthrough && output_size < original_size
}
fn refundable_reserved_units(reserved: i32, total_files: i32, consumed_units: i32) -> u32 {
let reserved = reserved.max(0);
let total_files = total_files.max(0);
let consumed_units = consumed_units.clamp(0, total_files);
let unused_reservation = reserved.saturating_sub(consumed_units);
let unfinished_files = total_files.saturating_sub(consumed_units);
u32::try_from(unused_reservation.min(unfinished_files)).unwrap_or(0)
}
fn anonymous_session_key(session_id: &str, date: NaiveDate) -> String {
format!("anon_quota:{session_id}:{}", date.format("%Y-%m-%d"))
}
pub(crate) fn anonymous_ip_scope(ip: IpAddr) -> String {
match ip {
IpAddr::V4(ip) => ip.to_string(),
IpAddr::V6(ip) => {
if let Some(mapped) = ip.to_ipv4_mapped() {
return mapped.to_string();
}
let network = std::net::Ipv6Addr::from(u128::from(ip) & (u128::MAX << 64));
format!("{network}/64")
}
}
}
fn anonymous_ip_key(ip: IpAddr, date: NaiveDate) -> String {
format!(
"anon_quota_ip:{}:{}",
anonymous_ip_scope(ip),
date.format("%Y-%m-%d")
)
}
fn utc8_date() -> NaiveDate {
let now = Utc::now() + Duration::hours(8);
now.date_naive()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::services::mail::Mailer;
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tokio::sync::Semaphore;
struct AnonymousSettlementFixture<'a> {
session_id: &'a str,
ip: IpAddr,
date: NaiveDate,
reserved_units: u32,
compression_rate: Option<i16>,
total_files: usize,
completed_files: usize,
}
async fn build_test_state(
pool: sqlx::PgPool,
database_url: String,
redis_url: String,
) -> AppState {
let mut config = Config::from_env().expect("load quota test config");
config.database_url = database_url;
config.redis_url = redis_url;
config.mail_enabled = false;
config.mail_log_links_when_disabled = false;
config.anon_daily_units = 10;
let redis = redis::Client::open(config.redis_url.clone())
.expect("create quota test Redis client")
.get_connection_manager()
.await
.expect("connect quota test Redis");
AppState {
mailer: Arc::new(Mailer::new(&config).expect("create disabled quota test mailer")),
image_processing_semaphore: Arc::new(Semaphore::new(2)),
runtime_policy_cache: crate::services::settings::RuntimePolicyCache::new(),
storage_cache: crate::services::storage::StorageCache::new(),
config,
db: pool,
redis,
}
}
async fn insert_anonymous_settlement_fixture(
pool: &sqlx::PgPool,
fixture: AnonymousSettlementFixture<'_>,
) -> Uuid {
assert!(fixture.completed_files <= fixture.total_files);
let task_id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO tasks (
id, session_id, client_ip, status,
compression_rate, total_files, completed_files, failed_files,
anonymous_units_reserved, anonymous_quota_date
) VALUES (
$1, $2, $3::inet, 'completed',
$4, $5, $6, $7,
$8, $9
)
"#,
)
.bind(task_id)
.bind(fixture.session_id)
.bind(fixture.ip.to_string())
.bind(fixture.compression_rate)
.bind(fixture.total_files as i32)
.bind(fixture.completed_files as i32)
.bind((fixture.total_files - fixture.completed_files) as i32)
.bind(fixture.reserved_units as i32)
.bind(fixture.date)
.execute(pool)
.await
.expect("insert anonymous settlement task");
for index in 0..fixture.total_files {
let completed = index < fixture.completed_files;
sqlx::query(
r#"
INSERT INTO task_files (
id, task_id, original_name, original_format, output_format,
original_size, compressed_size, status
) VALUES (
$1, $2, $3, 'jpeg', 'jpeg',
100, $4, $5::file_status
)
"#,
)
.bind(Uuid::new_v4())
.bind(task_id)
.bind(format!("fixture-{index}.jpg"))
.bind(completed.then_some(50_i64))
.bind(if completed { "completed" } else { "failed" })
.execute(pool)
.await
.expect("insert anonymous settlement file");
}
task_id
}
async fn anonymous_quota_counts(
state: &AppState,
session_id: &str,
ip: IpAddr,
date: NaiveDate,
) -> (i64, i64) {
let mut redis = state.redis.clone();
let session_count: Option<i64> = redis::cmd("GET")
.arg(anonymous_session_key(session_id, date))
.query_async(&mut redis)
.await
.expect("read anonymous session quota");
let ip_count: Option<i64> = redis::cmd("GET")
.arg(anonymous_ip_key(ip, date))
.query_async(&mut redis)
.await
.expect("read anonymous IP quota");
(session_count.unwrap_or(0), ip_count.unwrap_or(0))
}
#[test]
fn balance_keeps_redeemed_units_separate_from_plan_usage() {
let balance = calculate_user_balance(10, 13, 0, 3, 7);
assert_eq!(balance.remaining_units, 7);
assert_eq!(balance.total_units, 20);
}
#[test]
fn balance_uses_plan_capacity_before_redeemed_units() {
let balance = calculate_user_balance(10, 4, 2, 0, 5);
assert_eq!(balance.remaining_units, 13);
assert_eq!(balance.total_units, 17);
}
#[test]
fn earlier_expiring_entitlement_is_consumed_first() {
let now = Utc::now();
assert!(should_consume_grant(
true,
Some(now + Duration::days(2)),
now + Duration::days(20),
));
assert!(!should_consume_grant(
true,
Some(now + Duration::days(30)),
now + Duration::days(20),
));
assert!(should_consume_grant(
false,
Some(now + Duration::days(30)),
now + Duration::days(20),
));
}
#[test]
fn anonymous_reservation_refunds_only_unconsumed_units() {
assert_eq!(refundable_reserved_units(10, 10, 6), 4);
assert_eq!(refundable_reserved_units(10, 10, 10), 0);
assert_eq!(refundable_reserved_units(10, 10, 0), 10);
}
#[test]
fn anonymous_reservation_refund_is_clamped_to_valid_bounds() {
assert_eq!(refundable_reserved_units(3, 10, 2), 1);
assert_eq!(refundable_reserved_units(10, 3, 1), 2);
assert_eq!(refundable_reserved_units(-1, 10, 0), 0);
assert_eq!(refundable_reserved_units(10, -1, -2), 0);
}
#[test]
fn output_metering_matches_passthrough_contract() {
assert!(output_consumes_unit(None, true, false, false, 100, 50));
assert!(!output_consumes_unit(
Some(100),
true,
false,
false,
100,
50
));
assert!(output_consumes_unit(
Some(100),
false,
false,
false,
100,
50
));
assert!(output_consumes_unit(Some(100), true, true, false, 100, 50));
assert!(!output_consumes_unit(None, true, false, false, 100, 100));
}
#[test]
fn anonymous_quota_keys_use_the_reserved_date() {
let date = NaiveDate::from_ymd_opt(2026, 7, 25).unwrap();
assert_eq!(
anonymous_session_key("session", date),
"anon_quota:session:2026-07-25"
);
assert_eq!(
anonymous_ip_key("127.0.0.1".parse().unwrap(), date),
"anon_quota_ip:127.0.0.1:2026-07-25"
);
}
#[test]
fn anonymous_ipv6_addresses_share_a_slash_64_scope() {
let first: IpAddr = "2001:db8:1234:5678::1".parse().unwrap();
let second: IpAddr = "2001:db8:1234:5678:ffff::99".parse().unwrap();
let other: IpAddr = "2001:db8:1234:5679::1".parse().unwrap();
assert_eq!(anonymous_ip_scope(first), "2001:db8:1234:5678::/64");
assert_eq!(anonymous_ip_scope(first), anonymous_ip_scope(second));
assert_ne!(anonymous_ip_scope(first), anonymous_ip_scope(other));
assert_eq!(
anonymous_ip_scope("::ffff:203.0.113.7".parse().unwrap()),
"203.0.113.7"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "requires isolated IMAGEFORGE_TEST_DATABASE_URL and IMAGEFORGE_TEST_REDIS_URL"]
async fn anonymous_batch_settlement_charges_successes_and_refunds_only_unused_units() {
let database_url = std::env::var("IMAGEFORGE_TEST_DATABASE_URL")
.expect("IMAGEFORGE_TEST_DATABASE_URL must be set");
assert!(
database_url.to_ascii_lowercase().contains("test"),
"refusing to run destructive integration test outside a test database"
);
let redis_url = std::env::var("IMAGEFORGE_TEST_REDIS_URL")
.expect("IMAGEFORGE_TEST_REDIS_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(16)
.connect(&database_url)
.await
.expect("connect quota test database");
sqlx::migrate!().run(&pool).await.expect("run migrations");
let state = build_test_state(pool.clone(), database_url, redis_url).await;
let marker = Uuid::new_v4().simple().to_string();
let mut cleanup = Vec::new();
let null_session = format!("quota-null-{marker}");
let null_ip: IpAddr = "198.51.100.11".parse().expect("parse fixture IP");
let null_date = reserve_anonymous_units(&state, &null_session, null_ip, 3)
.await
.expect("reserve NULL-rate batch quota");
let null_task = insert_anonymous_settlement_fixture(
&pool,
AnonymousSettlementFixture {
session_id: &null_session,
ip: null_ip,
date: null_date,
reserved_units: 3,
compression_rate: None,
total_files: 3,
completed_files: 3,
},
)
.await;
cleanup.push((null_task, null_session.clone(), null_ip, null_date));
assert_eq!(
settle_anonymous_task_reservation(&state, null_task)
.await
.expect("settle NULL-rate batch"),
Some(0)
);
assert_eq!(
anonymous_quota_counts(&state, &null_session, null_ip, null_date).await,
(3, 3)
);
let passthrough_session = format!("quota-passthrough-{marker}");
let passthrough_ip: IpAddr = "198.51.100.12".parse().expect("parse fixture IP");
let passthrough_date =
reserve_anonymous_units(&state, &passthrough_session, passthrough_ip, 3)
.await
.expect("reserve passthrough batch quota");
let passthrough_task = insert_anonymous_settlement_fixture(
&pool,
AnonymousSettlementFixture {
session_id: &passthrough_session,
ip: passthrough_ip,
date: passthrough_date,
reserved_units: 3,
compression_rate: Some(100),
total_files: 3,
completed_files: 3,
},
)
.await;
cleanup.push((
passthrough_task,
passthrough_session.clone(),
passthrough_ip,
passthrough_date,
));
assert_eq!(
settle_anonymous_task_reservation(&state, passthrough_task)
.await
.expect("settle passthrough batch"),
Some(3)
);
assert_eq!(
anonymous_quota_counts(
&state,
&passthrough_session,
passthrough_ip,
passthrough_date,
)
.await,
(0, 0)
);
let partial_session = format!("quota-partial-{marker}");
let partial_ip: IpAddr = "198.51.100.13".parse().expect("parse fixture IP");
let partial_date = reserve_anonymous_units(&state, &partial_session, partial_ip, 3)
.await
.expect("reserve partial batch quota");
let partial_task = insert_anonymous_settlement_fixture(
&pool,
AnonymousSettlementFixture {
session_id: &partial_session,
ip: partial_ip,
date: partial_date,
reserved_units: 3,
compression_rate: None,
total_files: 3,
completed_files: 2,
},
)
.await;
cleanup.push((
partial_task,
partial_session.clone(),
partial_ip,
partial_date,
));
assert_eq!(
settle_anonymous_task_reservation(&state, partial_task)
.await
.expect("settle partial batch"),
Some(1)
);
assert_eq!(
anonymous_quota_counts(&state, &partial_session, partial_ip, partial_date).await,
(2, 2)
);
let limit_session = format!("quota-limit-{marker}");
let limit_ip: IpAddr = "198.51.100.14".parse().expect("parse fixture IP");
let mut limit_date = None;
for batch in 0..2 {
let date = reserve_anonymous_units(&state, &limit_session, limit_ip, 5)
.await
.expect("reserve consecutive anonymous batch");
limit_date = Some(date);
let task_id = insert_anonymous_settlement_fixture(
&pool,
AnonymousSettlementFixture {
session_id: &limit_session,
ip: limit_ip,
date,
reserved_units: 5,
compression_rate: None,
total_files: 5,
completed_files: 5,
},
)
.await;
cleanup.push((task_id, limit_session.clone(), limit_ip, date));
assert_eq!(
settle_anonymous_task_reservation(&state, task_id)
.await
.expect("settle consecutive anonymous batch"),
Some(0),
"batch {batch} unexpectedly refunded consumed units"
);
}
let limit_error = reserve_anonymous_units(&state, &limit_session, limit_ip, 1)
.await
.expect_err("daily anonymous quota was bypassed");
assert_eq!(limit_error.code, ErrorCode::QuotaExceeded);
assert_eq!(
anonymous_quota_counts(
&state,
&limit_session,
limit_ip,
limit_date.expect("limit quota date"),
)
.await,
(10, 10)
);
let mut redis = state.redis.clone();
for (task_id, session_id, ip, date) in cleanup {
sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(task_id)
.execute(&pool)
.await
.expect("delete quota settlement fixture");
let _: i64 = redis::cmd("DEL")
.arg(anonymous_session_key(&session_id, date))
.arg(anonymous_ip_key(ip, date))
.arg(format!("anon_quota_refund:{task_id}"))
.query_async(&mut redis)
.await
.expect("delete quota settlement Redis keys");
}
}
}