mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
Centralize isolated Redis test connections in the shared test module, use the canonical Claude Messages format, and correct the borrowed entry comparison.
1119 lines
37 KiB
Rust
1119 lines
37 KiB
Rust
use super::*;
|
|
|
|
type TestConnection = ::redis::aio::MultiplexedConnection;
|
|
|
|
// Frozen pre-optimization script, used as an oracle for out-of-order histories.
|
|
const LEGACY_USAGE_LIMIT_SCRIPT: &str = r#"
|
|
local count = #KEYS
|
|
local now = tonumber(ARGV[1])
|
|
local event_id = ARGV[2]
|
|
for i = 1, count do
|
|
local window_ms = tonumber(ARGV[(i - 1) * 3 + 4]) * 1000
|
|
redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', now - window_ms)
|
|
end
|
|
for i = 1, count do
|
|
local limit = tonumber(ARGV[(i - 1) * 3 + 3])
|
|
local window_ms = tonumber(ARGV[(i - 1) * 3 + 4]) * 1000
|
|
if not redis.call('ZSCORE', KEYS[i], event_id) then
|
|
local current = redis.call('ZCARD', KEYS[i])
|
|
if current >= limit then
|
|
local earliest = redis.call('ZRANGE', KEYS[i], 0, 0, 'WITHSCORES')
|
|
local retry_after = 1
|
|
if #earliest >= 2 then
|
|
retry_after = math.ceil(math.max(1, tonumber(earliest[2]) + window_ms - now) / 1000)
|
|
end
|
|
return {0, i, limit, retry_after}
|
|
end
|
|
end
|
|
end
|
|
for i = 1, count do
|
|
local retention = tonumber(ARGV[(i - 1) * 3 + 5])
|
|
redis.call('ZADD', KEYS[i], 'NX', now, event_id)
|
|
redis.call('EXPIRE', KEYS[i], retention + 1)
|
|
end
|
|
return {1, 0, 0, 0}
|
|
"#;
|
|
|
|
struct Fixture {
|
|
_server: TestRedisServer,
|
|
runtime: RuntimeState,
|
|
admin: TestConnection,
|
|
}
|
|
|
|
impl Fixture {
|
|
async fn start(protocol: &str) -> Option<Self> {
|
|
let Some(server) = TestRedisServer::start().await else {
|
|
eprintln!("usage cleanup {protocol} skipped: isolated Redis unavailable");
|
|
return None;
|
|
};
|
|
let mut admin = redis_test_connection(&server.redis_url).await;
|
|
::redis::cmd("ACL")
|
|
.arg("SETUSER")
|
|
.arg("usage-cleanup")
|
|
.arg("on")
|
|
.arg(">usage-cleanup-test-password")
|
|
.arg("~*")
|
|
.arg("+@all")
|
|
.query_async::<()>(&mut admin)
|
|
.await
|
|
.unwrap();
|
|
let runtime = RuntimeState::redis(
|
|
RedisClientConfig {
|
|
url: format!(
|
|
"redis://usage-cleanup:[email protected]:{}/6?protocol={protocol}",
|
|
server.port
|
|
),
|
|
key_prefix: Some("cleanup".to_string()),
|
|
},
|
|
Some(5_000),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
::redis::cmd("SELECT")
|
|
.arg(6)
|
|
.query_async::<()>(&mut admin)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("usage cleanup fixture ready: protocol={protocol} db=6 authenticated=true");
|
|
Some(Self {
|
|
_server: server,
|
|
runtime,
|
|
admin,
|
|
})
|
|
}
|
|
|
|
async fn consume(
|
|
&self,
|
|
rules: &[UsageLimitRule<'_>],
|
|
event_id: &str,
|
|
now_unix_ms: u64,
|
|
) -> UsageLimitCheck {
|
|
self.runtime
|
|
.check_and_consume_usage_limits(UsageLimitInput {
|
|
rules,
|
|
event_id,
|
|
now_unix_ms,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn seed(&mut self, prefix: &str, key: &str, count: usize, timestamp: u64) {
|
|
for start in (0..count).step_by(1_000) {
|
|
let mut command = ::redis::cmd("ZADD");
|
|
command.arg(format!("{prefix}:{key}"));
|
|
for index in start..(start + 1_000).min(count) {
|
|
command.arg(timestamp).arg(format!("old-{index:08}"));
|
|
}
|
|
command.query_async::<usize>(&mut self.admin).await.unwrap();
|
|
}
|
|
::redis::cmd("PEXPIRE")
|
|
.arg(format!("{prefix}:{key}"))
|
|
.arg(600_000)
|
|
.query_async::<usize>(&mut self.admin)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn rows(&mut self, prefix: &str, key: &str) -> Vec<(String, f64)> {
|
|
::redis::cmd("ZRANGE")
|
|
.arg(format!("{prefix}:{key}"))
|
|
.arg(0)
|
|
.arg(-1)
|
|
.arg("WITHSCORES")
|
|
.query_async(&mut self.admin)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn seed_live(&mut self, prefix: &str, key: &str, count: usize) {
|
|
for start in (0..count).step_by(512) {
|
|
let mut command = ::redis::cmd("ZADD");
|
|
command.arg(format!("{prefix}:{key}"));
|
|
for index in start..(start + 512).min(count) {
|
|
command.arg(100_001).arg(format!("live-{index:08}"));
|
|
}
|
|
command.query_async::<usize>(&mut self.admin).await.unwrap();
|
|
}
|
|
}
|
|
|
|
async fn wait_for_copy(&mut self) -> String {
|
|
tokio::time::timeout(Duration::from_secs(3), async {
|
|
loop {
|
|
let (_, keys): (u64, Vec<String>) = ::redis::cmd("SCAN")
|
|
.arg(0)
|
|
.arg("MATCH")
|
|
.arg("cleanup:*:__usage_copy:*")
|
|
.arg("COUNT")
|
|
.arg(1000)
|
|
.query_async(&mut self.admin)
|
|
.await
|
|
.unwrap();
|
|
if let Some(key) = keys.into_iter().next() {
|
|
return key;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("copy must become observable between its bounded chunks")
|
|
}
|
|
|
|
async fn calls(&mut self, command: &str) -> u64 {
|
|
let info: ::redis::InfoDict = ::redis::cmd("INFO")
|
|
.arg("commandstats")
|
|
.query_async(&mut self.admin)
|
|
.await
|
|
.unwrap();
|
|
info.get::<String>(&format!("cmdstat_{command}"))
|
|
.and_then(|value| {
|
|
value.split(',').find_map(|field| {
|
|
field
|
|
.strip_prefix("calls=")
|
|
.and_then(|value| value.parse().ok())
|
|
})
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
async fn reset_slowlog(&mut self) {
|
|
::redis::cmd("CONFIG")
|
|
.arg("SET")
|
|
.arg("slowlog-log-slower-than")
|
|
.arg(0)
|
|
.arg("slowlog-max-len")
|
|
.arg(4096)
|
|
.query_async::<()>(&mut self.admin)
|
|
.await
|
|
.unwrap();
|
|
::redis::cmd("SLOWLOG")
|
|
.arg("RESET")
|
|
.query_async::<()>(&mut self.admin)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn max_command_us(&mut self) -> i64 {
|
|
let entries: Vec<(i64, i64, i64, Vec<String>, String, String)> = ::redis::cmd("SLOWLOG")
|
|
.arg("GET")
|
|
.arg(4096)
|
|
.query_async(&mut self.admin)
|
|
.await
|
|
.unwrap();
|
|
entries.into_iter().map(|entry| entry.2).max().unwrap_or(0)
|
|
}
|
|
|
|
async fn legacy(
|
|
&mut self,
|
|
rules: &[UsageLimitRule<'_>],
|
|
event_id: &str,
|
|
now: u64,
|
|
) -> UsageLimitCheck {
|
|
let script = ::redis::Script::new(LEGACY_USAGE_LIMIT_SCRIPT);
|
|
let mut invocation = script.prepare_invoke();
|
|
for rule in rules {
|
|
invocation.key(format!("legacy:{}", rule.key));
|
|
}
|
|
invocation.arg(now).arg(event_id);
|
|
for rule in rules {
|
|
invocation
|
|
.arg(rule.limit)
|
|
.arg(rule.window_seconds)
|
|
.arg(rule.retention_seconds);
|
|
}
|
|
let result: Vec<i64> = invocation.invoke_async(&mut self.admin).await.unwrap();
|
|
match result.as_slice() {
|
|
[1, 0, 0, 0] => UsageLimitCheck::Allowed,
|
|
[0, index, limit, retry_after] => UsageLimitCheck::Rejected {
|
|
rule_index: *index as usize - 1,
|
|
limit: *limit as u64,
|
|
retry_after: *retry_after as u64,
|
|
},
|
|
_ => panic!("unexpected legacy result: {result:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rule(key: &str, limit: u64, window_seconds: u64) -> UsageLimitRule<'_> {
|
|
UsageLimitRule {
|
|
key,
|
|
limit,
|
|
window_seconds,
|
|
retention_seconds: 600,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_whole_window_detaches_and_reuses_key_without_bulk_delete() {
|
|
for protocol in ["resp2", "resp3"] {
|
|
let Some(mut fixture) = Fixture::start(protocol).await else {
|
|
return;
|
|
};
|
|
let rules = [UsageLimitRule {
|
|
retention_seconds: 10,
|
|
..rule("usage:{user}:whole", 1, 10)
|
|
}];
|
|
fixture
|
|
.seed("cleanup", rules[0].key, 100_000, 100_000)
|
|
.await;
|
|
let unlinks = fixture.calls("unlink").await;
|
|
let trims = fixture.calls("zremrangebyscore").await;
|
|
assert_eq!(
|
|
fixture.consume(&rules, "old-00000000", 110_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(fixture.calls("unlink").await, unlinks + 1);
|
|
assert_eq!(fixture.calls("zremrangebyscore").await, trims);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[0].key).await,
|
|
vec![("old-00000000".to_string(), 110_000.0)]
|
|
);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "old-00000000", 110_500).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(fixture.rows("cleanup", rules[0].key).await[0].1, 110_000.0);
|
|
let ttl: i64 = ::redis::cmd("PTTL")
|
|
.arg(format!("cleanup:{}", rules[0].key))
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
(8_000..=11_000).contains(&ttl),
|
|
"new key must receive its current retention: {ttl}"
|
|
);
|
|
fixture
|
|
.runtime
|
|
.release_usage_limits(UsageLimitReleaseInput {
|
|
rules: &rules,
|
|
event_id: "old-00000000",
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(fixture.rows("cleanup", rules[0].key).await.is_empty());
|
|
assert_eq!(
|
|
fixture.consume(&rules, "replacement", 110_500).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_preserves_live_boundary_and_out_of_order_rejection() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
let rules = [rule("usage:{user}:mixed", 1, 10)];
|
|
fixture.seed("cleanup", rules[0].key, 4_096, 100_000).await;
|
|
::redis::cmd("ZADD")
|
|
.arg(format!("cleanup:{}", rules[0].key))
|
|
.arg(100_001)
|
|
.arg("live")
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let unlinks = fixture.calls("unlink").await;
|
|
assert_eq!(
|
|
fixture.consume(&rules, "new", 110_000).await,
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 1,
|
|
retry_after: 1
|
|
}
|
|
);
|
|
assert_eq!(fixture.calls("unlink").await, unlinks + 1);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[0].key).await,
|
|
vec![("live".to_string(), 100_001.0)]
|
|
);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "new", 109_000).await,
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 1,
|
|
retry_after: 2
|
|
}
|
|
);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "new", 110_001).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_keeps_multi_window_denial_atomic_and_cleans_later_windows() {
|
|
let Some(mut fixture) = Fixture::start("resp2").await else {
|
|
return;
|
|
};
|
|
let rules = [
|
|
rule("usage:{user}:full", 1, 60),
|
|
rule("usage:{user}:expired", 1, 10),
|
|
];
|
|
for rule in &rules {
|
|
fixture
|
|
.seed(
|
|
"cleanup",
|
|
rule.key,
|
|
if rule.window_seconds == 10 { 1_024 } else { 1 },
|
|
100_000,
|
|
)
|
|
.await;
|
|
}
|
|
assert_eq!(
|
|
fixture.consume(&rules, "denied", 110_000).await,
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 1,
|
|
retry_after: 50
|
|
}
|
|
);
|
|
assert!(fixture.rows("cleanup", rules[1].key).await.is_empty());
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[0].key).await,
|
|
vec![("old-00000000".to_string(), 100_000.0)]
|
|
);
|
|
assert_eq!(
|
|
fixture.consume(&rules[1..], "denied", 101_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[1].key).await,
|
|
vec![("denied".to_string(), 101_000.0)]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_mixed_rebuild_preserves_rows_ttl_and_replay_boundaries() {
|
|
for protocol in ["resp2", "resp3"] {
|
|
let Some(mut fixture) = Fixture::start(protocol).await else {
|
|
return;
|
|
};
|
|
for live in [1_usize, 16, 256, 257, 4_096, 16_384] {
|
|
let key = format!("usage:{{user}}:mixed-{live}");
|
|
let rules = [rule(&key, live as u64, 10)];
|
|
fixture.seed("cleanup", &key, 10_000, 100_000).await;
|
|
let mut command = ::redis::cmd("ZADD");
|
|
command.arg(format!("cleanup:{key}"));
|
|
let rows = (0..live)
|
|
.map(|i| (format!("live-{i:03}"), 100_001.0 + i as f64))
|
|
.collect::<Vec<_>>();
|
|
for (member, score) in &rows {
|
|
command.arg(score).arg(member);
|
|
}
|
|
command
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let ttl_before: i64 = ::redis::cmd("PTTL")
|
|
.arg(format!("cleanup:{key}"))
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let unlinks = fixture.calls("unlink").await;
|
|
assert!(matches!(
|
|
fixture.consume(&rules, "new", 110_000).await,
|
|
UsageLimitCheck::Rejected { .. }
|
|
));
|
|
assert_eq!(fixture.rows("cleanup", &key).await, rows);
|
|
assert_eq!(fixture.calls("unlink").await - unlinks, 1);
|
|
let ttl_after: i64 = ::redis::cmd("PTTL")
|
|
.arg(format!("cleanup:{key}"))
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert!(ttl_after <= ttl_before && ttl_after >= ttl_before - 2_000);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "live-000", 109_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(fixture.rows("cleanup", &key).await, rows);
|
|
assert!(matches!(
|
|
fixture.consume(&rules, "old-00000000", 109_000).await,
|
|
UsageLimitCheck::Rejected { .. }
|
|
));
|
|
let temporary_exists: bool = ::redis::cmd("EXISTS")
|
|
.arg(format!("cleanup:{key}:__usage_trim"))
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert!(!temporary_exists);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_mixed_optional_acl_denials_keep_exact_state() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
for denied in ["zcount", "pttl", "exists", "unlink", "rename", "pexpire"] {
|
|
let key = format!("usage:{{user}}:acl-{denied}");
|
|
fixture.seed("cleanup", &key, 2_048, 100_000).await;
|
|
::redis::cmd("ZADD")
|
|
.arg(format!("cleanup:{key}"))
|
|
.arg(100_001)
|
|
.arg("live")
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
::redis::cmd("ACL")
|
|
.arg("SETUSER")
|
|
.arg("usage-cleanup")
|
|
.arg("+@all")
|
|
.arg(format!("-{denied}"))
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
fixture.consume(&[rule(&key, 1, 10)], "new", 110_000).await,
|
|
UsageLimitCheck::Rejected { .. }
|
|
));
|
|
assert_eq!(
|
|
fixture.rows("cleanup", &key).await,
|
|
vec![("live".into(), 100_001.0)]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_mixed_preserves_nonexpiring_keys_and_existing_temporary_keys() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
for collision in [false, true] {
|
|
let key = format!("usage:{{user}}:persistent-{collision}");
|
|
fixture.seed("cleanup", &key, 2_048, 100_000).await;
|
|
::redis::cmd("PERSIST")
|
|
.arg(format!("cleanup:{key}"))
|
|
.query_async::<bool>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
::redis::cmd("ZADD")
|
|
.arg(format!("cleanup:{key}"))
|
|
.arg(100_001)
|
|
.arg("live")
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
if collision {
|
|
::redis::cmd("SET")
|
|
.arg(format!("cleanup:{key}:__usage_trim"))
|
|
.arg("unrelated")
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
assert!(matches!(
|
|
fixture.consume(&[rule(&key, 1, 10)], "new", 110_000).await,
|
|
UsageLimitCheck::Rejected { .. }
|
|
));
|
|
let ttl: i64 = ::redis::cmd("PTTL")
|
|
.arg(format!("cleanup:{key}"))
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(ttl, -1);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", &key).await,
|
|
vec![("live".into(), 100_001.0)]
|
|
);
|
|
if collision {
|
|
let value: String = ::redis::cmd("GET")
|
|
.arg(format!("cleanup:{key}:__usage_trim"))
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(value, "unrelated");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_large_and_small_windows_keep_exact_cutoffs() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
for (index, count) in [0, 1, 255, 256, 257, 1_024].into_iter().enumerate() {
|
|
let key = format!("usage:{{user}}:boundary-{index}");
|
|
let rules = [rule(&key, 1, 1)];
|
|
let timestamp = MAX_REDIS_LUA_EXACT_INTEGER - 2_000_000;
|
|
fixture.seed("cleanup", &key, count, timestamp).await;
|
|
let unlinks = fixture.calls("unlink").await;
|
|
let result = fixture.consume(&rules, "new", timestamp + 999).await;
|
|
assert_eq!(
|
|
result,
|
|
if count == 0 {
|
|
UsageLimitCheck::Allowed
|
|
} else {
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 1,
|
|
retry_after: 1,
|
|
}
|
|
}
|
|
);
|
|
assert_eq!(fixture.calls("unlink").await, unlinks);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "new", timestamp + 1_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(
|
|
fixture.calls("unlink").await,
|
|
unlinks + u64::from(count > 256)
|
|
);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", &key).await,
|
|
vec![(
|
|
"new".to_string(),
|
|
(timestamp + if count == 0 { 999 } else { 1_000 }) as f64
|
|
)]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_unlink_acl_denial_uses_existing_atomic_trim() {
|
|
for protocol in ["resp2", "resp3"] {
|
|
let Some(mut fixture) = Fixture::start(protocol).await else {
|
|
return;
|
|
};
|
|
let rules = [rule("usage:{user}:acl", 1, 10)];
|
|
fixture.seed("cleanup", rules[0].key, 4_096, 100_000).await;
|
|
::redis::cmd("ACL")
|
|
.arg("SETUSER")
|
|
.arg("usage-cleanup")
|
|
.arg("-unlink")
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let trims = fixture.calls("zremrangebyscore").await;
|
|
assert_eq!(
|
|
fixture.consume(&rules, "new", 110_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(fixture.calls("zremrangebyscore").await, trims + 1);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[0].key).await,
|
|
vec![("new".to_string(), 110_000.0)]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_concurrent_expiration_preserves_the_admission_cap() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
let rules = [rule("usage:{user}:parallel-expired", 8, 10)];
|
|
fixture.seed("cleanup", rules[0].key, 32_768, 100_000).await;
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(64));
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
for index in 0..64 {
|
|
let runtime = fixture.runtime.clone();
|
|
let barrier = Arc::clone(&barrier);
|
|
tasks.spawn(async move {
|
|
let event_id = format!("new-{index}");
|
|
barrier.wait().await;
|
|
runtime
|
|
.check_and_consume_usage_limits(UsageLimitInput {
|
|
rules: &rules,
|
|
event_id: &event_id,
|
|
now_unix_ms: 110_000,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
});
|
|
}
|
|
let mut allowed = 0;
|
|
while let Some(result) = tasks.join_next().await {
|
|
match result.unwrap() {
|
|
UsageLimitCheck::Allowed => allowed += 1,
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 8,
|
|
retry_after: 10,
|
|
} => {}
|
|
other => panic!("unexpected admission: {other:?}"),
|
|
}
|
|
}
|
|
assert_eq!(allowed, 8);
|
|
assert_eq!(fixture.calls("unlink").await, 1);
|
|
assert_eq!(fixture.rows("cleanup", rules[0].key).await.len(), 8);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_cached_script_recovers_after_flush_without_reconsuming() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
let rules = [rule("usage:{user}:script-reload", 1, 10)];
|
|
assert_eq!(
|
|
fixture.consume(&rules, "original", 100_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
::redis::cmd("SCRIPT")
|
|
.arg("FLUSH")
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
fixture.consume(&rules, "original", 101_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "extra", 101_000).await,
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 1,
|
|
retry_after: 9,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[0].key).await,
|
|
vec![("original".to_string(), 100_000.0)]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_matches_legacy_for_replays_releases_and_regressing_time() {
|
|
for protocol in ["resp2", "resp3"] {
|
|
let Some(mut fixture) = Fixture::start(protocol).await else {
|
|
return;
|
|
};
|
|
let mut rules = [
|
|
rule("usage:{user}:one", 5, 1),
|
|
rule("usage:{user}:ten", 8, 10),
|
|
rule("usage:{user}:minute", 13, 60),
|
|
];
|
|
for rule in rules {
|
|
for prefix in ["cleanup", "legacy"] {
|
|
fixture.seed(prefix, rule.key, 512, 50_000).await;
|
|
}
|
|
}
|
|
let mut random = 0x1f28_e05b_a39d_c642_u64;
|
|
for index in 0..512 {
|
|
random ^= random << 13;
|
|
random ^= random >> 7;
|
|
random ^= random << 17;
|
|
let now = if index % 9 == 0 {
|
|
110_000
|
|
} else {
|
|
random % 130_001
|
|
};
|
|
let event = format!("event-{}", random % 19);
|
|
rules[0].limit = 1 + random % 7;
|
|
let actual = fixture.consume(&rules, &event, now).await;
|
|
let expected = fixture.legacy(&rules, &event, now).await;
|
|
assert_eq!(
|
|
actual, expected,
|
|
"protocol={protocol} step={index} now={now} event={event}"
|
|
);
|
|
if index % 5 == 0 {
|
|
fixture
|
|
.runtime
|
|
.release_usage_limits(UsageLimitReleaseInput {
|
|
rules: &rules,
|
|
event_id: &event,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
for rule in rules {
|
|
::redis::cmd("ZREM")
|
|
.arg(format!("legacy:{}", rule.key))
|
|
.arg(&event)
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
for rule in rules {
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rule.key).await,
|
|
fixture.rows("legacy", rule.key).await,
|
|
"protocol={protocol} step={index} key={}",
|
|
rule.key
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "isolated large-window timing baseline"]
|
|
async fn redis_usage_cleanup_large_window_timing() {
|
|
let mut fixture = Fixture::start("resp3")
|
|
.await
|
|
.expect("timing baseline requires Redis");
|
|
const MEMBERS: usize = 300_000;
|
|
let mut measurements = Vec::new();
|
|
for round in 0..3 {
|
|
let key = format!("usage:{{user}}:timing-{round}");
|
|
let rules = [rule(&key, 1, 10)];
|
|
for prefix in ["legacy", "cleanup"] {
|
|
fixture.seed(prefix, &key, MEMBERS, 100_000).await;
|
|
}
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(
|
|
fixture.legacy(&rules, "new", 110_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
let legacy_us = started.elapsed().as_micros();
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(
|
|
fixture.consume(&rules, "new", 110_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
let optimized_us = started.elapsed().as_micros();
|
|
assert_eq!(
|
|
fixture.rows("cleanup", &key).await,
|
|
fixture.rows("legacy", &key).await
|
|
);
|
|
measurements.push(serde_json::json!({"members":MEMBERS,"legacy_us":legacy_us,"optimized_us":optimized_us}));
|
|
}
|
|
eprintln!(
|
|
"usage cleanup timing: {}",
|
|
serde_json::to_string(&measurements).unwrap()
|
|
);
|
|
|
|
let mut mixed = Vec::new();
|
|
for live in [1, 64, 256, 257, 4_096, 32_768, 150_000] {
|
|
let key = format!("usage:{{user}}:mixed-timing-{live}");
|
|
let rules = [rule(&key, live, 10)];
|
|
for prefix in ["legacy", "cleanup"] {
|
|
fixture.seed(prefix, &key, MEMBERS, 100_000).await;
|
|
fixture.seed_live(prefix, &key, live as usize).await;
|
|
}
|
|
fixture.reset_slowlog().await;
|
|
let started = std::time::Instant::now();
|
|
let legacy = fixture.legacy(&rules, "new", 110_000).await;
|
|
let legacy_us = started.elapsed().as_micros();
|
|
let legacy_max_command_us = fixture.max_command_us().await;
|
|
fixture.reset_slowlog().await;
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(fixture.consume(&rules, "new", 110_000).await, legacy);
|
|
let optimized_us = started.elapsed().as_micros();
|
|
let optimized_max_command_us = fixture.max_command_us().await;
|
|
assert_eq!(
|
|
fixture.rows("cleanup", &key).await,
|
|
fixture.rows("legacy", &key).await
|
|
);
|
|
mixed.push(serde_json::json!({"expired": MEMBERS, "live": live, "legacy_us": legacy_us, "optimized_us": optimized_us,
|
|
"legacy_max_command_us": legacy_max_command_us, "optimized_max_command_us": optimized_max_command_us}));
|
|
}
|
|
eprintln!(
|
|
"usage cleanup mixed timing: {}",
|
|
serde_json::to_string(&mixed).unwrap()
|
|
);
|
|
|
|
let rules = [rule("usage:{user}:steady", 10_000, 60)];
|
|
for prefix in ["legacy", "cleanup"] {
|
|
fixture.seed(prefix, rules[0].key, 4_096, 100_000).await;
|
|
}
|
|
let mut legacy_us = Vec::new();
|
|
let mut optimized_us = Vec::new();
|
|
for index in 0..1_000 {
|
|
let event_id = format!("steady-{index}");
|
|
let now = 101_000 + index;
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(
|
|
fixture.legacy(&rules, &event_id, now).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
legacy_us.push(started.elapsed().as_micros());
|
|
let started = std::time::Instant::now();
|
|
assert_eq!(
|
|
fixture.consume(&rules, &event_id, now).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
optimized_us.push(started.elapsed().as_micros());
|
|
}
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rules[0].key).await,
|
|
fixture.rows("legacy", rules[0].key).await
|
|
);
|
|
legacy_us.sort_unstable();
|
|
optimized_us.sort_unstable();
|
|
eprintln!(
|
|
"usage cleanup steady timing: {}",
|
|
serde_json::json!({
|
|
"requests_per_script": 1_000,
|
|
"seeded_live_members": 4_096,
|
|
"legacy_p50_us": legacy_us[499],
|
|
"legacy_p95_us": legacy_us[949],
|
|
"optimized_p50_us": optimized_us[499],
|
|
"optimized_p95_us": optimized_us[949],
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_chunked_copy_retries_when_another_rule_changes() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
let rules = [
|
|
rule("usage:{copy}:short", 1, 10),
|
|
rule("usage:{copy}:large", 100_001, 10),
|
|
];
|
|
fixture.seed("cleanup", rules[0].key, 1, 100_000).await;
|
|
fixture.seed("cleanup", rules[1].key, 20_000, 100_000).await;
|
|
fixture.seed_live("cleanup", rules[1].key, 100_000).await;
|
|
let runtime = fixture.runtime.clone();
|
|
let task = tokio::spawn(async move {
|
|
runtime
|
|
.check_and_consume_usage_limits(UsageLimitInput {
|
|
rules: &rules,
|
|
event_id: "copier",
|
|
now_unix_ms: 110_000,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
});
|
|
fixture.wait_for_copy().await;
|
|
// No earlier rule may have been pruned before the atomic commit.
|
|
assert_eq!(fixture.rows("cleanup", rules[0].key).await.len(), 1);
|
|
::redis::cmd("ZADD")
|
|
.arg(format!("cleanup:{}", rules[0].key))
|
|
.arg(109_000)
|
|
.arg("concurrent-consumer")
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
::redis::cmd("ZREM")
|
|
.arg(format!("cleanup:{}", rules[1].key))
|
|
.arg("live-00000000")
|
|
.query_async::<usize>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
task.await.unwrap(),
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 1,
|
|
retry_after: 9,
|
|
}
|
|
);
|
|
let rows = fixture.rows("cleanup", rules[1].key).await;
|
|
assert_eq!(rows.len(), 99_999);
|
|
assert!(!rows
|
|
.iter()
|
|
.any(|(id, _)| id == "copier" || id == "live-00000000"));
|
|
assert!(fixture.calls("watch").await >= 2);
|
|
assert_eq!(fixture.calls("zremrangebyscore").await, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_cancelled_copy_keeps_source_and_expires_scratch() {
|
|
let Some(mut fixture) = Fixture::start("resp2").await else {
|
|
return;
|
|
};
|
|
let rules = [rule("usage:{copy}:cancelled", 100_001, 10)];
|
|
fixture.seed("cleanup", rules[0].key, 20_000, 100_000).await;
|
|
fixture.seed_live("cleanup", rules[0].key, 100_000).await;
|
|
let runtime = fixture.runtime.clone();
|
|
let task = tokio::spawn(async move {
|
|
runtime
|
|
.check_and_consume_usage_limits(UsageLimitInput {
|
|
rules: &rules,
|
|
event_id: "cancelled",
|
|
now_unix_ms: 110_000,
|
|
})
|
|
.await
|
|
});
|
|
let temporary = fixture.wait_for_copy().await;
|
|
task.abort();
|
|
assert!(task.await.unwrap_err().is_cancelled());
|
|
assert_eq!(fixture.rows("cleanup", rules[0].key).await.len(), 120_000);
|
|
let ttl: i64 = ::redis::cmd("PTTL")
|
|
.arg(&temporary)
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert!(ttl > 0 && ttl <= 60_000);
|
|
assert_eq!(
|
|
fixture.consume(&rules, "replacement", 110_000).await,
|
|
UsageLimitCheck::Allowed
|
|
);
|
|
let rows = fixture.rows("cleanup", rules[0].key).await;
|
|
assert_eq!(rows.len(), 100_001);
|
|
assert!(!rows.iter().any(|(id, _)| id == "cancelled"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_chunked_copy_concurrency_keeps_both_caps() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
let rules = [
|
|
rule("usage:{copy}:parallel-one", 4104, 10),
|
|
rule("usage:{copy}:parallel-two", 4104, 10),
|
|
];
|
|
for rule in &rules {
|
|
fixture.seed("cleanup", rule.key, 20_000, 100_000).await;
|
|
fixture.seed_live("cleanup", rule.key, 4096).await;
|
|
}
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
for index in 0..64 {
|
|
let runtime = fixture.runtime.clone();
|
|
tasks.spawn(async move {
|
|
runtime
|
|
.check_and_consume_usage_limits(UsageLimitInput {
|
|
rules: &rules,
|
|
event_id: &format!("parallel-{index}"),
|
|
now_unix_ms: 110_000,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
});
|
|
}
|
|
let mut allowed = 0;
|
|
while let Some(result) = tasks.join_next().await {
|
|
allowed += usize::from(result.unwrap() == UsageLimitCheck::Allowed);
|
|
}
|
|
assert_eq!(allowed, 8);
|
|
let first = fixture.rows("cleanup", rules[0].key).await;
|
|
assert_eq!(first.len(), 4104);
|
|
assert_eq!(first, fixture.rows("cleanup", rules[1].key).await);
|
|
assert_eq!(fixture.calls("zremrangebyscore").await, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_chunked_copy_optional_acl_denials_preserve_legacy_behavior() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
for denied in ["watch", "unwatch", "multi", "exec", "eval", "persist"] {
|
|
let key = format!("usage:{{copy}}:acl-{denied}");
|
|
fixture.seed("cleanup", &key, 8192, 100_000).await;
|
|
fixture.seed_live("cleanup", &key, 512).await;
|
|
::redis::cmd("ACL")
|
|
.arg("SETUSER")
|
|
.arg("usage-cleanup")
|
|
.arg("+@all")
|
|
.arg(format!("-{denied}"))
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let trims = fixture.calls("zremrangebyscore").await;
|
|
assert_eq!(
|
|
fixture
|
|
.consume(&[rule(&key, 512, 10)], "new", 110_000)
|
|
.await,
|
|
UsageLimitCheck::Rejected {
|
|
rule_index: 0,
|
|
limit: 512,
|
|
retry_after: 1
|
|
}
|
|
);
|
|
assert_eq!(fixture.rows("cleanup", &key).await.len(), 512);
|
|
assert_eq!(fixture.calls("zremrangebyscore").await, trims + 1);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_chunked_copy_matches_legacy_across_regressing_time() {
|
|
for protocol in ["resp2", "resp3"] {
|
|
let Some(mut fixture) = Fixture::start(protocol).await else {
|
|
return;
|
|
};
|
|
let rules = [
|
|
rule("usage:{history}:short", 512, 10),
|
|
rule("usage:{history}:long", 9000, 60),
|
|
];
|
|
for prefix in ["cleanup", "legacy"] {
|
|
for rule in &rules {
|
|
fixture.seed(prefix, rule.key, 8192, 100_000).await;
|
|
fixture.seed_live(prefix, rule.key, 512).await;
|
|
}
|
|
::redis::cmd("PERSIST")
|
|
.arg(format!("{prefix}:{}", rules[0].key))
|
|
.query_async::<bool>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for (event, now) in [
|
|
("denied", 110_000),
|
|
("live-00000000", 109_000),
|
|
("old-00000000", 1000),
|
|
("expired", 110_001),
|
|
("backdated", 102_000),
|
|
("new", 160_001),
|
|
] {
|
|
let expected = fixture.legacy(&rules, event, now).await;
|
|
assert_eq!(fixture.consume(&rules, event, now).await, expected);
|
|
for rule in &rules {
|
|
assert_eq!(
|
|
fixture.rows("cleanup", rule.key).await,
|
|
fixture.rows("legacy", rule.key).await
|
|
);
|
|
}
|
|
}
|
|
assert!(fixture.calls("watch").await > 0);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redis_usage_cleanup_copy_refuses_existing_temporary_and_denied_expiration() {
|
|
let Some(mut fixture) = Fixture::start("resp3").await else {
|
|
return;
|
|
};
|
|
let source = "cleanup:usage:{copy}:source";
|
|
let target = "cleanup:usage:{copy}:existing";
|
|
fixture
|
|
.seed_live("cleanup", "usage:{copy}:source", 512)
|
|
.await;
|
|
::redis::cmd("SET")
|
|
.arg(target)
|
|
.arg("unrelated")
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let mut connection = redis_test_connection(&format!(
|
|
"redis://usage-cleanup:[email protected]:{}/6",
|
|
fixture._server.port,
|
|
))
|
|
.await;
|
|
let script = ::redis::Script::new(include_str!("usage_copy.lua"));
|
|
let result = script
|
|
.key(source)
|
|
.key(target)
|
|
.arg(0)
|
|
.arg(511)
|
|
.arg(0)
|
|
.invoke_async::<i64>(&mut connection)
|
|
.await;
|
|
assert!(result.is_err());
|
|
let value: String = ::redis::cmd("GET")
|
|
.arg(target)
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(value, "unrelated");
|
|
::redis::cmd("ACL")
|
|
.arg("SETUSER")
|
|
.arg("usage-cleanup")
|
|
.arg("-pexpire")
|
|
.query_async::<()>(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
let result = script
|
|
.key(source)
|
|
.key("cleanup:usage:{copy}:denied")
|
|
.arg(0)
|
|
.arg(511)
|
|
.arg(0)
|
|
.invoke_async::<i64>(&mut connection)
|
|
.await;
|
|
assert!(result.is_err());
|
|
let exists: bool = ::redis::cmd("EXISTS")
|
|
.arg("cleanup:usage:{copy}:denied")
|
|
.query_async(&mut fixture.admin)
|
|
.await
|
|
.unwrap();
|
|
assert!(!exists);
|
|
assert_eq!(
|
|
fixture.rows("cleanup", "usage:{copy}:source").await.len(),
|
|
512
|
|
);
|
|
}
|