use alloy::primitives::FixedBytes;
use newton_metric::{inc_chain_watcher_redis_lookup, record_chain_watcher_filter_lookup_duration};
use redis::{aio::ConnectionManager, AsyncCommands, Client};
use std::time::Instant;
use tracing::{debug, warn};
const SEEN_TASKS_KEY_PREFIX: &str = "newton:seen-tasks";
const SEEN_TASKS_TTL_SECS: u64 = 3600;
pub struct TaskFilter {
chain_id: u64,
conn: ConnectionManager,
}
impl std::fmt::Debug for TaskFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskFilter")
.field("chain_id", &self.chain_id)
.field("redis", &"connected")
.finish()
}
}
impl TaskFilter {
pub async fn new(redis_url: &str, chain_id: u64) -> eyre::Result<Self> {
let client = Client::open(redis_url)?;
let conn = ConnectionManager::new(client).await?;
Ok(Self { chain_id, conn })
}
pub async fn is_seen_by_gateway(&self, task_id: &FixedBytes<32>) -> bool {
let key = format!("{}:{}", SEEN_TASKS_KEY_PREFIX, self.chain_id);
let mut conn = self.conn.clone();
let lookup_start = Instant::now();
let result = redis::cmd("SISMEMBER")
.arg(&key)
.arg(task_id.as_slice())
.query_async::<bool>(&mut conn)
.await;
record_chain_watcher_filter_lookup_duration(self.chain_id, lookup_start.elapsed().as_secs_f64());
match result {
Ok(exists) => {
inc_chain_watcher_redis_lookup(self.chain_id, if exists { "hit" } else { "miss" });
debug!(
chain_id = self.chain_id,
task_id = %task_id,
seen = exists,
"task filter lookup"
);
exists
}
Err(e) => {
inc_chain_watcher_redis_lookup(self.chain_id, "error");
warn!(
chain_id = self.chain_id,
task_id = %task_id,
error = %e,
"redis SISMEMBER failed, treating as unseen"
);
false
}
}
}
pub async fn is_direct_onchain(&self, task_id: &FixedBytes<32>) -> bool {
!self.is_seen_by_gateway(task_id).await
}
pub async fn mark_seen(&self, task_id: &FixedBytes<32>) -> eyre::Result<()> {
let key = format!("{}:{}", SEEN_TASKS_KEY_PREFIX, self.chain_id);
let mut conn = self.conn.clone();
conn.sadd::<_, _, ()>(&key, task_id.as_slice()).await?;
conn.expire::<_, ()>(&key, SEEN_TASKS_TTL_SECS as i64).await?;
Ok(())
}
pub fn redis_key(chain_id: u64) -> String {
format!("{}:{}", SEEN_TASKS_KEY_PREFIX, chain_id)
}
pub const fn ttl_secs() -> u64 {
SEEN_TASKS_TTL_SECS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redis_key_format() {
assert_eq!(TaskFilter::redis_key(31337), "newton:seen-tasks:31337");
assert_eq!(TaskFilter::redis_key(1), "newton:seen-tasks:1");
}
#[test]
fn ttl_is_one_hour() {
assert_eq!(TaskFilter::ttl_secs(), 3600);
}
#[tokio::test]
#[ignore]
async fn mark_seen_then_is_seen_by_gateway_returns_true() {
let filter = TaskFilter::new("redis://:redis@localhost:6379", 99999)
.await
.expect("redis connection");
let task_id = FixedBytes::from([0xABu8; 32]);
let key = TaskFilter::redis_key(99999);
let mut conn = filter.conn.clone();
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
assert!(!filter.is_seen_by_gateway(&task_id).await);
assert!(filter.is_direct_onchain(&task_id).await);
filter.mark_seen(&task_id).await.expect("mark_seen should succeed");
assert!(filter.is_seen_by_gateway(&task_id).await);
assert!(!filter.is_direct_onchain(&task_id).await);
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
}
#[tokio::test]
#[ignore]
async fn unseen_task_is_direct_onchain() {
let filter = TaskFilter::new("redis://:redis@localhost:6379", 99998)
.await
.expect("redis connection");
let key = TaskFilter::redis_key(99998);
let mut conn = filter.conn.clone();
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
let task_id = FixedBytes::from([0xCDu8; 32]);
assert!(filter.is_direct_onchain(&task_id).await);
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
}
#[tokio::test]
#[ignore]
async fn different_chains_have_separate_sets() {
let filter_a = TaskFilter::new("redis://:redis@localhost:6379", 88881)
.await
.expect("redis connection");
let filter_b = TaskFilter::new("redis://:redis@localhost:6379", 88882)
.await
.expect("redis connection");
let task_id = FixedBytes::from([0xEFu8; 32]);
let key_a = TaskFilter::redis_key(88881);
let key_b = TaskFilter::redis_key(88882);
let mut conn = filter_a.conn.clone();
let _: Result<(), _> = redis::cmd("DEL").arg(&key_a).query_async(&mut conn).await;
let _: Result<(), _> = redis::cmd("DEL").arg(&key_b).query_async(&mut conn).await;
filter_a.mark_seen(&task_id).await.expect("mark_seen");
assert!(filter_a.is_seen_by_gateway(&task_id).await);
assert!(!filter_b.is_seen_by_gateway(&task_id).await);
let _: Result<(), _> = redis::cmd("DEL").arg(&key_a).query_async(&mut conn).await;
let _: Result<(), _> = redis::cmd("DEL").arg(&key_b).query_async(&mut conn).await;
}
#[tokio::test]
#[ignore]
async fn gateway_sadd_compatible_with_filter_sismember() {
let chain_id = 77777u64;
let key = TaskFilter::redis_key(chain_id);
let task_id = FixedBytes::from([0x42u8; 32]);
let client = redis::Client::open("redis://:redis@localhost:6379").expect("redis client");
let mut gateway_conn = ConnectionManager::new(client).await.expect("redis conn");
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut gateway_conn).await;
let _: () = redis::cmd("SADD")
.arg(&key)
.arg(task_id.as_slice())
.query_async(&mut gateway_conn)
.await
.expect("SADD");
let _: () = redis::cmd("EXPIRE")
.arg(&key)
.arg(3600i64)
.query_async(&mut gateway_conn)
.await
.expect("EXPIRE");
let filter = TaskFilter::new("redis://:redis@localhost:6379", chain_id)
.await
.expect("filter");
assert!(
filter.is_seen_by_gateway(&task_id).await,
"filter should detect task written by gateway's SADD"
);
assert!(
!filter.is_direct_onchain(&task_id).await,
"gateway-originated task should NOT be treated as direct on-chain"
);
let unseen = FixedBytes::from([0x99u8; 32]);
assert!(filter.is_direct_onchain(&unseen).await);
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut gateway_conn).await;
}
}