refactor: extract runtime state backends

This commit is contained in:
fawney19
2026-05-08 00:18:12 +08:00
parent 6f620d92be
commit 6247ac3edc
111 changed files with 4358 additions and 3203 deletions

View File

@@ -1,58 +0,0 @@
use std::fmt;
use super::RedisBackend;
use crate::driver::redis::{RedisLockRunner, RedisLockRunnerConfig};
use crate::DataLayerError;
#[derive(Clone, Default)]
pub struct DataLockBackends {
redis: Option<RedisLockRunner>,
}
impl fmt::Debug for DataLockBackends {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataLockBackends")
.field("has_redis", &self.redis.is_some())
.finish()
}
}
impl DataLockBackends {
pub(crate) fn from_redis(redis: Option<&RedisBackend>) -> Result<Self, DataLayerError> {
Ok(Self {
redis: redis
.map(|backend| backend.lock_runner(RedisLockRunnerConfig::default()))
.transpose()?,
})
}
pub fn redis(&self) -> Option<RedisLockRunner> {
self.redis.clone()
}
pub fn has_any(&self) -> bool {
self.redis.is_some()
}
}
#[cfg(test)]
mod tests {
use super::DataLockBackends;
use crate::backend::RedisBackend;
use crate::driver::redis::RedisClientConfig;
#[test]
fn builds_redis_lock_runner_from_backend() {
let backend = RedisBackend::from_config(RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
})
.expect("redis backend should build");
let locks =
DataLockBackends::from_redis(Some(&backend)).expect("lock backends should build");
assert!(locks.has_any());
assert!(locks.redis().is_some());
}
}

View File

@@ -2,37 +2,31 @@
//!
//! `DataBackends` chooses the configured SQL driver, builds low-level pools,
//! instantiates concrete repositories, and exposes app-facing read/write,
//! lease, lock, worker, and maintenance handles. Request-path repository SQL
//! lease, transaction, and maintenance handles. Request-path repository SQL
//! belongs in `repository/*`; backend-owned maintenance SQL lives in focused
//! modules such as `stats`, `wallet`, and `system`. Pool/client primitives
//! belong in `driver/*`.
mod leases;
mod locks;
mod maintenance;
mod mysql;
mod postgres;
mod read;
mod redis;
mod sqlite;
mod stats;
mod stats_common;
mod system;
mod transactions;
mod wallet;
mod workers;
mod write;
use crate::maintenance::DatabasePoolSummary;
pub use leases::DataLeaseBackends;
pub use locks::DataLockBackends;
pub use mysql::MysqlBackend;
pub use postgres::PostgresBackend;
pub use read::DataReadRepositories;
pub use redis::RedisBackend;
pub use sqlite::SqliteBackend;
pub use transactions::DataTransactionBackends;
pub use workers::DataWorkerBackends;
pub use write::DataWriteRepositories;
use crate::database::DatabaseDriver;
@@ -51,12 +45,9 @@ pub struct DataBackends {
postgres: Option<PostgresBackend>,
mysql: Option<MysqlBackend>,
sqlite: Option<SqliteBackend>,
redis: Option<RedisBackend>,
leases: DataLeaseBackends,
locks: DataLockBackends,
read: DataReadRepositories,
transactions: DataTransactionBackends,
workers: DataWorkerBackends,
write: DataWriteRepositories,
}
@@ -111,17 +102,10 @@ impl DataBackends {
}
_ => None,
};
let redis = config
.redis
.clone()
.map(RedisBackend::from_config)
.transpose()?;
let leases = DataLeaseBackends::from_postgres(postgres.as_ref())?;
let locks = DataLockBackends::from_redis(redis.as_ref())?;
let read =
DataReadRepositories::from_backends(postgres.as_ref(), mysql.as_ref(), sqlite.as_ref());
let transactions = DataTransactionBackends::from_postgres(postgres.as_ref());
let workers = DataWorkerBackends::from_redis(redis.as_ref())?;
let write = DataWriteRepositories::from_backends(
postgres.as_ref(),
mysql.as_ref(),
@@ -133,12 +117,9 @@ impl DataBackends {
postgres,
mysql,
sqlite,
redis,
leases,
locks,
read,
transactions,
workers,
write,
})
}
@@ -165,10 +146,6 @@ impl DataBackends {
self.sqlite.as_ref()
}
pub fn redis(&self) -> Option<&RedisBackend> {
self.redis.as_ref()
}
pub fn read(&self) -> &DataReadRepositories {
&self.read
}
@@ -177,18 +154,10 @@ impl DataBackends {
&self.leases
}
pub fn locks(&self) -> &DataLockBackends {
&self.locks
}
pub fn transactions(&self) -> &DataTransactionBackends {
&self.transactions
}
pub fn workers(&self) -> &DataWorkerBackends {
&self.workers
}
pub fn write(&self) -> &DataWriteRepositories {
&self.write
}
@@ -197,12 +166,9 @@ impl DataBackends {
self.postgres.is_some()
|| self.mysql.is_some()
|| self.sqlite.is_some()
|| self.redis.is_some()
|| self.leases.has_any()
|| self.locks.has_any()
|| self.read.has_any()
|| self.transactions.has_any()
|| self.workers.has_any()
|| self.write.has_any()
}
}
@@ -224,9 +190,7 @@ mod tests {
assert!(backends.postgres().is_none());
assert!(backends.mysql().is_none());
assert!(backends.sqlite().is_none());
assert!(backends.redis().is_none());
assert!(backends.leases().postgres().is_none());
assert!(backends.locks().redis().is_none());
assert!(backends.read().auth_api_keys().is_none());
assert!(backends.read().auth_modules().is_none());
assert!(backends.read().billing().is_none());
@@ -241,7 +205,6 @@ mod tests {
assert!(backends.read().usage().is_none());
assert!(backends.read().video_tasks().is_none());
assert!(backends.transactions().postgres().is_none());
assert!(backends.workers().redis().is_none());
assert!(backends.write().settlement().is_none());
assert!(backends.write().usage().is_none());
}
@@ -260,7 +223,6 @@ mod tests {
statement_cache_capacity: 64,
require_ssl: false,
}),
redis: None,
})
.expect("postgres backend should build");
@@ -308,7 +270,6 @@ mod tests {
pool: SqlPoolConfig::default(),
}),
postgres: None,
redis: None,
})
.expect("mysql backend should build");
@@ -360,7 +321,6 @@ mod tests {
pool: SqlPoolConfig::default(),
}),
postgres: None,
redis: None,
})
.expect("sqlite backend should build");
@@ -401,34 +361,4 @@ mod tests {
assert!(backends.write().wallets().is_some());
assert!(backends.config().effective_database().is_some());
}
#[test]
fn builds_redis_backend_from_config() {
let backends = DataBackends::from_config(DataLayerConfig {
database: None,
postgres: None,
redis: Some(crate::driver::redis::RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
}),
})
.expect("redis backend should build");
assert!(backends.has_runtime_backends());
assert!(backends.postgres().is_none());
assert!(backends.mysql().is_none());
assert!(backends.sqlite().is_none());
assert!(backends.redis().is_some());
assert!(backends.leases().postgres().is_none());
assert!(backends.locks().redis().is_some());
assert!(backends.workers().redis().is_some());
assert!(backends.read().auth_api_keys().is_none());
assert!(backends.read().auth_modules().is_none());
assert!(backends.read().global_models().is_none());
assert!(backends.read().oauth_providers().is_none());
assert!(backends.transactions().postgres().is_none());
assert!(backends.write().settlement().is_none());
assert!(backends.write().usage().is_none());
assert!(backends.config().redis.is_some());
}
}

View File

@@ -1,86 +0,0 @@
use crate::driver::redis::{
RedisClient, RedisClientConfig, RedisClientFactory, RedisKeyspace, RedisKvRunner,
RedisKvRunnerConfig, RedisLockRunner, RedisLockRunnerConfig, RedisStreamRunner,
RedisStreamRunnerConfig,
};
use crate::DataLayerError;
#[derive(Debug, Clone)]
pub struct RedisBackend {
config: RedisClientConfig,
client: RedisClient,
}
impl RedisBackend {
pub fn from_config(config: RedisClientConfig) -> Result<Self, DataLayerError> {
let factory = RedisClientFactory::new(config.clone())?;
let client = factory.connect_lazy()?;
Ok(Self { config, client })
}
pub fn config(&self) -> &RedisClientConfig {
&self.config
}
pub fn client(&self) -> &RedisClient {
&self.client
}
pub fn client_clone(&self) -> RedisClient {
self.client.clone()
}
pub fn keyspace(&self) -> RedisKeyspace {
self.config.keyspace()
}
pub fn lock_runner(
&self,
config: RedisLockRunnerConfig,
) -> Result<RedisLockRunner, DataLayerError> {
RedisLockRunner::new(self.client_clone(), self.keyspace(), config)
}
pub fn stream_runner(
&self,
config: RedisStreamRunnerConfig,
) -> Result<RedisStreamRunner, DataLayerError> {
RedisStreamRunner::new(self.client_clone(), self.keyspace(), config)
}
pub fn kv_runner(&self, config: RedisKvRunnerConfig) -> Result<RedisKvRunner, DataLayerError> {
RedisKvRunner::new(self.client_clone(), self.keyspace(), config)
}
}
#[cfg(test)]
mod tests {
use super::RedisBackend;
use crate::driver::redis::{
RedisClientConfig, RedisKvRunnerConfig, RedisLockRunnerConfig, RedisStreamRunnerConfig,
};
#[test]
fn backend_retains_config_client_and_shared_runners() {
let config = RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
};
let backend = RedisBackend::from_config(config.clone()).expect("backend should build");
assert_eq!(backend.config(), &config);
assert_eq!(backend.keyspace().key("audit"), "aether:audit");
let _client_ref = backend.client();
let _client_clone = backend.client_clone();
let _lock_runner = backend
.lock_runner(RedisLockRunnerConfig::default())
.expect("lock runner should build");
let _stream_runner = backend
.stream_runner(RedisStreamRunnerConfig::default())
.expect("stream runner should build");
let _kv_runner = backend
.kv_runner(RedisKvRunnerConfig::default())
.expect("kv runner should build");
}
}

View File

@@ -1,58 +0,0 @@
use std::fmt;
use super::RedisBackend;
use crate::driver::redis::{RedisStreamRunner, RedisStreamRunnerConfig};
use crate::DataLayerError;
#[derive(Clone, Default)]
pub struct DataWorkerBackends {
redis: Option<RedisStreamRunner>,
}
impl fmt::Debug for DataWorkerBackends {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataWorkerBackends")
.field("has_redis", &self.redis.is_some())
.finish()
}
}
impl DataWorkerBackends {
pub(crate) fn from_redis(redis: Option<&RedisBackend>) -> Result<Self, DataLayerError> {
Ok(Self {
redis: redis
.map(|backend| backend.stream_runner(RedisStreamRunnerConfig::default()))
.transpose()?,
})
}
pub fn redis(&self) -> Option<RedisStreamRunner> {
self.redis.clone()
}
pub fn has_any(&self) -> bool {
self.redis.is_some()
}
}
#[cfg(test)]
mod tests {
use super::DataWorkerBackends;
use crate::backend::RedisBackend;
use crate::driver::redis::RedisClientConfig;
#[test]
fn builds_redis_stream_runner_from_backend() {
let backend = RedisBackend::from_config(RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
})
.expect("redis backend should build");
let workers =
DataWorkerBackends::from_redis(Some(&backend)).expect("worker backends should build");
assert!(workers.has_any());
assert!(workers.redis().is_some());
}
}

View File

@@ -1,13 +1,11 @@
use crate::database::SqlDatabaseConfig;
use crate::driver::postgres::PostgresPoolConfig;
use crate::driver::redis::RedisClientConfig;
use crate::DataLayerError;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct DataLayerConfig {
pub database: Option<SqlDatabaseConfig>,
pub postgres: Option<PostgresPoolConfig>,
pub redis: Option<RedisClientConfig>,
}
impl DataLayerConfig {
@@ -15,7 +13,6 @@ impl DataLayerConfig {
Self {
database: Some(database),
postgres: None,
redis: None,
}
}
@@ -23,7 +20,6 @@ impl DataLayerConfig {
Self {
database: Some(SqlDatabaseConfig::from_postgres_config(postgres)),
postgres: None,
redis: None,
}
}
@@ -42,14 +38,11 @@ impl DataLayerConfig {
if let Some(postgres) = &self.postgres {
postgres.validate()?;
}
if let Some(redis) = &self.redis {
redis.validate()?;
}
Ok(())
}
pub fn has_persistent_backends(&self) -> bool {
self.effective_database().is_some() || self.redis.is_some()
self.effective_database().is_some()
}
}
@@ -58,7 +51,6 @@ mod tests {
use super::DataLayerConfig;
use crate::database::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
use crate::driver::postgres::PostgresPoolConfig;
use crate::driver::redis::RedisClientConfig;
#[test]
fn validates_nested_backend_configs() {
@@ -74,10 +66,6 @@ mod tests {
statement_cache_capacity: 64,
require_ssl: false,
}),
redis: Some(RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
}),
};
assert!(config.validate().is_ok());
@@ -98,7 +86,6 @@ mod tests {
statement_cache_capacity: 64,
require_ssl: false,
}),
redis: None,
};
assert!(config.validate().is_err());
@@ -122,7 +109,6 @@ mod tests {
statement_cache_capacity: 64,
require_ssl: false,
}),
redis: None,
};
let effective = config

View File

@@ -1,10 +1,9 @@
//! Low-level data driver primitives.
//!
//! These modules own pools, transactions, leases, and Redis client helpers.
//! These modules own pools, transactions, and lease helpers.
//! Domain repository logic belongs in `repository/*`, and application-facing
//! composition belongs in `backend`.
pub mod mysql;
pub mod postgres;
pub mod redis;
pub mod sqlite;

View File

@@ -1,69 +0,0 @@
use crate::driver::redis::RedisKeyspace;
use crate::error::RedisResultExt;
use crate::DataLayerError;
pub type RedisClient = redis::Client;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct RedisClientConfig {
pub url: String,
pub key_prefix: Option<String>,
}
impl RedisClientConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
let raw = self.url.trim();
if raw.is_empty() {
return Err(DataLayerError::InvalidConfiguration(
"redis url cannot be empty".to_string(),
));
}
url::Url::parse(raw).map_err(|err| {
DataLayerError::InvalidConfiguration(format!("invalid redis url: {err}"))
})?;
Ok(())
}
pub fn keyspace(&self) -> RedisKeyspace {
RedisKeyspace::new(self.key_prefix.as_deref())
}
}
#[derive(Debug, Clone)]
pub struct RedisClientFactory {
config: RedisClientConfig,
}
impl RedisClientFactory {
pub fn new(config: RedisClientConfig) -> Result<Self, DataLayerError> {
config.validate()?;
Ok(Self { config })
}
pub fn config(&self) -> &RedisClientConfig {
&self.config
}
pub fn connect_lazy(&self) -> Result<RedisClient, DataLayerError> {
RedisClient::open(self.config.url.clone()).map_redis_err()
}
}
#[cfg(test)]
mod tests {
use super::{RedisClientConfig, RedisClientFactory};
#[test]
fn factory_builds_lazy_client_from_valid_config() {
let config = RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
};
let factory = RedisClientFactory::new(config.clone()).expect("factory should build");
assert_eq!(factory.config(), &config);
let _client = factory
.connect_lazy()
.expect("lazy redis client should build");
}
}

View File

@@ -1,182 +0,0 @@
use std::future::Future;
use std::time::Duration;
use crate::driver::redis::{RedisClient, RedisKeyspace};
use crate::error::RedisResultExt;
use crate::DataLayerError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisKvRunnerConfig {
pub command_timeout_ms: Option<u64>,
pub default_ttl_seconds: u64,
}
impl Default for RedisKvRunnerConfig {
fn default() -> Self {
Self {
command_timeout_ms: Some(1_000),
default_ttl_seconds: 300,
}
}
}
impl RedisKvRunnerConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
if let Some(timeout) = self.command_timeout_ms {
if timeout == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis kv command_timeout_ms must be positive".to_string(),
));
}
}
if self.default_ttl_seconds == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis kv default_ttl_seconds must be positive".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct RedisKvRunner {
client: RedisClient,
keyspace: RedisKeyspace,
config: RedisKvRunnerConfig,
}
impl RedisKvRunner {
pub fn new(
client: RedisClient,
keyspace: RedisKeyspace,
config: RedisKvRunnerConfig,
) -> Result<Self, DataLayerError> {
config.validate()?;
Ok(Self {
client,
keyspace,
config,
})
}
pub fn client(&self) -> &RedisClient {
&self.client
}
pub fn keyspace(&self) -> &RedisKeyspace {
&self.keyspace
}
pub fn config(&self) -> RedisKvRunnerConfig {
self.config
}
pub async fn setex(
&self,
key: &str,
value: &str,
ttl_seconds: Option<u64>,
) -> Result<String, DataLayerError> {
let resolved_ttl = ttl_seconds.unwrap_or(self.config.default_ttl_seconds);
let namespaced_key = self.keyspace.key(key);
self.run_with_timeout("redis kv setex", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
redis::cmd("SETEX")
.arg(&namespaced_key)
.arg(resolved_ttl)
.arg(value)
.query_async(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn del(&self, key: &str) -> Result<i64, DataLayerError> {
let namespaced_key = self.keyspace.key(key);
self.run_with_timeout("redis kv del", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
redis::cmd("DEL")
.arg(&namespaced_key)
.query_async(&mut connection)
.await
.map_redis_err()
})
.await
}
async fn run_with_timeout<T, F>(
&self,
operation: &'static str,
future: F,
) -> Result<T, DataLayerError>
where
F: Future<Output = Result<T, DataLayerError>>,
{
if let Some(timeout_ms) = self.config.command_timeout_ms {
tokio::time::timeout(Duration::from_millis(timeout_ms), future)
.await
.map_err(|_| {
DataLayerError::TimedOut(format!("{operation} exceeded {timeout_ms}ms timeout"))
})?
} else {
future.await
}
}
}
#[cfg(test)]
mod tests {
use super::{RedisKvRunner, RedisKvRunnerConfig};
use crate::driver::redis::{RedisClientConfig, RedisClientFactory, RedisKeyspace};
fn build_runner() -> RedisKvRunner {
let config = RedisClientConfig {
url: "redis://localhost/0".to_string(),
key_prefix: Some("aether-test".to_string()),
};
let factory = RedisClientFactory::new(config).expect("redis factory");
let client = factory.connect_lazy().expect("connect");
let keyspace = factory.config().keyspace();
RedisKvRunner::new(client, keyspace, RedisKvRunnerConfig::default()).expect("runner build")
}
#[test]
fn runner_reuses_client_keyspace_and_config() {
let runner = build_runner();
assert_eq!(
runner.keyspace().key("kv:setex:1"),
"aether-test:kv:setex:1"
);
assert_eq!(runner.config(), RedisKvRunnerConfig::default());
let _client = runner.client();
}
#[test]
fn rejects_zero_default_ttl() {
let config = RedisKvRunnerConfig {
command_timeout_ms: Some(100),
default_ttl_seconds: 0,
};
assert!(RedisKvRunner::new(
RedisClientFactory::new(RedisClientConfig {
url: "redis://localhost/0".to_string(),
key_prefix: Some("aether-test".to_string()),
})
.expect("redis factory")
.connect_lazy()
.expect("redis client"),
RedisKeyspace::new(Some("aether-test")),
config,
)
.is_err());
}
}

View File

@@ -1,323 +0,0 @@
use std::future::Future;
use std::time::Duration;
use crate::driver::redis::{RedisClient, RedisKeyspace};
use crate::error::RedisResultExt;
use crate::DataLayerError;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisLockKey(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisLockLease {
pub key: RedisLockKey,
pub owner: String,
pub token: String,
pub ttl_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisLockRunnerConfig {
pub command_timeout_ms: Option<u64>,
pub default_ttl_ms: u64,
}
impl Default for RedisLockRunnerConfig {
fn default() -> Self {
Self {
command_timeout_ms: Some(1_000),
default_ttl_ms: 15_000,
}
}
}
impl RedisLockRunnerConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
if matches!(self.command_timeout_ms, Some(0)) {
return Err(DataLayerError::InvalidConfiguration(
"redis lock command_timeout_ms must be positive".to_string(),
));
}
if self.default_ttl_ms == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis lock default_ttl_ms must be positive".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct RedisLockRunner {
client: RedisClient,
keyspace: RedisKeyspace,
config: RedisLockRunnerConfig,
}
impl RedisLockRunner {
pub fn new(
client: RedisClient,
keyspace: RedisKeyspace,
config: RedisLockRunnerConfig,
) -> Result<Self, DataLayerError> {
config.validate()?;
Ok(Self {
client,
keyspace,
config,
})
}
pub fn client(&self) -> &RedisClient {
&self.client
}
pub fn keyspace(&self) -> &RedisKeyspace {
&self.keyspace
}
pub fn config(&self) -> RedisLockRunnerConfig {
self.config
}
pub async fn try_acquire(
&self,
key: &RedisLockKey,
owner: &str,
ttl_ms: Option<u64>,
) -> Result<Option<RedisLockLease>, DataLayerError> {
validate_owner(owner)?;
validate_key(key)?;
let ttl_ms = self.resolve_ttl_ms(ttl_ms)?;
let token = format!("{owner}:{}", Uuid::new_v4());
self.run_with_timeout("redis lock acquire", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let status = redis::cmd("SET")
.arg(&key.0)
.arg(&token)
.arg("NX")
.arg("PX")
.arg(ttl_ms)
.query_async::<Option<String>>(&mut connection)
.await
.map_redis_err()?;
Ok(status.map(|_| RedisLockLease {
key: key.clone(),
owner: owner.to_string(),
token,
ttl_ms,
}))
})
.await
}
pub async fn release(&self, lease: &RedisLockLease) -> Result<bool, DataLayerError> {
validate_lease(lease)?;
self.run_with_timeout("redis lock release", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let deleted = redis::Script::new(
"if redis.call('get', KEYS[1]) == ARGV[1] then \
return redis.call('del', KEYS[1]) \
else \
return 0 \
end",
)
.key(&lease.key.0)
.arg(&lease.token)
.invoke_async::<i32>(&mut connection)
.await
.map_redis_err()?;
Ok(deleted > 0)
})
.await
}
pub async fn renew(
&self,
lease: &RedisLockLease,
ttl_ms: Option<u64>,
) -> Result<bool, DataLayerError> {
validate_lease(lease)?;
let ttl_ms = self.resolve_ttl_ms(ttl_ms)?;
self.run_with_timeout("redis lock renew", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let renewed = redis::Script::new(
"if redis.call('get', KEYS[1]) == ARGV[1] then \
return redis.call('pexpire', KEYS[1], ARGV[2]) \
else \
return 0 \
end",
)
.key(&lease.key.0)
.arg(&lease.token)
.arg(ttl_ms)
.invoke_async::<i32>(&mut connection)
.await
.map_redis_err()?;
Ok(renewed > 0)
})
.await
}
async fn run_with_timeout<T, F>(
&self,
operation: &'static str,
future: F,
) -> Result<T, DataLayerError>
where
F: Future<Output = Result<T, DataLayerError>>,
{
if let Some(timeout_ms) = self.config.command_timeout_ms {
tokio::time::timeout(Duration::from_millis(timeout_ms), future)
.await
.map_err(|_| {
DataLayerError::TimedOut(format!("{operation} exceeded {timeout_ms}ms timeout"))
})?
} else {
future.await
}
}
fn resolve_ttl_ms(&self, ttl_ms: Option<u64>) -> Result<u64, DataLayerError> {
let ttl_ms = ttl_ms.unwrap_or(self.config.default_ttl_ms);
if ttl_ms == 0 {
return Err(DataLayerError::InvalidInput(
"redis lock ttl_ms must be positive".to_string(),
));
}
Ok(ttl_ms)
}
}
fn validate_owner(owner: &str) -> Result<(), DataLayerError> {
if owner.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis lock owner cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_key(key: &RedisLockKey) -> Result<(), DataLayerError> {
if key.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis lock key cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_lease(lease: &RedisLockLease) -> Result<(), DataLayerError> {
validate_key(&lease.key)?;
validate_owner(&lease.owner)?;
if lease.token.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis lock token cannot be empty".to_string(),
));
}
if lease.ttl_ms == 0 {
return Err(DataLayerError::InvalidInput(
"redis lock ttl_ms must be positive".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{RedisLockKey, RedisLockLease, RedisLockRunner, RedisLockRunnerConfig};
use crate::driver::redis::{RedisClientConfig, RedisClientFactory};
fn sample_runner() -> RedisLockRunner {
let client = RedisClientFactory::new(RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
})
.expect("factory should build")
.connect_lazy()
.expect("client should build");
RedisLockRunner::new(
client,
RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
}
.keyspace(),
RedisLockRunnerConfig::default(),
)
.expect("runner should build")
}
#[test]
fn validates_runner_config() {
assert!(RedisLockRunnerConfig {
command_timeout_ms: Some(0),
..RedisLockRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisLockRunnerConfig {
default_ttl_ms: 0,
..RedisLockRunnerConfig::default()
}
.validate()
.is_err());
}
#[test]
fn runner_reuses_client_and_keyspace() {
let runner = sample_runner();
assert_eq!(runner.config(), RedisLockRunnerConfig::default());
assert_eq!(runner.keyspace().lock_key("poller").0, "aether:lock:poller");
let _client_ref = runner.client();
}
#[tokio::test]
async fn rejects_invalid_owner_or_lease_before_network() {
let runner = sample_runner();
assert!(runner
.try_acquire(&RedisLockKey("aether:lock:poller".to_string()), "", None)
.await
.is_err());
assert!(runner
.release(&RedisLockLease {
key: RedisLockKey("aether:lock:poller".to_string()),
owner: "worker-1".to_string(),
token: String::new(),
ttl_ms: 1_000,
})
.await
.is_err());
assert!(runner
.renew(
&RedisLockLease {
key: RedisLockKey("aether:lock:poller".to_string()),
owner: "worker-1".to_string(),
token: "token-1".to_string(),
ttl_ms: 1_000,
},
Some(0),
)
.await
.is_err());
}
}

View File

@@ -1,14 +0,0 @@
mod client;
mod kv;
mod lock;
mod namespace;
mod stream;
pub use client::{RedisClient, RedisClientConfig, RedisClientFactory};
pub use kv::{RedisKvRunner, RedisKvRunnerConfig};
pub use lock::{RedisLockKey, RedisLockLease, RedisLockRunner, RedisLockRunnerConfig};
pub use namespace::RedisKeyspace;
pub use stream::{
RedisConsumerGroup, RedisConsumerName, RedisStreamEntry, RedisStreamName,
RedisStreamReclaimConfig, RedisStreamReclaimResult, RedisStreamRunner, RedisStreamRunnerConfig,
};

View File

@@ -1,43 +0,0 @@
use aether_cache::CacheKeyNamespace;
use crate::driver::redis::{RedisLockKey, RedisStreamName};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisKeyspace {
namespace: CacheKeyNamespace,
}
impl RedisKeyspace {
pub fn new(prefix: Option<&str>) -> Self {
let normalized = prefix.unwrap_or_default().trim().trim_matches(':');
Self {
namespace: CacheKeyNamespace::new(normalized),
}
}
pub fn key(&self, raw_key: &str) -> String {
self.namespace.key(raw_key)
}
pub fn lock_key(&self, raw_key: &str) -> RedisLockKey {
RedisLockKey(self.namespace.child("lock").key(raw_key))
}
pub fn stream_name(&self, raw_name: &str) -> RedisStreamName {
RedisStreamName(self.namespace.child("stream").key(raw_name))
}
}
#[cfg(test)]
mod tests {
use super::RedisKeyspace;
#[test]
fn composes_prefixed_lock_and_stream_names() {
let keyspace = RedisKeyspace::new(Some("aether"));
assert_eq!(keyspace.key("auth:user"), "aether:auth:user");
assert_eq!(keyspace.lock_key("poller").0, "aether:lock:poller");
assert_eq!(keyspace.stream_name("audit").0, "aether:stream:audit");
}
}

View File

@@ -1,747 +0,0 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::time::Duration;
use redis::from_redis_value;
use redis::streams::StreamReadReply;
use redis::Value as RedisValue;
use crate::driver::redis::{RedisClient, RedisKeyspace};
use crate::error::{redis_error, RedisResultExt};
use crate::DataLayerError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisStreamName(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisConsumerGroup(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RedisConsumerName(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisStreamEntry {
pub id: String,
pub fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RedisStreamReclaimResult {
pub next_start_id: String,
pub entries: Vec<RedisStreamEntry>,
pub deleted_ids: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisStreamReclaimConfig {
pub min_idle_ms: u64,
pub count: usize,
}
impl Default for RedisStreamReclaimConfig {
fn default() -> Self {
Self {
min_idle_ms: 60_000,
count: 32,
}
}
}
impl RedisStreamReclaimConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
if self.min_idle_ms == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis stream reclaim min_idle_ms must be positive".to_string(),
));
}
if self.count == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis stream reclaim count must be positive".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RedisStreamRunnerConfig {
pub command_timeout_ms: Option<u64>,
pub read_block_ms: Option<u64>,
pub read_count: usize,
}
impl Default for RedisStreamRunnerConfig {
fn default() -> Self {
Self {
command_timeout_ms: Some(2_000),
read_block_ms: Some(1_000),
read_count: 32,
}
}
}
impl RedisStreamRunnerConfig {
pub fn validate(&self) -> Result<(), DataLayerError> {
if matches!(self.command_timeout_ms, Some(0)) {
return Err(DataLayerError::InvalidConfiguration(
"redis stream command_timeout_ms must be positive".to_string(),
));
}
if matches!(self.read_block_ms, Some(0)) {
return Err(DataLayerError::InvalidConfiguration(
"redis stream read_block_ms must be positive".to_string(),
));
}
if let (Some(command_timeout_ms), Some(read_block_ms)) =
(self.command_timeout_ms, self.read_block_ms)
{
if command_timeout_ms <= read_block_ms {
return Err(DataLayerError::InvalidConfiguration(
"redis stream command_timeout_ms must be greater than read_block_ms"
.to_string(),
));
}
}
if self.read_count == 0 {
return Err(DataLayerError::InvalidConfiguration(
"redis stream read_count must be positive".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct RedisStreamRunner {
client: RedisClient,
keyspace: RedisKeyspace,
config: RedisStreamRunnerConfig,
}
impl RedisStreamRunner {
pub fn new(
client: RedisClient,
keyspace: RedisKeyspace,
config: RedisStreamRunnerConfig,
) -> Result<Self, DataLayerError> {
config.validate()?;
Ok(Self {
client,
keyspace,
config,
})
}
pub fn client(&self) -> &RedisClient {
&self.client
}
pub fn keyspace(&self) -> &RedisKeyspace {
&self.keyspace
}
pub fn config(&self) -> RedisStreamRunnerConfig {
self.config
}
pub async fn ensure_consumer_group(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
start_id: &str,
) -> Result<(), DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
validate_stream_position(start_id)?;
self.run_with_timeout("redis stream ensure consumer group", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let result = redis::cmd("XGROUP")
.arg("CREATE")
.arg(&stream.0)
.arg(&group.0)
.arg(start_id)
.arg("MKSTREAM")
.query_async::<String>(&mut connection)
.await;
match result {
Ok(_) => Ok(()),
Err(err) if err.code() == Some("BUSYGROUP") => Ok(()),
Err(err) => Err(redis_error(err)),
}
})
.await
}
pub async fn append_fields(
&self,
stream: &RedisStreamName,
fields: &BTreeMap<String, String>,
) -> Result<String, DataLayerError> {
self.append_fields_with_maxlen(stream, fields, None).await
}
pub async fn append_fields_with_maxlen(
&self,
stream: &RedisStreamName,
fields: &BTreeMap<String, String>,
maxlen: Option<usize>,
) -> Result<String, DataLayerError> {
validate_stream_name(stream)?;
if fields.is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream fields cannot be empty".to_string(),
));
}
self.run_with_timeout("redis stream append", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let mut command = redis::cmd("XADD");
command.arg(&stream.0);
if let Some(maxlen) = maxlen.filter(|value| *value > 0) {
command.arg("MAXLEN").arg("~").arg(maxlen);
}
command.arg("*");
for (key, value) in fields {
command.arg(key).arg(value);
}
command
.query_async::<String>(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn append_json(
&self,
stream: &RedisStreamName,
field: &str,
payload: &serde_json::Value,
) -> Result<String, DataLayerError> {
if field.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream json field cannot be empty".to_string(),
));
}
let mut fields = BTreeMap::new();
fields.insert(
field.to_string(),
serde_json::to_string(payload).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"failed to serialize redis stream payload: {err}"
))
})?,
);
self.append_fields(stream, &fields).await
}
pub async fn read_group(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
consumer: &RedisConsumerName,
) -> Result<Vec<RedisStreamEntry>, DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
validate_consumer(consumer)?;
self.run_with_timeout("redis stream read group", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let mut command = redis::cmd("XREADGROUP");
command
.arg("GROUP")
.arg(&group.0)
.arg(&consumer.0)
.arg("COUNT")
.arg(self.config.read_count);
if let Some(block_ms) = self.config.read_block_ms {
command.arg("BLOCK").arg(block_ms);
}
command.arg("STREAMS").arg(&stream.0).arg(">");
let reply = command
.query_async::<StreamReadReply>(&mut connection)
.await
.map_redis_err()?;
Ok(reply
.keys
.into_iter()
.flat_map(|key| key.ids.into_iter())
.map(|id| RedisStreamEntry {
id: id.id,
fields: id
.map
.into_iter()
.filter_map(|(field, value)| {
redis::from_redis_value::<String>(&value)
.ok()
.map(|text| (field, text))
})
.collect(),
})
.collect())
})
.await
}
pub async fn ack(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
ids: &[String],
) -> Result<usize, DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
if ids.is_empty() {
return Ok(0);
}
self.run_with_timeout("redis stream ack", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let mut command = redis::cmd("XACK");
command.arg(&stream.0).arg(&group.0);
for id in ids {
command.arg(id);
}
command
.query_async::<usize>(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn delete(
&self,
stream: &RedisStreamName,
ids: &[String],
) -> Result<usize, DataLayerError> {
validate_stream_name(stream)?;
if ids.is_empty() {
return Ok(0);
}
self.run_with_timeout("redis stream delete", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let mut command = redis::cmd("XDEL");
command.arg(&stream.0);
for id in ids {
command.arg(id);
}
command
.query_async::<usize>(&mut connection)
.await
.map_redis_err()
})
.await
}
pub async fn claim_stale(
&self,
stream: &RedisStreamName,
group: &RedisConsumerGroup,
consumer: &RedisConsumerName,
start_id: &str,
config: RedisStreamReclaimConfig,
) -> Result<RedisStreamReclaimResult, DataLayerError> {
validate_stream_name(stream)?;
validate_group(group)?;
validate_consumer(consumer)?;
validate_stream_position(start_id)?;
config.validate()?;
self.run_with_timeout("redis stream reclaim", async {
let mut connection = self
.client
.get_multiplexed_async_connection()
.await
.map_redis_err()?;
let reply = redis::cmd("XAUTOCLAIM")
.arg(&stream.0)
.arg(&group.0)
.arg(&consumer.0)
.arg(config.min_idle_ms)
.arg(start_id)
.arg("COUNT")
.arg(config.count)
.query_async::<RedisValue>(&mut connection)
.await
.map_redis_err()?;
parse_reclaim_result(reply)
})
.await
}
async fn run_with_timeout<T, F>(
&self,
operation: &'static str,
future: F,
) -> Result<T, DataLayerError>
where
F: Future<Output = Result<T, DataLayerError>>,
{
if let Some(timeout_ms) = self.config.command_timeout_ms {
tokio::time::timeout(Duration::from_millis(timeout_ms), future)
.await
.map_err(|_| {
DataLayerError::TimedOut(format!("{operation} exceeded {timeout_ms}ms timeout"))
})?
} else {
future.await
}
}
}
fn validate_stream_name(stream: &RedisStreamName) -> Result<(), DataLayerError> {
if stream.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream name cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_group(group: &RedisConsumerGroup) -> Result<(), DataLayerError> {
if group.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis consumer group cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_consumer(consumer: &RedisConsumerName) -> Result<(), DataLayerError> {
if consumer.0.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis consumer name cannot be empty".to_string(),
));
}
Ok(())
}
fn validate_stream_position(position: &str) -> Result<(), DataLayerError> {
if position.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"redis stream position cannot be empty".to_string(),
));
}
Ok(())
}
fn parse_reclaim_result(value: RedisValue) -> Result<RedisStreamReclaimResult, DataLayerError> {
let RedisValue::Array(parts) = value else {
return Err(DataLayerError::UnexpectedValue(
"redis xautoclaim returned non-array payload".to_string(),
));
};
if parts.len() < 2 || parts.len() > 3 {
return Err(DataLayerError::UnexpectedValue(format!(
"redis xautoclaim returned {} top-level fields, expected 2 or 3",
parts.len()
)));
}
let next_start_id = parse_string_value(&parts[0], "redis xautoclaim next_start_id")?;
let entries = parse_reclaim_entries(&parts[1])?;
let deleted_ids = match parts.get(2) {
Some(value) => parse_string_array(value, "redis xautoclaim deleted_ids")?,
None => Vec::new(),
};
Ok(RedisStreamReclaimResult {
next_start_id,
entries,
deleted_ids,
})
}
fn parse_reclaim_entries(value: &RedisValue) -> Result<Vec<RedisStreamEntry>, DataLayerError> {
match value {
RedisValue::Array(entries) => entries.iter().map(parse_reclaim_entry).collect(),
RedisValue::Nil => Ok(Vec::new()),
_ => Err(DataLayerError::UnexpectedValue(
"redis xautoclaim entries payload was not an array".to_string(),
)),
}
}
fn parse_reclaim_entry(value: &RedisValue) -> Result<RedisStreamEntry, DataLayerError> {
let RedisValue::Array(parts) = value else {
return Err(DataLayerError::UnexpectedValue(
"redis xautoclaim entry was not an array".to_string(),
));
};
if parts.len() != 2 {
return Err(DataLayerError::UnexpectedValue(format!(
"redis xautoclaim entry had {} fields, expected 2",
parts.len()
)));
}
let id = parse_string_value(&parts[0], "redis xautoclaim entry id")?;
let fields = parse_string_map(&parts[1], "redis xautoclaim entry fields")?;
Ok(RedisStreamEntry { id, fields })
}
fn parse_string_map(
value: &RedisValue,
context: &str,
) -> Result<BTreeMap<String, String>, DataLayerError> {
match value {
RedisValue::Array(values) => {
if values.len() % 2 != 0 {
return Err(DataLayerError::UnexpectedValue(format!(
"{context} expected an even number of field elements, got {}",
values.len()
)));
}
let mut fields = BTreeMap::new();
for pair in values.chunks(2) {
let key = parse_string_value(&pair[0], context)?;
let value = parse_string_value(&pair[1], context)?;
fields.insert(key, value);
}
Ok(fields)
}
RedisValue::Map(entries) => entries
.iter()
.map(|(key, value)| {
Ok((
parse_string_value(key, context)?,
parse_string_value(value, context)?,
))
})
.collect(),
RedisValue::Nil => Ok(BTreeMap::new()),
_ => Err(DataLayerError::UnexpectedValue(format!(
"{context} expected a redis array/map payload"
))),
}
}
fn parse_string_array(value: &RedisValue, context: &str) -> Result<Vec<String>, DataLayerError> {
match value {
RedisValue::Array(values) => values
.iter()
.map(|value| parse_string_value(value, context))
.collect(),
RedisValue::Nil => Ok(Vec::new()),
_ => Err(DataLayerError::UnexpectedValue(format!(
"{context} expected a redis array payload"
))),
}
}
fn parse_string_value(value: &RedisValue, context: &str) -> Result<String, DataLayerError> {
from_redis_value::<String>(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!(
"{context} was not a string-compatible redis value: {err}"
))
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
parse_reclaim_result, RedisConsumerGroup, RedisConsumerName, RedisStreamName,
RedisStreamReclaimConfig, RedisStreamReclaimResult, RedisStreamRunner,
RedisStreamRunnerConfig,
};
use crate::driver::redis::{RedisClientConfig, RedisClientFactory};
use redis::Value as RedisValue;
fn sample_runner() -> RedisStreamRunner {
let config = RedisClientConfig {
url: "redis://127.0.0.1/0".to_string(),
key_prefix: Some("aether".to_string()),
};
let client = RedisClientFactory::new(config.clone())
.expect("factory should build")
.connect_lazy()
.expect("client should build");
RedisStreamRunner::new(
client,
config.keyspace(),
RedisStreamRunnerConfig::default(),
)
.expect("runner should build")
}
#[test]
fn validates_stream_runner_config() {
assert!(RedisStreamRunnerConfig {
command_timeout_ms: Some(0),
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamRunnerConfig {
read_block_ms: Some(0),
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamRunnerConfig {
command_timeout_ms: Some(1_000),
read_block_ms: Some(1_000),
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamRunnerConfig {
read_count: 0,
..RedisStreamRunnerConfig::default()
}
.validate()
.is_err());
}
#[test]
fn validates_reclaim_config() {
assert!(RedisStreamReclaimConfig {
min_idle_ms: 0,
..RedisStreamReclaimConfig::default()
}
.validate()
.is_err());
assert!(RedisStreamReclaimConfig {
count: 0,
..RedisStreamReclaimConfig::default()
}
.validate()
.is_err());
}
#[test]
fn runner_reuses_client_and_keyspace() {
let runner = sample_runner();
assert_eq!(runner.config(), RedisStreamRunnerConfig::default());
assert_eq!(
runner.keyspace().stream_name("audit").0,
"aether:stream:audit"
);
let _client_ref = runner.client();
}
#[tokio::test]
async fn rejects_invalid_inputs_before_network() {
let runner = sample_runner();
let stream = RedisStreamName("aether:stream:audit".to_string());
let group = RedisConsumerGroup("audit-workers".to_string());
let consumer = RedisConsumerName("worker-1".to_string());
assert!(runner
.ensure_consumer_group(&stream, &group, "")
.await
.is_err());
assert!(runner
.append_fields(&stream, &BTreeMap::new())
.await
.is_err());
assert!(runner
.append_json(&stream, "", &serde_json::json!({"ok": true}))
.await
.is_err());
assert!(runner
.read_group(&stream, &group, &RedisConsumerName(String::new()))
.await
.is_err());
assert_eq!(
runner.ack(&stream, &group, &[]).await.expect("empty ack"),
0
);
assert_eq!(runner.delete(&stream, &[]).await.expect("empty delete"), 0);
assert!(runner
.claim_stale(
&stream,
&group,
&consumer,
"",
RedisStreamReclaimConfig::default()
)
.await
.is_err());
let _ = consumer;
}
#[test]
fn parses_reclaim_result_with_deleted_ids() {
let parsed = parse_reclaim_result(RedisValue::Array(vec![
RedisValue::BulkString(b"0-0".to_vec()),
RedisValue::Array(vec![RedisValue::Array(vec![
RedisValue::BulkString(b"1710000000000-0".to_vec()),
RedisValue::Array(vec![
RedisValue::BulkString(b"payload".to_vec()),
RedisValue::BulkString(br#"{"ok":true}"#.to_vec()),
RedisValue::BulkString(b"kind".to_vec()),
RedisValue::BulkString(b"shadow".to_vec()),
]),
])]),
RedisValue::Array(vec![RedisValue::BulkString(b"1709999999999-0".to_vec())]),
]))
.expect("reclaim result should parse");
assert_eq!(
parsed,
RedisStreamReclaimResult {
next_start_id: "0-0".to_string(),
entries: vec![super::RedisStreamEntry {
id: "1710000000000-0".to_string(),
fields: BTreeMap::from([
("kind".to_string(), "shadow".to_string()),
("payload".to_string(), r#"{"ok":true}"#.to_string()),
]),
}],
deleted_ids: vec!["1709999999999-0".to_string()],
}
);
}
#[test]
fn parses_reclaim_result_without_deleted_ids() {
let parsed = parse_reclaim_result(RedisValue::Array(vec![
RedisValue::BulkString(b"0-0".to_vec()),
RedisValue::Array(vec![]),
]))
.expect("reclaim result should parse");
assert_eq!(
parsed,
RedisStreamReclaimResult {
next_start_id: "0-0".to_string(),
entries: Vec::new(),
deleted_ids: Vec::new(),
}
);
}
}

View File

@@ -4,10 +4,6 @@ pub(crate) fn postgres_error(error: impl std::fmt::Display) -> DataLayerError {
DataLayerError::postgres(error)
}
pub(crate) fn redis_error(error: impl std::fmt::Display) -> DataLayerError {
DataLayerError::redis(error)
}
pub(crate) fn sql_error(error: impl std::fmt::Display) -> DataLayerError {
DataLayerError::sql(error)
}
@@ -22,16 +18,6 @@ impl<T> SqlxResultExt<T> for Result<T, sqlx::Error> {
}
}
pub(crate) trait RedisResultExt<T> {
fn map_redis_err(self) -> Result<T, DataLayerError>;
}
impl<T> RedisResultExt<T> for Result<T, redis::RedisError> {
fn map_redis_err(self) -> Result<T, DataLayerError> {
self.map_err(redis_error)
}
}
pub(crate) trait SqlResultExt<T> {
fn map_sql_err(self) -> Result<T, DataLayerError>;
}

View File

@@ -1,6 +1,6 @@
//! Runtime data access for Aether.
//!
//! This crate contains concrete database/Redis clients, repository
//! This crate contains concrete database clients, repository
//! implementations, migration/backfill/export workflows, and the backend
//! composition layer. Shared repository contracts that other crates compile
//! against live in `aether-data-contracts`.
@@ -17,9 +17,8 @@ pub mod maintenance;
pub mod repository;
pub use backend::{
DataBackends, DataLeaseBackends, DataLockBackends, DataReadRepositories,
DataTransactionBackends, DataWorkerBackends, DataWriteRepositories, PostgresBackend,
RedisBackend,
DataBackends, DataLeaseBackends, DataReadRepositories, DataTransactionBackends,
DataWriteRepositories, PostgresBackend,
};
pub use config::DataLayerConfig;
pub use database::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, DEFAULT_SQLITE_DATABASE_URL};