Add multi-database data layer

Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite.

Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers.

Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
fawney19
2026-05-05 18:27:36 +08:00
parent 099653f732
commit fce7e959e5
372 changed files with 86217 additions and 21160 deletions

View File

@@ -0,0 +1,66 @@
use std::fmt;
use super::PostgresBackend;
use crate::driver::postgres::{PostgresLeaseRunner, PostgresLeaseRunnerConfig};
use crate::DataLayerError;
#[derive(Clone, Default)]
pub struct DataLeaseBackends {
postgres: Option<PostgresLeaseRunner>,
}
impl fmt::Debug for DataLeaseBackends {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataLeaseBackends")
.field("has_postgres", &self.postgres.is_some())
.finish()
}
}
impl DataLeaseBackends {
pub(crate) fn from_postgres(
postgres: Option<&PostgresBackend>,
) -> Result<Self, DataLayerError> {
Ok(Self {
postgres: postgres
.map(|backend| backend.lease_runner(PostgresLeaseRunnerConfig::default()))
.transpose()?,
})
}
pub fn postgres(&self) -> Option<PostgresLeaseRunner> {
self.postgres.clone()
}
pub fn has_any(&self) -> bool {
self.postgres.is_some()
}
}
#[cfg(test)]
mod tests {
use super::DataLeaseBackends;
use crate::backend::PostgresBackend;
use crate::driver::postgres::PostgresPoolConfig;
#[tokio::test]
async fn builds_postgres_lease_runner_from_backend() {
let backend = PostgresBackend::from_config(PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
})
.expect("postgres backend should build");
let leases =
DataLeaseBackends::from_postgres(Some(&backend)).expect("lease backends should build");
assert!(leases.has_any());
assert!(leases.postgres().is_some());
}
}

View File

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,474 @@
use super::{
summarize_pool, DataBackends, MysqlBackend, PostgresBackend, SqlBackendRef, SqliteBackend,
};
use crate::error::{SqlResultExt, SqlxResultExt};
use crate::maintenance::{
DatabaseMaintenanceSummary, DatabasePoolSummary, StatsDailyAggregationInput,
StatsDailyAggregationSummary, StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult,
};
use crate::repository::system::{AdminSystemStats, StoredSystemConfigEntry};
use crate::DataLayerError;
use sqlx::migrate::MigrateError;
fn maintenance_identifier(value: &str) -> Result<&str, DataLayerError> {
let valid = !value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
if valid {
Ok(value)
} else {
Err(DataLayerError::InvalidInput(format!(
"invalid maintenance table name: {value}"
)))
}
}
impl DataBackends {
pub fn has_database_maintenance_backend(&self) -> bool {
self.sql_backend().is_some()
}
pub fn has_database_pool_summary(&self) -> bool {
self.sql_backend().is_some()
}
pub fn has_system_config_backend(&self) -> bool {
self.sql_backend().is_some()
}
pub fn has_wallet_daily_usage_aggregation_backend(&self) -> bool {
self.sql_backend().is_some()
}
pub fn has_stats_hourly_aggregation_backend(&self) -> bool {
self.sql_backend().is_some()
}
pub fn has_stats_daily_aggregation_backend(&self) -> bool {
self.sql_backend().is_some()
}
pub async fn run_database_maintenance(
&self,
table_names: &[&str],
) -> Result<DatabaseMaintenanceSummary, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.run_database_maintenance(table_names).await,
None => Ok(DatabaseMaintenanceSummary::default()),
}
}
pub async fn run_database_migrations(&self) -> Result<bool, MigrateError> {
match self.sql_backend() {
Some(backend) => backend.run_database_migrations().await,
None => Ok(false),
}
}
pub async fn run_database_backfills(&self) -> Result<bool, MigrateError> {
match self.sql_backend() {
Some(backend) => backend.run_database_backfills().await,
None => Ok(false),
}
}
pub async fn pending_database_migrations(
&self,
) -> Result<Option<Vec<crate::lifecycle::migrate::PendingMigrationInfo>>, MigrateError> {
match self.sql_backend() {
Some(backend) => backend.pending_database_migrations().await,
None => Ok(None),
}
}
pub async fn prepare_database_for_startup(
&self,
) -> Result<Option<Vec<crate::lifecycle::migrate::PendingMigrationInfo>>, MigrateError> {
match self.sql_backend() {
Some(backend) => backend.prepare_database_for_startup().await,
None => Ok(None),
}
}
pub async fn pending_database_backfills(
&self,
) -> Result<Option<Vec<crate::lifecycle::backfill::PendingBackfillInfo>>, MigrateError> {
match self.sql_backend() {
Some(backend) => backend.pending_database_backfills().await,
None => Ok(None),
}
}
pub fn database_pool_summary(&self) -> Option<DatabasePoolSummary> {
self.sql_backend().map(SqlBackendRef::database_pool_summary)
}
pub async fn aggregate_wallet_daily_usage(
&self,
input: &WalletDailyUsageAggregationInput,
) -> Result<WalletDailyUsageAggregationResult, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.aggregate_wallet_daily_usage(input).await,
None => Ok(WalletDailyUsageAggregationResult::default()),
}
}
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.aggregate_stats_hourly(input).await,
None => Ok(None),
}
}
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.aggregate_stats_daily(input).await,
None => Ok(None),
}
}
pub async fn find_system_config_value(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.find_system_config_value(key).await,
None => Ok(None),
}
}
pub async fn list_system_config_entries(
&self,
) -> Result<Vec<StoredSystemConfigEntry>, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.list_system_config_entries().await,
None => Ok(Vec::new()),
}
}
pub async fn upsert_system_config_entry(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<Option<StoredSystemConfigEntry>, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend
.upsert_system_config_entry(key, value, description)
.await
.map(Some),
None => Ok(None),
}
}
pub async fn delete_system_config_value(&self, key: &str) -> Result<bool, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.delete_system_config_value(key).await,
None => Ok(false),
}
}
pub async fn read_admin_system_stats(&self) -> Result<AdminSystemStats, DataLayerError> {
match self.sql_backend() {
Some(backend) => backend.read_admin_system_stats().await,
None => Ok(AdminSystemStats::default()),
}
}
}
impl PostgresBackend {
pub async fn run_table_maintenance(
&self,
table_names: &[&str],
) -> Result<DatabaseMaintenanceSummary, DataLayerError> {
let mut summary = DatabaseMaintenanceSummary::default();
for table_name in table_names {
let table_name = maintenance_identifier(table_name)?;
summary.attempted += 1;
let statement = format!("VACUUM ANALYZE \"{table_name}\"");
if sqlx::raw_sql(&statement)
.execute(self.pool())
.await
.map_postgres_err()
.is_ok()
{
summary.succeeded += 1;
}
}
Ok(summary)
}
}
impl MysqlBackend {
pub async fn run_table_maintenance(
&self,
table_names: &[&str],
) -> Result<DatabaseMaintenanceSummary, DataLayerError> {
let mut summary = DatabaseMaintenanceSummary::default();
for table_name in table_names {
let table_name = maintenance_identifier(table_name)?;
summary.attempted += 1;
let statement = format!("ANALYZE TABLE `{table_name}`");
if sqlx::raw_sql(&statement)
.execute(self.pool())
.await
.map_sql_err()
.is_ok()
{
summary.succeeded += 1;
}
}
Ok(summary)
}
}
impl SqliteBackend {
pub async fn run_table_maintenance(
&self,
table_names: &[&str],
) -> Result<DatabaseMaintenanceSummary, DataLayerError> {
let mut summary = DatabaseMaintenanceSummary::default();
for table_name in table_names {
let table_name = maintenance_identifier(table_name)?;
summary.attempted += 1;
let statement = format!("ANALYZE \"{table_name}\"");
if sqlx::raw_sql(&statement)
.execute(self.pool())
.await
.map_sql_err()
.is_ok()
{
summary.succeeded += 1;
}
}
if summary.succeeded > 0 {
sqlx::raw_sql("PRAGMA optimize")
.execute(self.pool())
.await
.map_sql_err()?;
}
Ok(summary)
}
}
impl<'a> SqlBackendRef<'a> {
async fn run_database_maintenance(
self,
table_names: &[&str],
) -> Result<DatabaseMaintenanceSummary, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.run_table_maintenance(table_names).await,
Self::Mysql(mysql) => mysql.run_table_maintenance(table_names).await,
Self::Sqlite(sqlite) => sqlite.run_table_maintenance(table_names).await,
}
}
async fn run_database_migrations(self) -> Result<bool, MigrateError> {
match self {
Self::Postgres(postgres) => {
crate::lifecycle::migrate::run_migrations(postgres.pool()).await?;
Ok(true)
}
Self::Mysql(mysql) => {
crate::lifecycle::migrate::run_mysql_migrations(mysql.pool()).await?;
Ok(true)
}
Self::Sqlite(sqlite) => {
crate::lifecycle::migrate::run_sqlite_migrations(sqlite.pool()).await?;
Ok(true)
}
}
}
async fn run_database_backfills(self) -> Result<bool, MigrateError> {
match self {
Self::Postgres(postgres) => {
crate::lifecycle::backfill::run_backfills(postgres.pool()).await?;
Ok(true)
}
Self::Mysql(mysql) => {
crate::lifecycle::backfill::run_mysql_backfills(mysql.pool()).await?;
Ok(true)
}
Self::Sqlite(sqlite) => {
crate::lifecycle::backfill::run_sqlite_backfills(sqlite.pool()).await?;
Ok(true)
}
}
}
async fn pending_database_migrations(
self,
) -> Result<Option<Vec<crate::lifecycle::migrate::PendingMigrationInfo>>, MigrateError> {
match self {
Self::Postgres(postgres) => Ok(Some(
crate::lifecycle::migrate::pending_migrations(postgres.pool()).await?,
)),
Self::Mysql(mysql) => Ok(Some(
crate::lifecycle::migrate::pending_mysql_migrations(mysql.pool()).await?,
)),
Self::Sqlite(sqlite) => Ok(Some(
crate::lifecycle::migrate::pending_sqlite_migrations(sqlite.pool()).await?,
)),
}
}
async fn prepare_database_for_startup(
self,
) -> Result<Option<Vec<crate::lifecycle::migrate::PendingMigrationInfo>>, MigrateError> {
match self {
Self::Postgres(postgres) => Ok(Some(
crate::lifecycle::migrate::prepare_database_for_startup(postgres.pool()).await?,
)),
Self::Mysql(mysql) => Ok(Some(
crate::lifecycle::migrate::prepare_mysql_database_for_startup(mysql.pool()).await?,
)),
Self::Sqlite(sqlite) => Ok(Some(
crate::lifecycle::migrate::prepare_sqlite_database_for_startup(sqlite.pool())
.await?,
)),
}
}
async fn pending_database_backfills(
self,
) -> Result<Option<Vec<crate::lifecycle::backfill::PendingBackfillInfo>>, MigrateError> {
match self {
Self::Postgres(postgres) => Ok(Some(
crate::lifecycle::backfill::pending_backfills(postgres.pool()).await?,
)),
Self::Mysql(mysql) => Ok(Some(
crate::lifecycle::backfill::pending_mysql_backfills(mysql.pool()).await?,
)),
Self::Sqlite(sqlite) => Ok(Some(
crate::lifecycle::backfill::pending_sqlite_backfills(sqlite.pool()).await?,
)),
}
}
fn database_pool_summary(self) -> DatabasePoolSummary {
match self {
Self::Postgres(postgres) => summarize_pool(
crate::database::DatabaseDriver::Postgres,
usize::try_from(postgres.pool().size()).unwrap_or(usize::MAX),
postgres.pool().num_idle(),
postgres.config().max_connections,
),
Self::Mysql(mysql) => summarize_pool(
crate::database::DatabaseDriver::Mysql,
usize::try_from(mysql.pool().size()).unwrap_or(usize::MAX),
mysql.pool().num_idle(),
mysql.config().pool.max_connections,
),
Self::Sqlite(sqlite) => summarize_pool(
crate::database::DatabaseDriver::Sqlite,
usize::try_from(sqlite.pool().size()).unwrap_or(usize::MAX),
sqlite.pool().num_idle(),
sqlite.config().pool.max_connections,
),
}
}
async fn aggregate_wallet_daily_usage(
self,
input: &WalletDailyUsageAggregationInput,
) -> Result<WalletDailyUsageAggregationResult, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.aggregate_wallet_daily_usage(input).await,
Self::Mysql(mysql) => mysql.aggregate_wallet_daily_usage(input).await,
Self::Sqlite(sqlite) => sqlite.aggregate_wallet_daily_usage(input).await,
}
}
async fn aggregate_stats_hourly(
self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.aggregate_stats_hourly(input).await,
Self::Mysql(mysql) => mysql.aggregate_stats_hourly(input).await,
Self::Sqlite(sqlite) => sqlite.aggregate_stats_hourly(input).await,
}
}
async fn aggregate_stats_daily(
self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.aggregate_stats_daily(input).await,
Self::Mysql(mysql) => mysql.aggregate_stats_daily(input).await,
Self::Sqlite(sqlite) => sqlite.aggregate_stats_daily(input).await,
}
}
async fn find_system_config_value(
self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.find_system_config_value(key).await,
Self::Mysql(mysql) => mysql.find_system_config_value(key).await,
Self::Sqlite(sqlite) => sqlite.find_system_config_value(key).await,
}
}
async fn list_system_config_entries(
self,
) -> Result<Vec<StoredSystemConfigEntry>, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.list_system_config_entries().await,
Self::Mysql(mysql) => mysql.list_system_config_entries().await,
Self::Sqlite(sqlite) => sqlite.list_system_config_entries().await,
}
}
async fn upsert_system_config_entry(
self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<StoredSystemConfigEntry, DataLayerError> {
match self {
Self::Postgres(postgres) => {
postgres
.upsert_system_config_entry(key, value, description)
.await
}
Self::Mysql(mysql) => {
mysql
.upsert_system_config_entry(key, value, description)
.await
}
Self::Sqlite(sqlite) => {
sqlite
.upsert_system_config_entry(key, value, description)
.await
}
}
}
async fn delete_system_config_value(self, key: &str) -> Result<bool, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.delete_system_config_value(key).await,
Self::Mysql(mysql) => mysql.delete_system_config_value(key).await,
Self::Sqlite(sqlite) => sqlite.delete_system_config_value(key).await,
}
}
async fn read_admin_system_stats(self) -> Result<AdminSystemStats, DataLayerError> {
match self {
Self::Postgres(postgres) => postgres.read_admin_system_stats().await,
Self::Mysql(mysql) => mysql.read_admin_system_stats().await,
Self::Sqlite(sqlite) => sqlite.read_admin_system_stats().await,
}
}
}

View File

@@ -0,0 +1,434 @@
//! Backend composition layer.
//!
//! `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
//! 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;
use crate::{DataLayerConfig, DataLayerError};
#[derive(Clone, Copy)]
enum SqlBackendRef<'a> {
Postgres(&'a PostgresBackend),
Mysql(&'a MysqlBackend),
Sqlite(&'a SqliteBackend),
}
#[derive(Debug, Clone, Default)]
pub struct DataBackends {
config: DataLayerConfig,
postgres: Option<PostgresBackend>,
mysql: Option<MysqlBackend>,
sqlite: Option<SqliteBackend>,
redis: Option<RedisBackend>,
leases: DataLeaseBackends,
locks: DataLockBackends,
read: DataReadRepositories,
transactions: DataTransactionBackends,
workers: DataWorkerBackends,
write: DataWriteRepositories,
}
fn summarize_pool(
driver: DatabaseDriver,
pool_size: usize,
idle: usize,
max_connections: u32,
) -> DatabasePoolSummary {
let max_connections = max_connections.max(1);
let checked_out = pool_size.saturating_sub(idle);
let usage_rate = checked_out as f64 / f64::from(max_connections) * 100.0;
DatabasePoolSummary {
driver,
checked_out,
pool_size,
idle,
max_connections,
usage_rate,
}
}
impl DataBackends {
fn sql_backend(&self) -> Option<SqlBackendRef<'_>> {
self.postgres
.as_ref()
.map(SqlBackendRef::Postgres)
.or_else(|| self.mysql.as_ref().map(SqlBackendRef::Mysql))
.or_else(|| self.sqlite.as_ref().map(SqlBackendRef::Sqlite))
}
pub fn from_config(config: DataLayerConfig) -> Result<Self, DataLayerError> {
config.validate()?;
let database = config.effective_database();
let postgres = match database.clone() {
Some(database) if database.driver == DatabaseDriver::Postgres => Some(
PostgresBackend::from_config(database.to_postgres_config()?)?,
),
_ => None,
};
let mysql = match database.clone() {
Some(database) if database.driver == DatabaseDriver::Mysql => {
Some(MysqlBackend::from_config(database)?)
}
_ => None,
};
let sqlite = match database {
Some(database) if database.driver == DatabaseDriver::Sqlite => {
Some(SqliteBackend::from_config(database)?)
}
_ => 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(),
sqlite.as_ref(),
);
Ok(Self {
config,
postgres,
mysql,
sqlite,
redis,
leases,
locks,
read,
transactions,
workers,
write,
})
}
pub fn config(&self) -> &DataLayerConfig {
&self.config
}
pub fn postgres(&self) -> Option<&PostgresBackend> {
self.postgres.as_ref()
}
pub fn database_driver(&self) -> Option<DatabaseDriver> {
self.config
.effective_database()
.map(|database| database.driver)
}
pub fn mysql(&self) -> Option<&MysqlBackend> {
self.mysql.as_ref()
}
pub fn sqlite(&self) -> Option<&SqliteBackend> {
self.sqlite.as_ref()
}
pub fn redis(&self) -> Option<&RedisBackend> {
self.redis.as_ref()
}
pub fn read(&self) -> &DataReadRepositories {
&self.read
}
pub fn leases(&self) -> &DataLeaseBackends {
&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
}
pub fn has_runtime_backends(&self) -> bool {
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()
}
}
#[cfg(test)]
mod tests {
use super::DataBackends;
use crate::{
driver::postgres::PostgresPoolConfig, DataLayerConfig, DatabaseDriver, SqlDatabaseConfig,
SqlPoolConfig,
};
#[test]
fn builds_empty_backends_from_default_config() {
let backends = DataBackends::from_config(DataLayerConfig::default())
.expect("empty config should be accepted");
assert!(!backends.has_runtime_backends());
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());
assert!(backends.read().gemini_file_mappings().is_none());
assert!(backends.read().global_models().is_none());
assert!(backends.read().management_tokens().is_none());
assert!(backends.read().oauth_providers().is_none());
assert!(backends.read().proxy_nodes().is_none());
assert!(backends.read().minimal_candidate_selection().is_none());
assert!(backends.read().request_candidates().is_none());
assert!(backends.read().provider_catalog().is_none());
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());
}
#[tokio::test]
async fn builds_postgres_backend_from_config() {
let backends = DataBackends::from_config(DataLayerConfig {
database: None,
postgres: Some(PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
}),
redis: None,
})
.expect("postgres backend should build");
assert!(backends.has_runtime_backends());
assert!(backends.postgres().is_some());
assert!(backends.mysql().is_none());
assert!(backends.sqlite().is_none());
assert!(backends.leases().postgres().is_some());
assert!(backends.read().auth_api_keys().is_some());
assert!(backends.read().auth_modules().is_some());
assert!(backends.read().billing().is_some());
assert!(backends.read().gemini_file_mappings().is_some());
assert!(backends.read().global_models().is_some());
assert!(backends.read().management_tokens().is_some());
assert!(backends.read().minimal_candidate_selection().is_some());
assert!(backends.read().oauth_providers().is_some());
assert!(backends.read().proxy_nodes().is_some());
assert!(backends.read().minimal_candidate_selection().is_some());
assert!(backends.read().request_candidates().is_some());
assert!(backends.read().provider_catalog().is_some());
assert!(backends.read().provider_quotas().is_some());
assert!(backends.read().usage().is_some());
assert!(backends.read().video_tasks().is_some());
assert!(backends.read().wallets().is_some());
assert!(backends.transactions().postgres().is_some());
assert!(backends.write().auth_modules().is_some());
assert!(backends.write().gemini_file_mappings().is_some());
assert!(backends.write().management_tokens().is_some());
assert!(backends.write().oauth_providers().is_some());
assert!(backends.write().proxy_nodes().is_some());
assert!(backends.write().provider_catalog().is_some());
assert!(backends.write().provider_quotas().is_some());
assert!(backends.write().settlement().is_some());
assert!(backends.write().usage().is_some());
assert!(backends.write().wallets().is_some());
assert!(backends.config().effective_database().is_some());
}
#[tokio::test]
async fn builds_mysql_backend_from_database_config_with_first_core_repository() {
let backends = DataBackends::from_config(DataLayerConfig {
database: Some(SqlDatabaseConfig {
driver: DatabaseDriver::Mysql,
url: "mysql://user:pass@localhost:3306/aether".to_string(),
pool: SqlPoolConfig::default(),
}),
postgres: None,
redis: None,
})
.expect("mysql backend should build");
assert!(backends.has_runtime_backends());
assert!(backends.postgres().is_none());
assert!(backends.mysql().is_some());
assert!(backends.sqlite().is_none());
assert!(backends.read().has_any());
assert!(backends.read().announcements().is_some());
assert!(backends.read().auth_api_keys().is_some());
assert!(backends.read().auth_modules().is_some());
assert!(backends.read().billing().is_some());
assert!(backends.read().gemini_file_mappings().is_some());
assert!(backends.read().global_models().is_some());
assert!(backends.read().management_tokens().is_some());
assert!(backends.read().minimal_candidate_selection().is_some());
assert!(backends.read().oauth_providers().is_some());
assert!(backends.read().provider_catalog().is_some());
assert!(backends.read().provider_quotas().is_some());
assert!(backends.read().proxy_nodes().is_some());
assert!(backends.read().request_candidates().is_some());
assert!(backends.read().users().is_some());
assert!(backends.read().video_tasks().is_some());
assert!(backends.has_stats_hourly_aggregation_backend());
assert!(backends.has_stats_daily_aggregation_backend());
assert!(backends.write().has_any());
assert!(backends.write().announcements().is_some());
assert!(backends.write().auth_api_keys().is_some());
assert!(backends.write().auth_modules().is_some());
assert!(backends.write().gemini_file_mappings().is_some());
assert!(backends.write().global_models().is_some());
assert!(backends.write().management_tokens().is_some());
assert!(backends.write().oauth_providers().is_some());
assert!(backends.write().proxy_nodes().is_some());
assert!(backends.write().provider_catalog().is_some());
assert!(backends.write().provider_quotas().is_some());
assert!(backends.write().request_candidates().is_some());
assert!(backends.write().video_tasks().is_some());
assert!(backends.write().wallets().is_some());
assert!(backends.config().effective_database().is_some());
}
#[tokio::test]
async fn builds_sqlite_backend_from_database_config_with_first_core_repository() {
let backends = DataBackends::from_config(DataLayerConfig {
database: Some(SqlDatabaseConfig {
driver: DatabaseDriver::Sqlite,
url: "sqlite://./data/aether.db".to_string(),
pool: SqlPoolConfig::default(),
}),
postgres: None,
redis: None,
})
.expect("sqlite backend should build");
assert!(backends.has_runtime_backends());
assert!(backends.postgres().is_none());
assert!(backends.mysql().is_none());
assert!(backends.sqlite().is_some());
assert!(backends.read().has_any());
assert!(backends.read().announcements().is_some());
assert!(backends.read().auth_api_keys().is_some());
assert!(backends.read().auth_modules().is_some());
assert!(backends.read().billing().is_some());
assert!(backends.read().gemini_file_mappings().is_some());
assert!(backends.read().global_models().is_some());
assert!(backends.read().management_tokens().is_some());
assert!(backends.read().oauth_providers().is_some());
assert!(backends.read().provider_catalog().is_some());
assert!(backends.read().provider_quotas().is_some());
assert!(backends.read().proxy_nodes().is_some());
assert!(backends.read().request_candidates().is_some());
assert!(backends.read().users().is_some());
assert!(backends.read().video_tasks().is_some());
assert!(backends.has_stats_hourly_aggregation_backend());
assert!(backends.has_stats_daily_aggregation_backend());
assert!(backends.write().has_any());
assert!(backends.write().announcements().is_some());
assert!(backends.write().auth_api_keys().is_some());
assert!(backends.write().auth_modules().is_some());
assert!(backends.write().gemini_file_mappings().is_some());
assert!(backends.write().global_models().is_some());
assert!(backends.write().management_tokens().is_some());
assert!(backends.write().oauth_providers().is_some());
assert!(backends.write().proxy_nodes().is_some());
assert!(backends.write().provider_catalog().is_some());
assert!(backends.write().provider_quotas().is_some());
assert!(backends.write().request_candidates().is_some());
assert!(backends.write().video_tasks().is_some());
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

@@ -0,0 +1,562 @@
use std::sync::Arc;
use crate::database::SqlDatabaseConfig;
use crate::driver::mysql::{MysqlPool, MysqlPoolFactory};
use crate::repository::announcements::{
AnnouncementReadRepository, AnnouncementWriteRepository, MysqlAnnouncementRepository,
};
use crate::repository::audit::{AuditLogReadRepository, MysqlAuditLogReadRepository};
use crate::repository::auth::{
AuthApiKeyReadRepository, AuthApiKeyWriteRepository, MysqlAuthApiKeyReadRepository,
};
use crate::repository::auth_modules::{
AuthModuleReadRepository, AuthModuleWriteRepository, MysqlAuthModuleReadRepository,
MysqlAuthModuleRepository,
};
use crate::repository::billing::{BillingReadRepository, MysqlBillingReadRepository};
use crate::repository::candidate_selection::{
MinimalCandidateSelectionReadRepository, MysqlMinimalCandidateSelectionReadRepository,
};
use crate::repository::candidates::{
MysqlRequestCandidateRepository, RequestCandidateReadRepository,
RequestCandidateWriteRepository,
};
use crate::repository::gemini_file_mappings::{
GeminiFileMappingReadRepository, GeminiFileMappingWriteRepository,
MysqlGeminiFileMappingRepository,
};
use crate::repository::global_models::{
GlobalModelReadRepository, GlobalModelWriteRepository, MysqlGlobalModelReadRepository,
};
use crate::repository::management_tokens::{
ManagementTokenReadRepository, ManagementTokenWriteRepository, MysqlManagementTokenRepository,
};
use crate::repository::oauth_providers::{
MysqlOAuthProviderRepository, OAuthProviderReadRepository, OAuthProviderWriteRepository,
};
use crate::repository::provider_catalog::{
MysqlProviderCatalogReadRepository, ProviderCatalogReadRepository,
ProviderCatalogWriteRepository,
};
use crate::repository::proxy_nodes::{
MysqlProxyNodeReadRepository, ProxyNodeReadRepository, ProxyNodeWriteRepository,
};
use crate::repository::quota::{
MysqlProviderQuotaRepository, ProviderQuotaReadRepository, ProviderQuotaWriteRepository,
};
use crate::repository::settlement::{MysqlSettlementRepository, SettlementWriteRepository};
use crate::repository::usage::{
MysqlUsageReadRepository, MysqlUsageWriteRepository, UsageReadRepository, UsageWriteRepository,
};
use crate::repository::users::{MysqlUserReadRepository, UserReadRepository};
use crate::repository::video_tasks::{
MysqlVideoTaskRepository, VideoTaskReadRepository, VideoTaskWriteRepository,
};
use crate::repository::wallet::{
MysqlWalletReadRepository, WalletReadRepository, WalletWriteRepository,
};
use crate::DataLayerError;
#[derive(Debug, Clone)]
pub struct MysqlBackend {
config: SqlDatabaseConfig,
pool: MysqlPool,
}
impl MysqlBackend {
pub fn from_config(config: SqlDatabaseConfig) -> Result<Self, DataLayerError> {
let factory = MysqlPoolFactory::new(config.clone())?;
let pool = factory.connect_lazy()?;
Ok(Self { config, pool })
}
pub fn config(&self) -> &SqlDatabaseConfig {
&self.config
}
pub fn pool(&self) -> &MysqlPool {
&self.pool
}
pub fn pool_clone(&self) -> MysqlPool {
self.pool.clone()
}
pub fn auth_api_key_read_repository(&self) -> Arc<dyn AuthApiKeyReadRepository> {
Arc::new(MysqlAuthApiKeyReadRepository::new(self.pool_clone()))
}
pub fn announcement_read_repository(&self) -> Arc<dyn AnnouncementReadRepository> {
Arc::new(MysqlAnnouncementRepository::new(self.pool_clone()))
}
pub fn audit_log_read_repository(&self) -> Arc<dyn AuditLogReadRepository> {
Arc::new(MysqlAuditLogReadRepository::new(self.pool_clone()))
}
pub fn announcement_write_repository(&self) -> Arc<dyn AnnouncementWriteRepository> {
Arc::new(MysqlAnnouncementRepository::new(self.pool_clone()))
}
pub fn auth_api_key_write_repository(&self) -> Arc<dyn AuthApiKeyWriteRepository> {
Arc::new(MysqlAuthApiKeyReadRepository::new(self.pool_clone()))
}
pub fn management_token_read_repository(&self) -> Arc<dyn ManagementTokenReadRepository> {
Arc::new(MysqlManagementTokenRepository::new(self.pool_clone()))
}
pub fn management_token_write_repository(&self) -> Arc<dyn ManagementTokenWriteRepository> {
Arc::new(MysqlManagementTokenRepository::new(self.pool_clone()))
}
pub fn auth_module_read_repository(&self) -> Arc<dyn AuthModuleReadRepository> {
Arc::new(MysqlAuthModuleReadRepository::new(self.pool_clone()))
}
pub fn auth_module_write_repository(&self) -> Arc<dyn AuthModuleWriteRepository> {
Arc::new(MysqlAuthModuleRepository::new(self.pool_clone()))
}
pub fn billing_read_repository(&self) -> Arc<dyn BillingReadRepository> {
Arc::new(MysqlBillingReadRepository::new(self.pool_clone()))
}
pub fn request_candidate_read_repository(&self) -> Arc<dyn RequestCandidateReadRepository> {
Arc::new(MysqlRequestCandidateRepository::new(self.pool_clone()))
}
pub fn request_candidate_write_repository(&self) -> Arc<dyn RequestCandidateWriteRepository> {
Arc::new(MysqlRequestCandidateRepository::new(self.pool_clone()))
}
pub fn minimal_candidate_selection_read_repository(
&self,
) -> Arc<dyn MinimalCandidateSelectionReadRepository> {
Arc::new(MysqlMinimalCandidateSelectionReadRepository::new(
self.pool_clone(),
))
}
pub fn gemini_file_mapping_read_repository(&self) -> Arc<dyn GeminiFileMappingReadRepository> {
Arc::new(MysqlGeminiFileMappingRepository::new(self.pool_clone()))
}
pub fn gemini_file_mapping_write_repository(
&self,
) -> Arc<dyn GeminiFileMappingWriteRepository> {
Arc::new(MysqlGeminiFileMappingRepository::new(self.pool_clone()))
}
pub fn global_model_read_repository(&self) -> Arc<dyn GlobalModelReadRepository> {
Arc::new(MysqlGlobalModelReadRepository::new(self.pool_clone()))
}
pub fn global_model_write_repository(&self) -> Arc<dyn GlobalModelWriteRepository> {
Arc::new(MysqlGlobalModelReadRepository::new(self.pool_clone()))
}
pub fn oauth_provider_read_repository(&self) -> Arc<dyn OAuthProviderReadRepository> {
Arc::new(MysqlOAuthProviderRepository::new(self.pool_clone()))
}
pub fn oauth_provider_write_repository(&self) -> Arc<dyn OAuthProviderWriteRepository> {
Arc::new(MysqlOAuthProviderRepository::new(self.pool_clone()))
}
pub fn provider_catalog_read_repository(&self) -> Arc<dyn ProviderCatalogReadRepository> {
Arc::new(MysqlProviderCatalogReadRepository::new(self.pool_clone()))
}
pub fn provider_catalog_write_repository(&self) -> Arc<dyn ProviderCatalogWriteRepository> {
Arc::new(MysqlProviderCatalogReadRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(MysqlProxyNodeReadRepository::new(self.pool_clone()))
}
pub fn proxy_node_write_repository(&self) -> Arc<dyn ProxyNodeWriteRepository> {
Arc::new(MysqlProxyNodeReadRepository::new(self.pool_clone()))
}
pub fn provider_quota_read_repository(&self) -> Arc<dyn ProviderQuotaReadRepository> {
Arc::new(MysqlProviderQuotaRepository::new(self.pool_clone()))
}
pub fn provider_quota_write_repository(&self) -> Arc<dyn ProviderQuotaWriteRepository> {
Arc::new(MysqlProviderQuotaRepository::new(self.pool_clone()))
}
pub fn settlement_write_repository(&self) -> Arc<dyn SettlementWriteRepository> {
Arc::new(MysqlSettlementRepository::new(self.pool_clone()))
}
pub fn usage_write_repository(&self) -> Arc<dyn UsageWriteRepository> {
Arc::new(MysqlUsageWriteRepository::new(self.pool_clone()))
}
pub fn usage_read_repository(&self) -> Arc<dyn UsageReadRepository> {
Arc::new(MysqlUsageReadRepository::new(self.pool_clone()))
}
pub fn user_read_repository(&self) -> Arc<dyn UserReadRepository> {
Arc::new(MysqlUserReadRepository::new(self.pool_clone()))
}
pub fn video_task_read_repository(&self) -> Arc<dyn VideoTaskReadRepository> {
Arc::new(MysqlVideoTaskRepository::new(self.pool_clone()))
}
pub fn video_task_write_repository(&self) -> Arc<dyn VideoTaskWriteRepository> {
Arc::new(MysqlVideoTaskRepository::new(self.pool_clone()))
}
pub fn wallet_read_repository(&self) -> Arc<dyn WalletReadRepository> {
Arc::new(MysqlWalletReadRepository::new(self.pool_clone()))
}
pub fn wallet_write_repository(&self) -> Arc<dyn WalletWriteRepository> {
Arc::new(MysqlWalletReadRepository::new(self.pool_clone()))
}
}
#[cfg(test)]
mod tests {
use super::MysqlBackend;
use crate::lifecycle::migrate::run_mysql_migrations;
use crate::{
DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, StatsDailyAggregationInput,
StatsHourlyAggregationInput, WalletDailyUsageAggregationInput,
};
#[tokio::test]
async fn backend_retains_config_and_pool() {
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Mysql,
url: "mysql://user:pass@localhost:3306/aether".to_string(),
pool: SqlPoolConfig::default(),
};
let backend = MysqlBackend::from_config(config.clone()).expect("backend should build");
assert_eq!(backend.config(), &config);
let _pool = backend.pool();
let _pool_clone = backend.pool_clone();
}
#[tokio::test]
async fn mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set() {
let Some(database_url) = std::env::var("AETHER_TEST_MYSQL_URL")
.ok()
.filter(|value| !value.trim().is_empty())
else {
eprintln!(
"skipping mysql wallet daily usage aggregation smoke test because AETHER_TEST_MYSQL_URL is unset"
);
return;
};
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Mysql,
url: database_url,
pool: SqlPoolConfig {
max_connections: 1,
..SqlPoolConfig::default()
},
};
let backend = MysqlBackend::from_config(config).expect("backend should build");
run_mysql_migrations(backend.pool())
.await
.expect("mysql migrations should run");
let suffix = format!(
"{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
let wallet_id = format!("wallet-daily-{suffix}");
let stale_wallet_id = format!("wallet-daily-stale-{suffix}");
let timezone = format!("Test/WalletDaily/{suffix}");
let request_one = format!("request-daily-1-{suffix}");
let request_two = format!("request-daily-2-{suffix}");
let request_zero = format!("request-daily-zero-{suffix}");
let request_outside = format!("request-daily-outside-{suffix}");
let stale_ledger_id = format!("stale-ledger-{suffix}");
sqlx::query(
r#"
INSERT INTO wallets (id, user_id, balance, gift_balance, limit_mode, created_at, updated_at)
VALUES
(?, ?, 10.0, 2.0, 'finite', 1, 1),
(?, ?, 0.0, 0.0, 'finite', 1, 1)
"#,
)
.bind(&wallet_id)
.bind(format!("user-{wallet_id}"))
.bind(&stale_wallet_id)
.bind(format!("user-{stale_wallet_id}"))
.execute(backend.pool())
.await
.expect("wallets should seed");
sqlx::query(
r#"
INSERT INTO `usage` (
request_id, wallet_id, provider_name, model, status, billing_status,
total_cost_usd, input_tokens, output_tokens, cache_creation_input_tokens,
cache_read_input_tokens, finalized_at, created_at_unix_ms, updated_at_unix_secs
) VALUES
(?, 'wrong-wallet', 'provider', 'model', 'completed', 'pending',
1.25, 10, 20, 3, 4, 4099999900, 4099999900000, 4099999900),
(?, NULL, 'provider', 'model', 'completed', 'pending',
2.00, 5, 7, 1, 2, 4099999901, 4099999901000, 4099999901),
(?, NULL, 'provider', 'model', 'completed', 'pending',
0.00, 100, 100, 0, 0, 4099999902, 4099999902000, 4099999902),
(?, NULL, 'provider', 'model', 'completed', 'pending',
9.00, 50, 50, 0, 0, 4099999903, 4099999903000, 4099999903)
"#,
)
.bind(&request_one)
.bind(&request_two)
.bind(&request_zero)
.bind(&request_outside)
.execute(backend.pool())
.await
.expect("usage should seed");
sqlx::query(
r#"
INSERT INTO usage_settlement_snapshots (
request_id, billing_status, wallet_id, finalized_at, created_at, updated_at
) VALUES
(?, 'settled', ?, 4100000000, 4100000000, 4100000000),
(?, 'settled', ?, 4100000100, 4100000100, 4100000100),
(?, 'settled', ?, 4100000150, 4100000150, 4100000150),
(?, 'settled', ?, 4100000200, 4100000200, 4100000200)
"#,
)
.bind(&request_one)
.bind(&wallet_id)
.bind(&request_two)
.bind(&wallet_id)
.bind(&request_zero)
.bind(&wallet_id)
.bind(&request_outside)
.bind(&wallet_id)
.execute(backend.pool())
.await
.expect("settlement snapshots should seed");
sqlx::query(
r#"
INSERT INTO wallet_daily_usage_ledgers (
id, wallet_id, billing_date, billing_timezone, total_cost_usd,
total_requests, input_tokens, output_tokens, cache_creation_tokens,
cache_read_tokens, aggregated_at, created_at, updated_at
) VALUES (?, ?, '2026-05-03', ?, 7.0, 3, 1, 1, 0, 0, 4099999999, 4099999999, 4099999999)
"#,
)
.bind(&stale_ledger_id)
.bind(&stale_wallet_id)
.bind(&timezone)
.execute(backend.pool())
.await
.expect("stale ledger should seed");
let summary = backend
.aggregate_wallet_daily_usage(&WalletDailyUsageAggregationInput {
billing_date: "2026-05-03".to_string(),
billing_timezone: timezone.clone(),
window_start_unix_secs: 4_100_000_000,
window_end_unix_secs: 4_100_000_200,
aggregated_at_unix_secs: 4_100_000_300,
})
.await
.expect("wallet daily usage aggregation should run");
assert_eq!(summary.aggregated_wallets, 1);
assert_eq!(summary.deleted_stale_ledgers, 1);
let ledger = sqlx::query_as::<
_,
(
String,
f64,
i64,
i64,
i64,
i64,
i64,
Option<i64>,
Option<i64>,
i64,
),
>(
r#"
SELECT
wallet_id,
total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
first_finalized_at,
last_finalized_at,
aggregated_at
FROM wallet_daily_usage_ledgers
WHERE wallet_id = ?
AND billing_date = '2026-05-03'
AND billing_timezone = ?
"#,
)
.bind(&wallet_id)
.bind(&timezone)
.fetch_one(backend.pool())
.await
.expect("aggregated ledger should load");
assert_eq!(ledger.0, wallet_id);
assert!((ledger.1 - 3.25).abs() < f64::EPSILON);
assert_eq!(ledger.2, 2);
assert_eq!(ledger.3, 15);
assert_eq!(ledger.4, 27);
assert_eq!(ledger.5, 4);
assert_eq!(ledger.6, 6);
assert_eq!(ledger.7, Some(4_100_000_000));
assert_eq!(ledger.8, Some(4_100_000_100));
assert_eq!(ledger.9, 4_100_000_300);
let stale_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_daily_usage_ledgers WHERE id = ?")
.bind(&stale_ledger_id)
.fetch_one(backend.pool())
.await
.expect("stale ledger count should load");
assert_eq!(stale_count, 0);
}
#[tokio::test]
async fn mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set() {
let Some(database_url) = std::env::var("AETHER_TEST_MYSQL_URL")
.ok()
.filter(|value| !value.trim().is_empty())
else {
eprintln!(
"skipping mysql stats aggregation smoke test because AETHER_TEST_MYSQL_URL is unset"
);
return;
};
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Mysql,
url: database_url,
pool: SqlPoolConfig {
max_connections: 1,
..SqlPoolConfig::default()
},
};
let backend = MysqlBackend::from_config(config).expect("backend should build");
run_mysql_migrations(backend.pool())
.await
.expect("mysql migrations should run");
sqlx::query(
r#"
INSERT INTO `usage` (
request_id, user_id, api_key_id, provider_name, model, status, billing_status,
status_code, error_category, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens, total_cost_usd,
actual_total_cost_usd, response_time_ms, created_at_unix_ms, updated_at_unix_secs
) VALUES
('stats-1', 'user-1', 'key-1', 'provider-a', 'model-a', 'completed', 'settled',
200, NULL, 10, 20, 1, 2, 0.30, 0.25, 100, 3600000, 3600),
('stats-2', 'user-2', 'key-2', 'provider-b', 'model-b', 'failed', 'void',
500, 'upstream_error', 5, 7, 0, 1, 0.20, 0.20, 300, 3610000, 3610),
('stats-pending', 'user-3', 'key-3', 'provider-a', 'model-a', 'pending', 'pending',
NULL, NULL, 100, 100, 0, 0, 9.99, 9.99, 50, 3620000, 3620),
('stats-unknown-provider', 'user-4', 'key-4', 'unknown', 'model-a', 'completed', 'settled',
200, NULL, 100, 100, 0, 0, 9.99, 9.99, 50, 3630000, 3630)
"#,
)
.execute(backend.pool())
.await
.expect("usage stats rows should seed");
let target_hour = chrono::DateTime::<chrono::Utc>::from_timestamp(3600, 0)
.expect("target hour should be valid");
let aggregated_at = chrono::DateTime::<chrono::Utc>::from_timestamp(7200, 0)
.expect("aggregation time should be valid");
let hourly = backend
.aggregate_stats_hourly(&StatsHourlyAggregationInput {
target_hour_utc: target_hour,
aggregated_at,
})
.await
.expect("hourly stats aggregation should run")
.expect("hourly bucket should aggregate");
assert_eq!(hourly.hour_utc, target_hour);
assert_eq!(hourly.total_requests, 2);
assert_eq!(hourly.user_rows, 2);
assert_eq!(hourly.user_model_rows, 2);
assert_eq!(hourly.model_rows, 2);
assert_eq!(hourly.provider_rows, 2);
let hourly_row = sqlx::query_as::<_, (i64, i64, i64, i64, f64)>(
r#"
SELECT total_requests, success_requests, error_requests, input_tokens, total_cost
FROM stats_hourly
WHERE hour_utc = 3600
"#,
)
.fetch_one(backend.pool())
.await
.expect("hourly stats row should load");
assert_eq!(hourly_row.0, 2);
assert_eq!(hourly_row.1, 1);
assert_eq!(hourly_row.2, 1);
assert_eq!(hourly_row.3, 15);
assert!((hourly_row.4 - 0.50).abs() < f64::EPSILON);
let second_hourly = backend
.aggregate_stats_hourly(&StatsHourlyAggregationInput {
target_hour_utc: target_hour,
aggregated_at,
})
.await
.expect("second hourly aggregation should run");
assert!(second_hourly.is_none());
let target_day = chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0)
.expect("target day should be valid");
let daily = backend
.aggregate_stats_daily(&StatsDailyAggregationInput {
target_day_utc: target_day,
aggregated_at,
})
.await
.expect("daily stats aggregation should run")
.expect("daily bucket should aggregate");
assert_eq!(daily.day_start_utc, target_day);
assert_eq!(daily.total_requests, 2);
assert_eq!(daily.model_rows, 2);
assert_eq!(daily.provider_rows, 2);
assert_eq!(daily.api_key_rows, 2);
assert_eq!(daily.error_rows, 1);
assert_eq!(daily.user_rows, 2);
let daily_row = sqlx::query_as::<_, (i64, i64, i64, i64)>(
r#"
SELECT total_requests, success_requests, error_requests, unique_models
FROM stats_daily
WHERE `date` = 0
"#,
)
.fetch_one(backend.pool())
.await
.expect("daily stats row should load");
assert_eq!(daily_row, (2, 1, 1, 2));
}
}

View File

@@ -0,0 +1,297 @@
use std::sync::Arc;
use crate::driver::postgres::{
PostgresLeaseRunner, PostgresLeaseRunnerConfig, PostgresPool, PostgresPoolConfig,
PostgresPoolFactory, PostgresTransactionRunner,
};
use crate::repository::announcements::{
AnnouncementReadRepository, AnnouncementWriteRepository, SqlxAnnouncementReadRepository,
};
use crate::repository::audit::{AuditLogReadRepository, PostgresAuditLogReadRepository};
use crate::repository::auth::{
AuthApiKeyReadRepository, AuthApiKeyWriteRepository, SqlxAuthApiKeySnapshotReadRepository,
};
use crate::repository::auth_modules::{
AuthModuleReadRepository, AuthModuleWriteRepository, SqlxAuthModuleReadRepository,
SqlxAuthModuleRepository,
};
use crate::repository::billing::{BillingReadRepository, SqlxBillingReadRepository};
use crate::repository::candidate_selection::{
MinimalCandidateSelectionReadRepository, SqlxMinimalCandidateSelectionReadRepository,
};
use crate::repository::candidates::{
RequestCandidateReadRepository, RequestCandidateWriteRepository,
SqlxRequestCandidateReadRepository,
};
use crate::repository::gemini_file_mappings::{
GeminiFileMappingReadRepository, GeminiFileMappingWriteRepository,
SqlxGeminiFileMappingRepository,
};
use crate::repository::global_models::{
GlobalModelReadRepository, GlobalModelWriteRepository, SqlxGlobalModelReadRepository,
};
use crate::repository::management_tokens::{
ManagementTokenReadRepository, ManagementTokenWriteRepository, SqlxManagementTokenRepository,
};
use crate::repository::oauth_providers::{
OAuthProviderReadRepository, OAuthProviderWriteRepository, SqlxOAuthProviderRepository,
};
use crate::repository::provider_catalog::{
ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
SqlxProviderCatalogReadRepository,
};
use crate::repository::proxy_nodes::{
ProxyNodeReadRepository, ProxyNodeWriteRepository, SqlxProxyNodeRepository,
};
use crate::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, SqlxProviderQuotaRepository,
};
use crate::repository::settlement::{SettlementWriteRepository, SqlxSettlementRepository};
use crate::repository::usage::{
SqlxUsageReadRepository, UsageReadRepository, UsageWriteRepository,
};
use crate::repository::users::{SqlxUserReadRepository, UserReadRepository};
use crate::repository::video_tasks::{
SqlxVideoTaskReadRepository, SqlxVideoTaskRepository, VideoTaskReadRepository,
VideoTaskWriteRepository,
};
use crate::repository::wallet::{
SqlxWalletRepository, WalletReadRepository, WalletWriteRepository,
};
use crate::DataLayerError;
#[derive(Debug, Clone)]
pub struct PostgresBackend {
config: PostgresPoolConfig,
pool: PostgresPool,
}
impl PostgresBackend {
pub fn from_config(config: PostgresPoolConfig) -> Result<Self, DataLayerError> {
let factory = PostgresPoolFactory::new(config.clone())?;
let pool = factory.connect_lazy()?;
Ok(Self { config, pool })
}
pub fn config(&self) -> &PostgresPoolConfig {
&self.config
}
pub fn pool(&self) -> &PostgresPool {
&self.pool
}
pub fn pool_clone(&self) -> PostgresPool {
self.pool.clone()
}
pub fn auth_api_key_read_repository(&self) -> Arc<dyn AuthApiKeyReadRepository> {
Arc::new(SqlxAuthApiKeySnapshotReadRepository::new(self.pool_clone()))
}
pub fn announcement_read_repository(&self) -> Arc<dyn AnnouncementReadRepository> {
Arc::new(SqlxAnnouncementReadRepository::new(self.pool_clone()))
}
pub fn audit_log_read_repository(&self) -> Arc<dyn AuditLogReadRepository> {
Arc::new(PostgresAuditLogReadRepository::new(self.pool_clone()))
}
pub fn announcement_write_repository(&self) -> Arc<dyn AnnouncementWriteRepository> {
Arc::new(SqlxAnnouncementReadRepository::new(self.pool_clone()))
}
pub fn auth_api_key_write_repository(&self) -> Arc<dyn AuthApiKeyWriteRepository> {
Arc::new(SqlxAuthApiKeySnapshotReadRepository::new(self.pool_clone()))
}
pub fn auth_module_read_repository(&self) -> Arc<dyn AuthModuleReadRepository> {
Arc::new(SqlxAuthModuleReadRepository::new(self.pool_clone()))
}
pub fn auth_module_write_repository(&self) -> Arc<dyn AuthModuleWriteRepository> {
Arc::new(SqlxAuthModuleRepository::new(self.pool_clone()))
}
pub fn billing_read_repository(&self) -> Arc<dyn BillingReadRepository> {
Arc::new(SqlxBillingReadRepository::new(self.pool_clone()))
}
pub fn minimal_candidate_selection_read_repository(
&self,
) -> Arc<dyn MinimalCandidateSelectionReadRepository> {
Arc::new(SqlxMinimalCandidateSelectionReadRepository::new(
self.pool_clone(),
))
}
pub fn request_candidate_read_repository(&self) -> Arc<dyn RequestCandidateReadRepository> {
Arc::new(SqlxRequestCandidateReadRepository::new(self.pool_clone()))
}
pub fn request_candidate_write_repository(&self) -> Arc<dyn RequestCandidateWriteRepository> {
Arc::new(SqlxRequestCandidateReadRepository::new(self.pool_clone()))
}
pub fn gemini_file_mapping_read_repository(&self) -> Arc<dyn GeminiFileMappingReadRepository> {
Arc::new(SqlxGeminiFileMappingRepository::new(self.pool_clone()))
}
pub fn gemini_file_mapping_write_repository(
&self,
) -> Arc<dyn GeminiFileMappingWriteRepository> {
Arc::new(SqlxGeminiFileMappingRepository::new(self.pool_clone()))
}
pub fn global_model_read_repository(&self) -> Arc<dyn GlobalModelReadRepository> {
Arc::new(SqlxGlobalModelReadRepository::new(self.pool_clone()))
}
pub fn global_model_write_repository(&self) -> Arc<dyn GlobalModelWriteRepository> {
Arc::new(SqlxGlobalModelReadRepository::new(self.pool_clone()))
}
pub fn management_token_read_repository(&self) -> Arc<dyn ManagementTokenReadRepository> {
Arc::new(SqlxManagementTokenRepository::new(self.pool_clone()))
}
pub fn management_token_write_repository(&self) -> Arc<dyn ManagementTokenWriteRepository> {
Arc::new(SqlxManagementTokenRepository::new(self.pool_clone()))
}
pub fn oauth_provider_read_repository(&self) -> Arc<dyn OAuthProviderReadRepository> {
Arc::new(SqlxOAuthProviderRepository::new(self.pool_clone()))
}
pub fn oauth_provider_write_repository(&self) -> Arc<dyn OAuthProviderWriteRepository> {
Arc::new(SqlxOAuthProviderRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(SqlxProxyNodeRepository::new(self.pool_clone()))
}
pub fn proxy_node_write_repository(&self) -> Arc<dyn ProxyNodeWriteRepository> {
Arc::new(SqlxProxyNodeRepository::new(self.pool_clone()))
}
pub fn provider_catalog_read_repository(&self) -> Arc<dyn ProviderCatalogReadRepository> {
Arc::new(SqlxProviderCatalogReadRepository::new(self.pool_clone()))
}
pub fn provider_catalog_write_repository(&self) -> Arc<dyn ProviderCatalogWriteRepository> {
Arc::new(SqlxProviderCatalogReadRepository::new(self.pool_clone()))
}
pub fn provider_quota_read_repository(&self) -> Arc<dyn ProviderQuotaReadRepository> {
Arc::new(SqlxProviderQuotaRepository::new(self.pool_clone()))
}
pub fn usage_read_repository(&self) -> Arc<dyn UsageReadRepository> {
Arc::new(SqlxUsageReadRepository::new(self.pool_clone()))
}
pub fn user_read_repository(&self) -> Arc<dyn UserReadRepository> {
Arc::new(SqlxUserReadRepository::new(self.pool_clone()))
}
pub fn usage_write_repository(&self) -> Arc<dyn UsageWriteRepository> {
Arc::new(SqlxUsageReadRepository::new(self.pool_clone()))
}
pub fn wallet_read_repository(&self) -> Arc<dyn WalletReadRepository> {
Arc::new(SqlxWalletRepository::new(self.pool_clone()))
}
pub fn wallet_write_repository(&self) -> Arc<dyn WalletWriteRepository> {
Arc::new(SqlxWalletRepository::new(self.pool_clone()))
}
pub fn settlement_write_repository(&self) -> Arc<dyn SettlementWriteRepository> {
Arc::new(SqlxSettlementRepository::new(self.pool_clone()))
}
pub fn video_task_read_repository(&self) -> Arc<dyn VideoTaskReadRepository> {
Arc::new(SqlxVideoTaskReadRepository::new(self.pool_clone()))
}
pub fn video_task_write_repository(&self) -> Arc<dyn VideoTaskWriteRepository> {
Arc::new(SqlxVideoTaskRepository::new(self.pool_clone()))
}
pub fn transaction_runner(&self) -> PostgresTransactionRunner {
PostgresTransactionRunner::new(self.pool_clone())
}
pub fn lease_runner(
&self,
config: PostgresLeaseRunnerConfig,
) -> Result<PostgresLeaseRunner, DataLayerError> {
PostgresLeaseRunner::new(self.transaction_runner(), config)
}
pub fn provider_quota_write_repository(&self) -> Arc<dyn ProviderQuotaWriteRepository> {
Arc::new(SqlxProviderQuotaRepository::new(self.pool_clone()))
}
}
#[cfg(test)]
mod tests {
use super::PostgresBackend;
use crate::driver::postgres::{PostgresLeaseRunnerConfig, PostgresPoolConfig};
#[tokio::test]
async fn backend_retains_config_and_pool() {
let config = PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
};
let backend =
PostgresBackend::from_config(config.clone()).expect("backend should build lazily");
assert_eq!(backend.config(), &config);
let _pool = backend.pool();
let _pool_clone = backend.pool_clone();
let _auth_api_key_reader = backend.auth_api_key_read_repository();
let _auth_api_key_writer = backend.auth_api_key_write_repository();
let _auth_module_reader = backend.auth_module_read_repository();
let _billing_reader = backend.billing_read_repository();
let _gemini_file_mapping_reader = backend.gemini_file_mapping_read_repository();
let _global_model_reader = backend.global_model_read_repository();
let _global_model_writer = backend.global_model_write_repository();
let _management_token_reader = backend.management_token_read_repository();
let _management_token_writer = backend.management_token_write_repository();
let _oauth_provider_reader = backend.oauth_provider_read_repository();
let _oauth_provider_writer = backend.oauth_provider_write_repository();
let _proxy_node_reader = backend.proxy_node_read_repository();
let _proxy_node_writer = backend.proxy_node_write_repository();
let _minimal_candidate_selection_reader =
backend.minimal_candidate_selection_read_repository();
let _request_candidate_reader = backend.request_candidate_read_repository();
let _request_candidate_writer = backend.request_candidate_write_repository();
let _gemini_file_mapping_writer = backend.gemini_file_mapping_write_repository();
let _provider_catalog_reader = backend.provider_catalog_read_repository();
let _provider_catalog_writer = backend.provider_catalog_write_repository();
let _provider_quota_reader = backend.provider_quota_read_repository();
let _usage_reader = backend.usage_read_repository();
let _usage_writer = backend.usage_write_repository();
let _wallet_reader = backend.wallet_read_repository();
let _wallet_writer = backend.wallet_write_repository();
let _settlement_writer = backend.settlement_write_repository();
let _video_task_reader = backend.video_task_read_repository();
let _video_task_writer = backend.video_task_write_repository();
let _transaction_runner = backend.transaction_runner();
let _lease_runner = backend
.lease_runner(PostgresLeaseRunnerConfig::default())
.expect("lease runner should build");
let _provider_quota_writer = backend.provider_quota_write_repository();
}
}

View File

@@ -0,0 +1,301 @@
use std::fmt;
use std::sync::Arc;
use super::{MysqlBackend, PostgresBackend, SqliteBackend};
use crate::repository::announcements::AnnouncementReadRepository;
use crate::repository::audit::AuditLogReadRepository;
use crate::repository::auth::AuthApiKeyReadRepository;
use crate::repository::auth_modules::AuthModuleReadRepository;
use crate::repository::billing::BillingReadRepository;
use crate::repository::candidate_selection::MinimalCandidateSelectionReadRepository;
use crate::repository::candidates::RequestCandidateReadRepository;
use crate::repository::gemini_file_mappings::GeminiFileMappingReadRepository;
use crate::repository::global_models::GlobalModelReadRepository;
use crate::repository::management_tokens::ManagementTokenReadRepository;
use crate::repository::oauth_providers::OAuthProviderReadRepository;
use crate::repository::provider_catalog::ProviderCatalogReadRepository;
use crate::repository::proxy_nodes::ProxyNodeReadRepository;
use crate::repository::quota::ProviderQuotaReadRepository;
use crate::repository::usage::UsageReadRepository;
use crate::repository::users::UserReadRepository;
use crate::repository::video_tasks::VideoTaskReadRepository;
use crate::repository::wallet::WalletReadRepository;
#[derive(Clone, Default)]
pub struct DataReadRepositories {
announcements: Option<Arc<dyn AnnouncementReadRepository>>,
audit_logs: Option<Arc<dyn AuditLogReadRepository>>,
auth_api_keys: Option<Arc<dyn AuthApiKeyReadRepository>>,
auth_modules: Option<Arc<dyn AuthModuleReadRepository>>,
billing: Option<Arc<dyn BillingReadRepository>>,
gemini_file_mappings: Option<Arc<dyn GeminiFileMappingReadRepository>>,
global_models: Option<Arc<dyn GlobalModelReadRepository>>,
management_tokens: Option<Arc<dyn ManagementTokenReadRepository>>,
oauth_providers: Option<Arc<dyn OAuthProviderReadRepository>>,
proxy_nodes: Option<Arc<dyn ProxyNodeReadRepository>>,
minimal_candidate_selection: Option<Arc<dyn MinimalCandidateSelectionReadRepository>>,
request_candidates: Option<Arc<dyn RequestCandidateReadRepository>>,
provider_catalog: Option<Arc<dyn ProviderCatalogReadRepository>>,
provider_quotas: Option<Arc<dyn ProviderQuotaReadRepository>>,
usage: Option<Arc<dyn UsageReadRepository>>,
users: Option<Arc<dyn UserReadRepository>>,
video_tasks: Option<Arc<dyn VideoTaskReadRepository>>,
wallets: Option<Arc<dyn WalletReadRepository>>,
}
impl fmt::Debug for DataReadRepositories {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataReadRepositories")
.field("has_auth_api_keys", &self.auth_api_keys.is_some())
.field("has_announcements", &self.announcements.is_some())
.field("has_audit_logs", &self.audit_logs.is_some())
.field("has_auth_modules", &self.auth_modules.is_some())
.field("has_billing", &self.billing.is_some())
.field(
"has_gemini_file_mappings",
&self.gemini_file_mappings.is_some(),
)
.field("has_global_models", &self.global_models.is_some())
.field("has_management_tokens", &self.management_tokens.is_some())
.field("has_oauth_providers", &self.oauth_providers.is_some())
.field("has_proxy_nodes", &self.proxy_nodes.is_some())
.field(
"has_minimal_candidate_selection",
&self.minimal_candidate_selection.is_some(),
)
.field("has_request_candidates", &self.request_candidates.is_some())
.field("has_provider_catalog", &self.provider_catalog.is_some())
.field("has_provider_quotas", &self.provider_quotas.is_some())
.field("has_usage", &self.usage.is_some())
.field("has_users", &self.users.is_some())
.field("has_video_tasks", &self.video_tasks.is_some())
.field("has_wallets", &self.wallets.is_some())
.finish()
}
}
impl DataReadRepositories {
pub(crate) fn from_backends(
postgres: Option<&PostgresBackend>,
mysql: Option<&MysqlBackend>,
sqlite: Option<&SqliteBackend>,
) -> Self {
Self {
announcements: postgres
.map(PostgresBackend::announcement_read_repository)
.or_else(|| mysql.map(MysqlBackend::announcement_read_repository))
.or_else(|| sqlite.map(SqliteBackend::announcement_read_repository)),
audit_logs: postgres
.map(PostgresBackend::audit_log_read_repository)
.or_else(|| mysql.map(MysqlBackend::audit_log_read_repository))
.or_else(|| sqlite.map(SqliteBackend::audit_log_read_repository)),
auth_api_keys: postgres
.map(PostgresBackend::auth_api_key_read_repository)
.or_else(|| mysql.map(MysqlBackend::auth_api_key_read_repository))
.or_else(|| sqlite.map(SqliteBackend::auth_api_key_read_repository)),
auth_modules: postgres
.map(PostgresBackend::auth_module_read_repository)
.or_else(|| mysql.map(MysqlBackend::auth_module_read_repository))
.or_else(|| sqlite.map(SqliteBackend::auth_module_read_repository)),
billing: postgres
.map(PostgresBackend::billing_read_repository)
.or_else(|| mysql.map(MysqlBackend::billing_read_repository))
.or_else(|| sqlite.map(SqliteBackend::billing_read_repository)),
gemini_file_mappings: postgres
.map(PostgresBackend::gemini_file_mapping_read_repository)
.or_else(|| mysql.map(MysqlBackend::gemini_file_mapping_read_repository))
.or_else(|| sqlite.map(SqliteBackend::gemini_file_mapping_read_repository)),
global_models: postgres
.map(PostgresBackend::global_model_read_repository)
.or_else(|| mysql.map(MysqlBackend::global_model_read_repository))
.or_else(|| sqlite.map(SqliteBackend::global_model_read_repository)),
management_tokens: postgres
.map(PostgresBackend::management_token_read_repository)
.or_else(|| mysql.map(MysqlBackend::management_token_read_repository))
.or_else(|| sqlite.map(SqliteBackend::management_token_read_repository)),
oauth_providers: postgres
.map(PostgresBackend::oauth_provider_read_repository)
.or_else(|| mysql.map(MysqlBackend::oauth_provider_read_repository))
.or_else(|| sqlite.map(SqliteBackend::oauth_provider_read_repository)),
proxy_nodes: postgres
.map(PostgresBackend::proxy_node_read_repository)
.or_else(|| mysql.map(MysqlBackend::proxy_node_read_repository))
.or_else(|| sqlite.map(SqliteBackend::proxy_node_read_repository)),
minimal_candidate_selection: postgres
.map(PostgresBackend::minimal_candidate_selection_read_repository)
.or_else(|| mysql.map(MysqlBackend::minimal_candidate_selection_read_repository))
.or_else(|| sqlite.map(SqliteBackend::minimal_candidate_selection_read_repository)),
request_candidates: postgres
.map(PostgresBackend::request_candidate_read_repository)
.or_else(|| mysql.map(MysqlBackend::request_candidate_read_repository))
.or_else(|| sqlite.map(SqliteBackend::request_candidate_read_repository)),
provider_catalog: postgres
.map(PostgresBackend::provider_catalog_read_repository)
.or_else(|| mysql.map(MysqlBackend::provider_catalog_read_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_catalog_read_repository)),
provider_quotas: postgres
.map(PostgresBackend::provider_quota_read_repository)
.or_else(|| mysql.map(MysqlBackend::provider_quota_read_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_quota_read_repository)),
usage: postgres
.map(PostgresBackend::usage_read_repository)
.or_else(|| mysql.map(MysqlBackend::usage_read_repository))
.or_else(|| sqlite.map(SqliteBackend::usage_read_repository)),
users: postgres
.map(PostgresBackend::user_read_repository)
.or_else(|| mysql.map(MysqlBackend::user_read_repository))
.or_else(|| sqlite.map(SqliteBackend::user_read_repository)),
video_tasks: postgres
.map(PostgresBackend::video_task_read_repository)
.or_else(|| mysql.map(MysqlBackend::video_task_read_repository))
.or_else(|| sqlite.map(SqliteBackend::video_task_read_repository)),
wallets: postgres
.map(PostgresBackend::wallet_read_repository)
.or_else(|| mysql.map(MysqlBackend::wallet_read_repository))
.or_else(|| sqlite.map(SqliteBackend::wallet_read_repository)),
}
}
#[cfg(test)]
pub(crate) fn from_postgres(postgres: Option<&PostgresBackend>) -> Self {
Self::from_backends(postgres, None, None)
}
pub fn auth_api_keys(&self) -> Option<Arc<dyn AuthApiKeyReadRepository>> {
self.auth_api_keys.clone()
}
pub fn announcements(&self) -> Option<Arc<dyn AnnouncementReadRepository>> {
self.announcements.clone()
}
pub fn audit_logs(&self) -> Option<Arc<dyn AuditLogReadRepository>> {
self.audit_logs.clone()
}
pub fn auth_modules(&self) -> Option<Arc<dyn AuthModuleReadRepository>> {
self.auth_modules.clone()
}
pub fn billing(&self) -> Option<Arc<dyn BillingReadRepository>> {
self.billing.clone()
}
pub fn gemini_file_mappings(&self) -> Option<Arc<dyn GeminiFileMappingReadRepository>> {
self.gemini_file_mappings.clone()
}
pub fn global_models(&self) -> Option<Arc<dyn GlobalModelReadRepository>> {
self.global_models.clone()
}
pub fn management_tokens(&self) -> Option<Arc<dyn ManagementTokenReadRepository>> {
self.management_tokens.clone()
}
pub fn oauth_providers(&self) -> Option<Arc<dyn OAuthProviderReadRepository>> {
self.oauth_providers.clone()
}
pub fn proxy_nodes(&self) -> Option<Arc<dyn ProxyNodeReadRepository>> {
self.proxy_nodes.clone()
}
pub fn minimal_candidate_selection(
&self,
) -> Option<Arc<dyn MinimalCandidateSelectionReadRepository>> {
self.minimal_candidate_selection.clone()
}
pub fn request_candidates(&self) -> Option<Arc<dyn RequestCandidateReadRepository>> {
self.request_candidates.clone()
}
pub fn provider_catalog(&self) -> Option<Arc<dyn ProviderCatalogReadRepository>> {
self.provider_catalog.clone()
}
pub fn provider_quotas(&self) -> Option<Arc<dyn ProviderQuotaReadRepository>> {
self.provider_quotas.clone()
}
pub fn usage(&self) -> Option<Arc<dyn UsageReadRepository>> {
self.usage.clone()
}
pub fn users(&self) -> Option<Arc<dyn UserReadRepository>> {
self.users.clone()
}
pub fn video_tasks(&self) -> Option<Arc<dyn VideoTaskReadRepository>> {
self.video_tasks.clone()
}
pub fn wallets(&self) -> Option<Arc<dyn WalletReadRepository>> {
self.wallets.clone()
}
pub fn has_any(&self) -> bool {
self.auth_api_keys.is_some()
|| self.announcements.is_some()
|| self.audit_logs.is_some()
|| self.auth_modules.is_some()
|| self.billing.is_some()
|| self.gemini_file_mappings.is_some()
|| self.global_models.is_some()
|| self.management_tokens.is_some()
|| self.oauth_providers.is_some()
|| self.proxy_nodes.is_some()
|| self.minimal_candidate_selection.is_some()
|| self.request_candidates.is_some()
|| self.provider_catalog.is_some()
|| self.provider_quotas.is_some()
|| self.usage.is_some()
|| self.users.is_some()
|| self.video_tasks.is_some()
|| self.wallets.is_some()
}
}
#[cfg(test)]
mod tests {
use super::DataReadRepositories;
use crate::backend::PostgresBackend;
use crate::driver::postgres::PostgresPoolConfig;
#[tokio::test]
async fn builds_read_repositories_from_postgres_backend() {
let backend = PostgresBackend::from_config(PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
})
.expect("postgres backend should build");
let read = DataReadRepositories::from_postgres(Some(&backend));
assert!(read.has_any());
assert!(read.announcements().is_some());
assert!(read.audit_logs().is_some());
assert!(read.auth_api_keys().is_some());
assert!(read.auth_modules().is_some());
assert!(read.billing().is_some());
assert!(read.gemini_file_mappings().is_some());
assert!(read.global_models().is_some());
assert!(read.management_tokens().is_some());
assert!(read.oauth_providers().is_some());
assert!(read.proxy_nodes().is_some());
assert!(read.minimal_candidate_selection().is_some());
assert!(read.request_candidates().is_some());
assert!(read.provider_catalog().is_some());
assert!(read.provider_quotas().is_some());
assert!(read.usage().is_some());
assert!(read.video_tasks().is_some());
assert!(read.wallets().is_some());
}
}

View File

@@ -0,0 +1,86 @@
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

@@ -0,0 +1,579 @@
use std::sync::Arc;
use crate::database::SqlDatabaseConfig;
use crate::driver::sqlite::{SqlitePool, SqlitePoolFactory};
use crate::repository::announcements::{
AnnouncementReadRepository, AnnouncementWriteRepository, SqliteAnnouncementRepository,
};
use crate::repository::audit::{AuditLogReadRepository, SqliteAuditLogReadRepository};
use crate::repository::auth::{
AuthApiKeyReadRepository, AuthApiKeyWriteRepository, SqliteAuthApiKeyReadRepository,
};
use crate::repository::auth_modules::{
AuthModuleReadRepository, AuthModuleWriteRepository, SqliteAuthModuleReadRepository,
SqliteAuthModuleRepository,
};
use crate::repository::billing::{BillingReadRepository, SqliteBillingReadRepository};
use crate::repository::candidate_selection::{
MinimalCandidateSelectionReadRepository, SqliteMinimalCandidateSelectionReadRepository,
};
use crate::repository::candidates::{
RequestCandidateReadRepository, RequestCandidateWriteRepository,
SqliteRequestCandidateRepository,
};
use crate::repository::gemini_file_mappings::{
GeminiFileMappingReadRepository, GeminiFileMappingWriteRepository,
SqliteGeminiFileMappingRepository,
};
use crate::repository::global_models::{
GlobalModelReadRepository, GlobalModelWriteRepository, SqliteGlobalModelReadRepository,
};
use crate::repository::management_tokens::{
ManagementTokenReadRepository, ManagementTokenWriteRepository, SqliteManagementTokenRepository,
};
use crate::repository::oauth_providers::{
OAuthProviderReadRepository, OAuthProviderWriteRepository, SqliteOAuthProviderRepository,
};
use crate::repository::provider_catalog::{
ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
SqliteProviderCatalogReadRepository,
};
use crate::repository::proxy_nodes::{
ProxyNodeReadRepository, ProxyNodeWriteRepository, SqliteProxyNodeReadRepository,
};
use crate::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, SqliteProviderQuotaRepository,
};
use crate::repository::settlement::{SettlementWriteRepository, SqliteSettlementRepository};
use crate::repository::usage::{
SqliteUsageReadRepository, SqliteUsageWriteRepository, UsageReadRepository,
UsageWriteRepository,
};
use crate::repository::users::{SqliteUserReadRepository, UserReadRepository};
use crate::repository::video_tasks::{
SqliteVideoTaskRepository, VideoTaskReadRepository, VideoTaskWriteRepository,
};
use crate::repository::wallet::{
SqliteWalletReadRepository, WalletReadRepository, WalletWriteRepository,
};
use crate::DataLayerError;
#[derive(Debug, Clone)]
pub struct SqliteBackend {
config: SqlDatabaseConfig,
pool: SqlitePool,
}
impl SqliteBackend {
pub fn from_config(config: SqlDatabaseConfig) -> Result<Self, DataLayerError> {
let factory = SqlitePoolFactory::new(config.clone())?;
let pool = factory.connect_lazy()?;
Ok(Self { config, pool })
}
pub fn config(&self) -> &SqlDatabaseConfig {
&self.config
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
pub fn pool_clone(&self) -> SqlitePool {
self.pool.clone()
}
pub fn auth_api_key_read_repository(&self) -> Arc<dyn AuthApiKeyReadRepository> {
Arc::new(SqliteAuthApiKeyReadRepository::new(self.pool_clone()))
}
pub fn announcement_read_repository(&self) -> Arc<dyn AnnouncementReadRepository> {
Arc::new(SqliteAnnouncementRepository::new(self.pool_clone()))
}
pub fn audit_log_read_repository(&self) -> Arc<dyn AuditLogReadRepository> {
Arc::new(SqliteAuditLogReadRepository::new(self.pool_clone()))
}
pub fn announcement_write_repository(&self) -> Arc<dyn AnnouncementWriteRepository> {
Arc::new(SqliteAnnouncementRepository::new(self.pool_clone()))
}
pub fn auth_api_key_write_repository(&self) -> Arc<dyn AuthApiKeyWriteRepository> {
Arc::new(SqliteAuthApiKeyReadRepository::new(self.pool_clone()))
}
pub fn management_token_read_repository(&self) -> Arc<dyn ManagementTokenReadRepository> {
Arc::new(SqliteManagementTokenRepository::new(self.pool_clone()))
}
pub fn management_token_write_repository(&self) -> Arc<dyn ManagementTokenWriteRepository> {
Arc::new(SqliteManagementTokenRepository::new(self.pool_clone()))
}
pub fn auth_module_read_repository(&self) -> Arc<dyn AuthModuleReadRepository> {
Arc::new(SqliteAuthModuleReadRepository::new(self.pool_clone()))
}
pub fn auth_module_write_repository(&self) -> Arc<dyn AuthModuleWriteRepository> {
Arc::new(SqliteAuthModuleRepository::new(self.pool_clone()))
}
pub fn billing_read_repository(&self) -> Arc<dyn BillingReadRepository> {
Arc::new(SqliteBillingReadRepository::new(self.pool_clone()))
}
pub fn request_candidate_read_repository(&self) -> Arc<dyn RequestCandidateReadRepository> {
Arc::new(SqliteRequestCandidateRepository::new(self.pool_clone()))
}
pub fn request_candidate_write_repository(&self) -> Arc<dyn RequestCandidateWriteRepository> {
Arc::new(SqliteRequestCandidateRepository::new(self.pool_clone()))
}
pub fn minimal_candidate_selection_read_repository(
&self,
) -> Arc<dyn MinimalCandidateSelectionReadRepository> {
Arc::new(SqliteMinimalCandidateSelectionReadRepository::new(
self.pool_clone(),
))
}
pub fn gemini_file_mapping_read_repository(&self) -> Arc<dyn GeminiFileMappingReadRepository> {
Arc::new(SqliteGeminiFileMappingRepository::new(self.pool_clone()))
}
pub fn gemini_file_mapping_write_repository(
&self,
) -> Arc<dyn GeminiFileMappingWriteRepository> {
Arc::new(SqliteGeminiFileMappingRepository::new(self.pool_clone()))
}
pub fn global_model_read_repository(&self) -> Arc<dyn GlobalModelReadRepository> {
Arc::new(SqliteGlobalModelReadRepository::new(self.pool_clone()))
}
pub fn global_model_write_repository(&self) -> Arc<dyn GlobalModelWriteRepository> {
Arc::new(SqliteGlobalModelReadRepository::new(self.pool_clone()))
}
pub fn user_read_repository(&self) -> Arc<dyn UserReadRepository> {
Arc::new(SqliteUserReadRepository::new(self.pool_clone()))
}
pub fn video_task_read_repository(&self) -> Arc<dyn VideoTaskReadRepository> {
Arc::new(SqliteVideoTaskRepository::new(self.pool_clone()))
}
pub fn video_task_write_repository(&self) -> Arc<dyn VideoTaskWriteRepository> {
Arc::new(SqliteVideoTaskRepository::new(self.pool_clone()))
}
pub fn oauth_provider_read_repository(&self) -> Arc<dyn OAuthProviderReadRepository> {
Arc::new(SqliteOAuthProviderRepository::new(self.pool_clone()))
}
pub fn oauth_provider_write_repository(&self) -> Arc<dyn OAuthProviderWriteRepository> {
Arc::new(SqliteOAuthProviderRepository::new(self.pool_clone()))
}
pub fn provider_catalog_read_repository(&self) -> Arc<dyn ProviderCatalogReadRepository> {
Arc::new(SqliteProviderCatalogReadRepository::new(self.pool_clone()))
}
pub fn provider_catalog_write_repository(&self) -> Arc<dyn ProviderCatalogWriteRepository> {
Arc::new(SqliteProviderCatalogReadRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(SqliteProxyNodeReadRepository::new(self.pool_clone()))
}
pub fn proxy_node_write_repository(&self) -> Arc<dyn ProxyNodeWriteRepository> {
Arc::new(SqliteProxyNodeReadRepository::new(self.pool_clone()))
}
pub fn provider_quota_read_repository(&self) -> Arc<dyn ProviderQuotaReadRepository> {
Arc::new(SqliteProviderQuotaRepository::new(self.pool_clone()))
}
pub fn provider_quota_write_repository(&self) -> Arc<dyn ProviderQuotaWriteRepository> {
Arc::new(SqliteProviderQuotaRepository::new(self.pool_clone()))
}
pub fn settlement_write_repository(&self) -> Arc<dyn SettlementWriteRepository> {
Arc::new(SqliteSettlementRepository::new(self.pool_clone()))
}
pub fn usage_write_repository(&self) -> Arc<dyn UsageWriteRepository> {
Arc::new(SqliteUsageWriteRepository::new(self.pool_clone()))
}
pub fn usage_read_repository(&self) -> Arc<dyn UsageReadRepository> {
Arc::new(SqliteUsageReadRepository::new(self.pool_clone()))
}
pub fn wallet_read_repository(&self) -> Arc<dyn WalletReadRepository> {
Arc::new(SqliteWalletReadRepository::new(self.pool_clone()))
}
pub fn wallet_write_repository(&self) -> Arc<dyn WalletWriteRepository> {
Arc::new(SqliteWalletReadRepository::new(self.pool_clone()))
}
}
#[cfg(test)]
mod tests {
use super::SqliteBackend;
use crate::lifecycle::migrate::run_sqlite_migrations;
use crate::{
DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, StatsDailyAggregationInput,
StatsHourlyAggregationInput, WalletDailyUsageAggregationInput,
};
#[tokio::test]
async fn backend_retains_config_and_pool() {
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Sqlite,
url: "sqlite://./data/aether.db".to_string(),
pool: SqlPoolConfig::default(),
};
let backend = SqliteBackend::from_config(config.clone()).expect("backend should build");
assert_eq!(backend.config(), &config);
let _pool = backend.pool();
let _pool_clone = backend.pool_clone();
}
#[tokio::test]
async fn system_config_round_trips_after_sqlite_migrations() {
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Sqlite,
url: "sqlite::memory:".to_string(),
pool: SqlPoolConfig {
max_connections: 1,
..SqlPoolConfig::default()
},
};
let backend = SqliteBackend::from_config(config).expect("backend should build");
run_sqlite_migrations(backend.pool())
.await
.expect("sqlite migrations should run");
let value = serde_json::json!({"enabled": true});
let stored = backend
.upsert_system_config_entry("feature.local", &value, Some("local flag"))
.await
.expect("system config should upsert");
assert_eq!(stored.value, value);
assert_eq!(
backend
.find_system_config_value("feature.local")
.await
.expect("system config should read"),
Some(value)
);
assert_eq!(
backend
.list_system_config_entries()
.await
.expect("system config should list")
.len(),
1
);
assert!(backend
.delete_system_config_value("feature.local")
.await
.expect("system config should delete"));
}
#[tokio::test]
async fn table_maintenance_runs_after_sqlite_migrations() {
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Sqlite,
url: "sqlite::memory:".to_string(),
pool: SqlPoolConfig {
max_connections: 1,
..SqlPoolConfig::default()
},
};
let backend = SqliteBackend::from_config(config).expect("backend should build");
run_sqlite_migrations(backend.pool())
.await
.expect("sqlite migrations should run");
let summary = backend
.run_table_maintenance(&["usage", "request_candidates", "audit_logs"])
.await
.expect("sqlite table maintenance should run");
assert_eq!(summary.attempted, 3);
assert_eq!(summary.succeeded, 3);
}
#[tokio::test]
async fn wallet_daily_usage_aggregation_uses_settlement_wallets_after_sqlite_migrations() {
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Sqlite,
url: "sqlite::memory:".to_string(),
pool: SqlPoolConfig {
max_connections: 1,
..SqlPoolConfig::default()
},
};
let backend = SqliteBackend::from_config(config).expect("backend should build");
run_sqlite_migrations(backend.pool())
.await
.expect("sqlite migrations should run");
sqlx::query(
r#"
INSERT INTO wallets (id, user_id, balance, gift_balance, limit_mode, created_at, updated_at)
VALUES
('wallet-1', 'user-1', 10.0, 2.0, 'finite', 1, 1),
('wallet-stale', 'user-stale', 0.0, 0.0, 'finite', 1, 1)
"#,
)
.execute(backend.pool())
.await
.expect("wallets should seed");
sqlx::query(
r#"
INSERT INTO "usage" (
request_id, wallet_id, provider_name, model, status, billing_status,
total_cost_usd, input_tokens, output_tokens, cache_creation_input_tokens,
cache_read_input_tokens, finalized_at, created_at_unix_ms, updated_at_unix_secs
) VALUES
('request-1', 'wrong-wallet', 'provider', 'model', 'completed', 'pending',
1.25, 10, 20, 3, 4, 900, 900000, 900),
('request-2', NULL, 'provider', 'model', 'completed', 'pending',
2.00, 5, 7, 1, 2, 901, 901000, 901),
('request-zero', NULL, 'provider', 'model', 'completed', 'pending',
0.00, 100, 100, 0, 0, 902, 902000, 902),
('request-outside', NULL, 'provider', 'model', 'completed', 'pending',
9.00, 50, 50, 0, 0, 903, 903000, 903)
"#,
)
.execute(backend.pool())
.await
.expect("usage should seed");
sqlx::query(
r#"
INSERT INTO usage_settlement_snapshots (
request_id, billing_status, wallet_id, finalized_at, created_at, updated_at
) VALUES
('request-1', 'settled', 'wallet-1', 1000, 1000, 1000),
('request-2', 'settled', 'wallet-1', 1100, 1100, 1100),
('request-zero', 'settled', 'wallet-1', 1150, 1150, 1150),
('request-outside', 'settled', 'wallet-1', 1200, 1200, 1200)
"#,
)
.execute(backend.pool())
.await
.expect("settlement snapshots should seed");
sqlx::query(
r#"
INSERT INTO wallet_daily_usage_ledgers (
id, wallet_id, billing_date, billing_timezone, total_cost_usd,
total_requests, input_tokens, output_tokens, cache_creation_tokens,
cache_read_tokens, aggregated_at, created_at, updated_at
) VALUES (
'stale-ledger', 'wallet-stale', '2026-05-03', 'Asia/Shanghai',
7.0, 3, 1, 1, 0, 0, 999, 999, 999
)
"#,
)
.execute(backend.pool())
.await
.expect("stale ledger should seed");
let summary = backend
.aggregate_wallet_daily_usage(&WalletDailyUsageAggregationInput {
billing_date: "2026-05-03".to_string(),
billing_timezone: "Asia/Shanghai".to_string(),
window_start_unix_secs: 1000,
window_end_unix_secs: 1200,
aggregated_at_unix_secs: 1300,
})
.await
.expect("wallet daily usage aggregation should run");
assert_eq!(summary.aggregated_wallets, 1);
assert_eq!(summary.deleted_stale_ledgers, 1);
let ledger = sqlx::query_as::<
_,
(
String,
String,
f64,
i64,
i64,
i64,
i64,
i64,
Option<i64>,
Option<i64>,
i64,
),
>(
r#"
SELECT
id,
wallet_id,
total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
first_finalized_at,
last_finalized_at,
aggregated_at
FROM wallet_daily_usage_ledgers
WHERE billing_date = '2026-05-03'
AND billing_timezone = 'Asia/Shanghai'
"#,
)
.fetch_one(backend.pool())
.await
.expect("aggregated ledger should load");
assert_eq!(ledger.0.len(), 64);
assert_eq!(ledger.1, "wallet-1");
assert!((ledger.2 - 3.25).abs() < f64::EPSILON);
assert_eq!(ledger.3, 2);
assert_eq!(ledger.4, 15);
assert_eq!(ledger.5, 27);
assert_eq!(ledger.6, 4);
assert_eq!(ledger.7, 6);
assert_eq!(ledger.8, Some(1000));
assert_eq!(ledger.9, Some(1100));
assert_eq!(ledger.10, 1300);
let stale_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM wallet_daily_usage_ledgers WHERE id = 'stale-ledger'",
)
.fetch_one(backend.pool())
.await
.expect("stale ledger count should load");
assert_eq!(stale_count, 0);
}
#[tokio::test]
async fn stats_aggregation_runs_after_sqlite_migrations() {
let config = SqlDatabaseConfig {
driver: DatabaseDriver::Sqlite,
url: "sqlite::memory:".to_string(),
pool: SqlPoolConfig {
max_connections: 1,
..SqlPoolConfig::default()
},
};
let backend = SqliteBackend::from_config(config).expect("backend should build");
run_sqlite_migrations(backend.pool())
.await
.expect("sqlite migrations should run");
sqlx::query(
r#"
INSERT INTO "usage" (
request_id, user_id, api_key_id, provider_name, model, status, billing_status,
status_code, error_category, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens, total_cost_usd,
actual_total_cost_usd, response_time_ms, created_at_unix_ms, updated_at_unix_secs
) VALUES
('stats-1', 'user-1', 'key-1', 'provider-a', 'model-a', 'completed', 'settled',
200, NULL, 10, 20, 1, 2, 0.30, 0.25, 100, 3600000, 3600),
('stats-2', 'user-2', 'key-2', 'provider-b', 'model-b', 'failed', 'void',
500, 'upstream_error', 5, 7, 0, 1, 0.20, 0.20, 300, 3610000, 3610),
('stats-pending', 'user-3', 'key-3', 'provider-a', 'model-a', 'pending', 'pending',
NULL, NULL, 100, 100, 0, 0, 9.99, 9.99, 50, 3620000, 3620),
('stats-unknown-provider', 'user-4', 'key-4', 'unknown', 'model-a', 'completed', 'settled',
200, NULL, 100, 100, 0, 0, 9.99, 9.99, 50, 3630000, 3630)
"#,
)
.execute(backend.pool())
.await
.expect("usage stats rows should seed");
let target_hour = chrono::DateTime::<chrono::Utc>::from_timestamp(3600, 0)
.expect("target hour should be valid");
let aggregated_at = chrono::DateTime::<chrono::Utc>::from_timestamp(7200, 0)
.expect("aggregation time should be valid");
let hourly = backend
.aggregate_stats_hourly(&StatsHourlyAggregationInput {
target_hour_utc: target_hour,
aggregated_at,
})
.await
.expect("hourly stats aggregation should run")
.expect("hourly bucket should aggregate");
assert_eq!(hourly.hour_utc, target_hour);
assert_eq!(hourly.total_requests, 2);
assert_eq!(hourly.user_rows, 2);
assert_eq!(hourly.user_model_rows, 2);
assert_eq!(hourly.model_rows, 2);
assert_eq!(hourly.provider_rows, 2);
let hourly_row = sqlx::query_as::<_, (i64, i64, i64, i64, f64)>(
r#"
SELECT total_requests, success_requests, error_requests, input_tokens, total_cost
FROM stats_hourly
WHERE hour_utc = 3600
"#,
)
.fetch_one(backend.pool())
.await
.expect("hourly stats row should load");
assert_eq!(hourly_row.0, 2);
assert_eq!(hourly_row.1, 1);
assert_eq!(hourly_row.2, 1);
assert_eq!(hourly_row.3, 15);
assert!((hourly_row.4 - 0.50).abs() < f64::EPSILON);
let second_hourly = backend
.aggregate_stats_hourly(&StatsHourlyAggregationInput {
target_hour_utc: target_hour,
aggregated_at,
})
.await
.expect("second hourly aggregation should run");
assert!(second_hourly.is_none());
let target_day = chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0)
.expect("target day should be valid");
let daily = backend
.aggregate_stats_daily(&StatsDailyAggregationInput {
target_day_utc: target_day,
aggregated_at,
})
.await
.expect("daily stats aggregation should run")
.expect("daily bucket should aggregate");
assert_eq!(daily.day_start_utc, target_day);
assert_eq!(daily.total_requests, 2);
assert_eq!(daily.model_rows, 2);
assert_eq!(daily.provider_rows, 2);
assert_eq!(daily.api_key_rows, 2);
assert_eq!(daily.error_rows, 1);
assert_eq!(daily.user_rows, 2);
let daily_row = sqlx::query_as::<_, (i64, i64, i64, i64)>(
r#"
SELECT total_requests, success_requests, error_requests, unique_models
FROM stats_daily
WHERE "date" = 0
"#,
)
.fetch_one(backend.pool())
.await
.expect("daily stats row should load");
assert_eq!(daily_row, (2, 1, 1, 2));
}
}

View File

@@ -0,0 +1,4 @@
pub(crate) mod mysql;
pub(crate) mod postgres_daily;
pub(crate) mod postgres_hourly;
pub(crate) mod sqlite;

View File

@@ -0,0 +1,371 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use crate::backend::stats_common::{stats_id, unix_ms, unix_secs, utc_from_unix_secs};
use crate::backend::MysqlBackend;
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::{
DataLayerError, StatsDailyAggregationInput, StatsDailyAggregationSummary,
StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
};
impl MysqlBackend {
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
let Some(hour_utc_unix_secs) =
next_mysql_stats_hourly_bucket(self.pool(), input.target_hour_utc).await?
else {
return Ok(None);
};
perform_mysql_stats_hourly_aggregation(self.pool(), hour_utc_unix_secs, input.aggregated_at)
.await
.map(Some)
}
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
let Some(day_start_unix_secs) =
next_mysql_stats_daily_bucket(self.pool(), input.target_day_utc).await?
else {
return Ok(None);
};
perform_mysql_stats_daily_aggregation(self.pool(), day_start_unix_secs, input.aggregated_at)
.await
.map(Some)
}
}
async fn next_mysql_stats_hourly_bucket(
pool: &MysqlPool,
target_hour_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_hour: Option<i64> =
sqlx::query_scalar("SELECT MAX(hour_utc) FROM stats_hourly WHERE is_complete <> 0")
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_hour.map(|value| value + 3600).unwrap_or(0);
let search_until = unix_secs(target_hour_utc) + 3600;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(FLOOR(created_at_unix_ms / 3600000) * 3600)
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_hour_utc)))
}
async fn next_mysql_stats_daily_bucket(
pool: &MysqlPool,
target_day_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_day: Option<i64> =
sqlx::query_scalar("SELECT MAX(`date`) FROM stats_daily WHERE is_complete <> 0")
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_day.map(|value| value + 86_400).unwrap_or(0);
let search_until = unix_secs(target_day_utc) + 86_400;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(FLOOR(created_at_unix_ms / 86400000) * 86400)
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_day_utc)))
}
const MYSQL_STATS_AGGREGATE_SQL: &str = r#"
SELECT
COUNT(*) AS total_requests,
COALESCE(SUM(CASE
WHEN status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
THEN 1 ELSE 0 END), 0) AS error_requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(total_cost_usd), 0.0) AS total_cost,
COALESCE(SUM(actual_total_cost_usd), 0.0) AS actual_total_cost,
COALESCE(AVG(response_time_ms), 0.0) AS avg_response_time_ms
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
async fn perform_mysql_stats_hourly_aggregation(
pool: &MysqlPool,
hour_utc_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsHourlyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(hour_utc_unix_secs)?;
let end_ms = unix_ms(hour_utc_unix_secs + 3600)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(MYSQL_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
sqlx::query(
r#"
INSERT INTO stats_hourly (
id, hour_utc, total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, is_complete,
aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_requests = VALUES(total_requests),
success_requests = VALUES(success_requests),
error_requests = VALUES(error_requests),
input_tokens = VALUES(input_tokens),
output_tokens = VALUES(output_tokens),
cache_creation_tokens = VALUES(cache_creation_tokens),
cache_read_tokens = VALUES(cache_read_tokens),
total_cost = VALUES(total_cost),
actual_total_cost = VALUES(actual_total_cost),
avg_response_time_ms = VALUES(avg_response_time_ms),
is_complete = VALUES(is_complete),
aggregated_at = VALUES(aggregated_at),
updated_at = VALUES(updated_at)
"#,
)
.bind(stats_id(&format!("stats-hourly:{hour_utc_unix_secs}")))
.bind(hour_utc_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let user_rows = mysql_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
let user_model_rows = mysql_group_count(&mut tx, "user_id, model", start_ms, end_ms).await?;
let model_rows = mysql_group_count(&mut tx, "model", start_ms, end_ms).await?;
let provider_rows = mysql_group_count(&mut tx, "provider_name", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsHourlyAggregationSummary {
hour_utc: utc_from_unix_secs(hour_utc_unix_secs, "stats_hourly.hour_utc")?,
total_requests,
user_rows,
user_model_rows,
model_rows,
provider_rows,
})
}
async fn perform_mysql_stats_daily_aggregation(
pool: &MysqlPool,
day_start_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsDailyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(day_start_unix_secs)?;
let end_ms = unix_ms(day_start_unix_secs + 86_400)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(MYSQL_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
let unique_models = mysql_group_count(&mut tx, "model", start_ms, end_ms).await? as i64;
let unique_providers =
mysql_group_count(&mut tx, "provider_name", start_ms, end_ms).await? as i64;
sqlx::query(
r#"
INSERT INTO stats_daily (
id, `date`, total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, fallback_count,
unique_models, unique_providers, is_complete, aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, TRUE, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_requests = VALUES(total_requests),
success_requests = VALUES(success_requests),
error_requests = VALUES(error_requests),
input_tokens = VALUES(input_tokens),
output_tokens = VALUES(output_tokens),
cache_creation_tokens = VALUES(cache_creation_tokens),
cache_read_tokens = VALUES(cache_read_tokens),
total_cost = VALUES(total_cost),
actual_total_cost = VALUES(actual_total_cost),
avg_response_time_ms = VALUES(avg_response_time_ms),
fallback_count = VALUES(fallback_count),
unique_models = VALUES(unique_models),
unique_providers = VALUES(unique_providers),
is_complete = VALUES(is_complete),
aggregated_at = VALUES(aggregated_at),
updated_at = VALUES(updated_at)
"#,
)
.bind(stats_id(&format!("stats-daily:{day_start_unix_secs}")))
.bind(day_start_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(unique_models)
.bind(unique_providers)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let model_rows = usize::try_from(unique_models).unwrap_or(usize::MAX);
let provider_rows = usize::try_from(unique_providers).unwrap_or(usize::MAX);
let api_key_rows = mysql_group_count(&mut tx, "api_key_id", start_ms, end_ms).await?;
let error_rows = mysql_error_group_count(&mut tx, start_ms, end_ms).await?;
let user_rows = mysql_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsDailyAggregationSummary {
day_start_utc: utc_from_unix_secs(day_start_unix_secs, "stats_daily.date")?,
total_requests,
model_rows,
provider_rows,
api_key_rows,
error_rows,
user_rows,
})
}
async fn mysql_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
group_columns: &str,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let not_empty = group_columns
.split(',')
.map(str::trim)
.map(|column| format!("{column} IS NOT NULL AND {column} <> ''"))
.collect::<Vec<_>>()
.join(" AND ");
let sql = format!(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND {not_empty}
GROUP BY {group_columns}
) AS grouped
"#
);
let count: i64 = sqlx::query_scalar(&sql)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
async fn mysql_error_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND (
status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
)
GROUP BY COALESCE(NULLIF(error_category, ''), 'unknown_error'), provider_name, model
) AS grouped
"#,
)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}

View File

@@ -0,0 +1,638 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use uuid::Uuid;
use crate::backend::PostgresBackend;
use crate::{
error::postgres_error, DataLayerError, StatsDailyAggregationInput, StatsDailyAggregationSummary,
};
mod percentiles;
mod sql;
use self::percentiles::{percentile_ms_to_i64, PercentileSummary};
use self::sql::*;
impl PostgresBackend {
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
let Some(day_start_utc) = next_stats_aggregation_day(self.pool(), input.target_day_utc)
.await
.map_err(postgres_error)?
else {
return Ok(None);
};
perform_stats_aggregation_for_day(self.pool(), day_start_utc, input.aggregated_at)
.await
.map(Some)
.map_err(postgres_error)
}
}
async fn next_stats_aggregation_day(
pool: &crate::driver::postgres::PostgresPool,
target_day_utc: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>, sqlx::Error> {
let latest_row = sqlx::query(SELECT_LATEST_STATS_DAILY_DATE_SQL)
.fetch_one(pool)
.await?;
let latest_day = latest_row.try_get::<Option<DateTime<Utc>>, _>("latest_date")?;
let search_from = latest_day
.map(|value| value + chrono::Duration::days(1))
.unwrap_or_else(|| {
DateTime::<Utc>::from_timestamp(0, 0).expect("unix epoch should be valid")
});
let search_until = target_day_utc + chrono::Duration::days(1);
if search_from >= search_until {
return Ok(None);
}
let next_row = sqlx::query(SELECT_NEXT_STATS_DAILY_BUCKET_SQL)
.bind(search_from)
.bind(search_until)
.fetch_one(pool)
.await?;
let next_bucket = next_row.try_get::<Option<DateTime<Utc>>, _>("next_bucket")?;
Ok(next_bucket.filter(|value| *value <= target_day_utc))
}
async fn perform_stats_aggregation_for_day(
pool: &crate::driver::postgres::PostgresPool,
day_start_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<StatsDailyAggregationSummary, sqlx::Error> {
let day_end_utc = day_start_utc + chrono::Duration::days(1);
let mut tx = pool.begin().await?;
let aggregate_row = sqlx::query(SELECT_STATS_DAILY_AGGREGATE_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.fetch_one(&mut *tx)
.await?;
let total_requests = aggregate_row.try_get::<i64, _>("total_requests")?;
let error_requests = aggregate_row.try_get::<i64, _>("error_requests")?;
let success_requests = total_requests.saturating_sub(error_requests);
let fallback_count = sqlx::query(SELECT_STATS_DAILY_FALLBACK_COUNT_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(vec!["success", "failed"])
.fetch_one(&mut *tx)
.await?
.try_get::<i64, _>("fallback_count")?;
let response_percentiles = fetch_stats_daily_percentiles(
&mut tx,
SELECT_STATS_DAILY_RESPONSE_TIME_PERCENTILES_SQL,
day_start_utc,
day_end_utc,
)
.await?;
let first_byte_percentiles = fetch_stats_daily_percentiles(
&mut tx,
SELECT_STATS_DAILY_FIRST_BYTE_PERCENTILES_SQL,
day_start_utc,
day_end_utc,
)
.await?;
sqlx::query(UPSERT_STATS_DAILY_SQL)
.bind(Uuid::new_v4().to_string())
.bind(day_start_utc)
.bind(total_requests)
.bind(aggregate_row.try_get::<i64, _>("cache_hit_total_requests")?)
.bind(aggregate_row.try_get::<i64, _>("cache_hit_requests")?)
.bind(aggregate_row.try_get::<i64, _>("completed_total_requests")?)
.bind(aggregate_row.try_get::<i64, _>("completed_cache_hit_requests")?)
.bind(aggregate_row.try_get::<i64, _>("completed_input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("completed_cache_creation_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("completed_cache_read_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("completed_total_input_context")?)
.bind(aggregate_row.try_get::<f64, _>("completed_cache_creation_cost")?)
.bind(aggregate_row.try_get::<f64, _>("completed_cache_read_cost")?)
.bind(aggregate_row.try_get::<f64, _>("settled_total_cost")?)
.bind(aggregate_row.try_get::<i64, _>("settled_total_requests")?)
.bind(aggregate_row.try_get::<i64, _>("settled_input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("settled_output_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("settled_cache_creation_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("settled_cache_read_tokens")?)
.bind(aggregate_row.try_get::<Option<i64>, _>("settled_first_finalized_at_unix_secs")?)
.bind(aggregate_row.try_get::<Option<i64>, _>("settled_last_finalized_at_unix_secs")?)
.bind(success_requests)
.bind(error_requests)
.bind(aggregate_row.try_get::<i64, _>("input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("effective_input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("output_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_creation_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_creation_ephemeral_5m_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_creation_ephemeral_1h_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_read_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("total_input_context")?)
.bind(aggregate_row.try_get::<f64, _>("total_cost")?)
.bind(aggregate_row.try_get::<f64, _>("actual_total_cost")?)
.bind(aggregate_row.try_get::<f64, _>("input_cost")?)
.bind(aggregate_row.try_get::<f64, _>("output_cost")?)
.bind(aggregate_row.try_get::<f64, _>("cache_creation_cost")?)
.bind(aggregate_row.try_get::<f64, _>("cache_read_cost")?)
.bind(aggregate_row.try_get::<f64, _>("response_time_sum_ms")?)
.bind(aggregate_row.try_get::<i64, _>("response_time_samples")?)
.bind(aggregate_row.try_get::<f64, _>("avg_response_time_ms")?)
.bind(response_percentiles.p50)
.bind(response_percentiles.p90)
.bind(response_percentiles.p99)
.bind(first_byte_percentiles.p50)
.bind(first_byte_percentiles.p90)
.bind(first_byte_percentiles.p99)
.bind(fallback_count)
.bind(aggregate_row.try_get::<i64, _>("unique_models")?)
.bind(aggregate_row.try_get::<i64, _>("unique_providers")?)
.bind(true)
.bind(now_utc)
.bind(now_utc)
.bind(now_utc)
.execute(&mut *tx)
.await?;
let model_rows =
upsert_stats_daily_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
let provider_rows =
upsert_stats_daily_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_daily_model_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_daily_cost_savings_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_daily_cost_savings_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_daily_cost_savings_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_daily_cost_savings_model_provider_rows(
&mut tx,
day_start_utc,
day_end_utc,
now_utc,
)
.await?;
let api_key_rows =
upsert_stats_daily_api_key_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
let error_rows =
refresh_stats_daily_error_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
let user_rows =
upsert_stats_user_daily_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_model_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_user_daily_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_cost_savings_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_cost_savings_provider_rows(
&mut tx,
day_start_utc,
day_end_utc,
now_utc,
)
.await?;
upsert_stats_user_daily_cost_savings_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_user_daily_cost_savings_model_provider_rows(
&mut tx,
day_start_utc,
day_end_utc,
now_utc,
)
.await?;
upsert_stats_user_daily_api_format_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
refresh_stats_summary_row(&mut tx, day_end_utc, now_utc).await?;
refresh_stats_user_summary_rows(&mut tx, day_end_utc, now_utc).await?;
tx.commit().await?;
Ok(StatsDailyAggregationSummary {
day_start_utc,
total_requests,
model_rows,
provider_rows,
api_key_rows,
error_rows,
user_rows,
})
}
async fn fetch_stats_daily_percentiles(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
sql: &str,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
) -> Result<PercentileSummary, sqlx::Error> {
let row = sqlx::query(sql)
.bind(day_start_utc)
.bind(day_end_utc)
.fetch_one(&mut **tx)
.await?;
let sample_count = row.try_get::<i64, _>("sample_count")?;
if sample_count < 10 {
return Ok(PercentileSummary::default());
}
Ok(PercentileSummary {
p50: percentile_ms_to_i64(row.try_get::<Option<f64>, _>("p50")?),
p90: percentile_ms_to_i64(row.try_get::<Option<f64>, _>("p90")?),
p99: percentile_ms_to_i64(row.try_get::<Option<f64>, _>("p99")?),
})
}
async fn upsert_stats_daily_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_api_key_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_API_KEY_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn refresh_stats_daily_error_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
sqlx::query(DELETE_STATS_DAILY_ERRORS_FOR_DATE_SQL)
.bind(day_start_utc)
.execute(&mut **tx)
.await?;
let rows_affected = sqlx::query(INSERT_STATS_DAILY_ERROR_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_api_format_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_API_FORMAT_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn refresh_stats_summary_row(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cutoff_date: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
let totals_row = sqlx::query(SELECT_STATS_SUMMARY_TOTALS_SQL)
.bind(cutoff_date)
.fetch_one(&mut **tx)
.await?;
let entity_counts_row = sqlx::query(SELECT_STATS_SUMMARY_ENTITY_COUNTS_SQL)
.fetch_one(&mut **tx)
.await?;
let existing_summary_id = sqlx::query_scalar::<_, String>(SELECT_EXISTING_STATS_SUMMARY_ID_SQL)
.fetch_optional(&mut **tx)
.await?;
let all_time_requests = totals_row.try_get::<i64, _>("all_time_requests")?;
let all_time_success_requests = totals_row.try_get::<i64, _>("all_time_success_requests")?;
let all_time_error_requests = totals_row.try_get::<i64, _>("all_time_error_requests")?;
let all_time_input_tokens = totals_row.try_get::<i64, _>("all_time_input_tokens")?;
let all_time_output_tokens = totals_row.try_get::<i64, _>("all_time_output_tokens")?;
let all_time_cache_creation_tokens =
totals_row.try_get::<i64, _>("all_time_cache_creation_tokens")?;
let all_time_cache_read_tokens = totals_row.try_get::<i64, _>("all_time_cache_read_tokens")?;
let all_time_cost = totals_row.try_get::<f64, _>("all_time_cost")?;
let all_time_actual_cost = totals_row.try_get::<f64, _>("all_time_actual_cost")?;
let total_users = entity_counts_row.try_get::<i64, _>("total_users")?;
let active_users = entity_counts_row.try_get::<i64, _>("active_users")?;
let total_api_keys = entity_counts_row.try_get::<i64, _>("total_api_keys")?;
let active_api_keys = entity_counts_row.try_get::<i64, _>("active_api_keys")?;
if let Some(summary_id) = existing_summary_id {
sqlx::query(UPDATE_STATS_SUMMARY_SQL)
.bind(summary_id)
.bind(cutoff_date)
.bind(all_time_requests)
.bind(all_time_success_requests)
.bind(all_time_error_requests)
.bind(all_time_input_tokens)
.bind(all_time_output_tokens)
.bind(all_time_cache_creation_tokens)
.bind(all_time_cache_read_tokens)
.bind(all_time_cost)
.bind(all_time_actual_cost)
.bind(total_users)
.bind(active_users)
.bind(total_api_keys)
.bind(active_api_keys)
.bind(now_utc)
.execute(&mut **tx)
.await?;
} else {
sqlx::query(INSERT_STATS_SUMMARY_SQL)
.bind(Uuid::new_v4().to_string())
.bind(cutoff_date)
.bind(all_time_requests)
.bind(all_time_success_requests)
.bind(all_time_error_requests)
.bind(all_time_input_tokens)
.bind(all_time_output_tokens)
.bind(all_time_cache_creation_tokens)
.bind(all_time_cache_read_tokens)
.bind(all_time_cost)
.bind(all_time_actual_cost)
.bind(total_users)
.bind(active_users)
.bind(total_api_keys)
.bind(active_api_keys)
.bind(now_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn refresh_stats_user_summary_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cutoff_date: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
sqlx::query(UPSERT_STATS_USER_SUMMARY_SQL)
.bind(cutoff_date)
.bind(now_utc)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -0,0 +1,10 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) struct PercentileSummary {
pub(super) p50: Option<i64>,
pub(super) p90: Option<i64>,
pub(super) p99: Option<i64>,
}
pub(super) fn percentile_ms_to_i64(value: Option<f64>) -> Option<i64> {
value.and_then(|raw| raw.is_finite().then_some(raw.floor() as i64))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use uuid::Uuid;
use crate::backend::PostgresBackend;
use crate::{
error::postgres_error, DataLayerError, StatsHourlyAggregationInput,
StatsHourlyAggregationSummary,
};
mod sql;
use self::sql::*;
impl PostgresBackend {
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
let Some(hour_utc) = next_stats_hourly_bucket(self.pool(), input.target_hour_utc)
.await
.map_err(postgres_error)?
else {
return Ok(None);
};
perform_stats_hourly_aggregation_for_hour(self.pool(), hour_utc, input.aggregated_at)
.await
.map(Some)
.map_err(postgres_error)
}
}
async fn next_stats_hourly_bucket(
pool: &crate::driver::postgres::PostgresPool,
target_hour_utc: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>, sqlx::Error> {
let latest_row = sqlx::query(SELECT_LATEST_STATS_HOURLY_HOUR_SQL)
.fetch_one(pool)
.await?;
let latest_hour = latest_row.try_get::<Option<DateTime<Utc>>, _>("latest_hour")?;
let search_from = latest_hour
.map(|value| value + chrono::Duration::hours(1))
.unwrap_or_else(|| {
DateTime::<Utc>::from_timestamp(0, 0).expect("unix epoch should be valid")
});
let search_until = target_hour_utc + chrono::Duration::hours(1);
if search_from >= search_until {
return Ok(None);
}
let next_row = sqlx::query(SELECT_NEXT_STATS_HOURLY_BUCKET_SQL)
.bind(search_from)
.bind(search_until)
.fetch_one(pool)
.await?;
let next_bucket = next_row.try_get::<Option<DateTime<Utc>>, _>("next_bucket")?;
Ok(next_bucket.filter(|value| *value <= target_hour_utc))
}
async fn perform_stats_hourly_aggregation_for_hour(
pool: &crate::driver::postgres::PostgresPool,
hour_utc: DateTime<Utc>,
aggregated_at: DateTime<Utc>,
) -> Result<StatsHourlyAggregationSummary, sqlx::Error> {
let hour_end = hour_utc + chrono::Duration::hours(1);
let mut tx = pool.begin().await?;
let row = sqlx::query(SELECT_STATS_HOURLY_AGGREGATE_SQL)
.bind(hour_utc)
.bind(hour_end)
.fetch_one(&mut *tx)
.await?;
let total_requests = row.try_get::<i64, _>("total_requests")?;
let error_requests = row.try_get::<i64, _>("error_requests")?;
let success_requests = total_requests.saturating_sub(error_requests);
sqlx::query(UPSERT_STATS_HOURLY_SQL)
.bind(Uuid::new_v4().to_string())
.bind(hour_utc)
.bind(total_requests)
.bind(row.try_get::<i64, _>("cache_hit_total_requests")?)
.bind(row.try_get::<i64, _>("cache_hit_requests")?)
.bind(row.try_get::<i64, _>("completed_total_requests")?)
.bind(row.try_get::<i64, _>("completed_cache_hit_requests")?)
.bind(row.try_get::<i64, _>("completed_input_tokens")?)
.bind(row.try_get::<i64, _>("completed_cache_creation_tokens")?)
.bind(row.try_get::<i64, _>("completed_cache_read_tokens")?)
.bind(row.try_get::<i64, _>("completed_total_input_context")?)
.bind(row.try_get::<f64, _>("completed_cache_creation_cost")?)
.bind(row.try_get::<f64, _>("completed_cache_read_cost")?)
.bind(row.try_get::<f64, _>("settled_total_cost")?)
.bind(row.try_get::<i64, _>("settled_total_requests")?)
.bind(row.try_get::<i64, _>("settled_input_tokens")?)
.bind(row.try_get::<i64, _>("settled_output_tokens")?)
.bind(row.try_get::<i64, _>("settled_cache_creation_tokens")?)
.bind(row.try_get::<i64, _>("settled_cache_read_tokens")?)
.bind(row.try_get::<Option<i64>, _>("settled_first_finalized_at_unix_secs")?)
.bind(row.try_get::<Option<i64>, _>("settled_last_finalized_at_unix_secs")?)
.bind(success_requests)
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens")?)
.bind(row.try_get::<i64, _>("output_tokens")?)
.bind(row.try_get::<i64, _>("cache_creation_tokens")?)
.bind(row.try_get::<i64, _>("cache_read_tokens")?)
.bind(row.try_get::<f64, _>("total_cost")?)
.bind(row.try_get::<f64, _>("actual_total_cost")?)
.bind(row.try_get::<f64, _>("response_time_sum_ms")?)
.bind(row.try_get::<i64, _>("response_time_samples")?)
.bind(row.try_get::<f64, _>("avg_response_time_ms")?)
.bind(true)
.bind(aggregated_at)
.bind(aggregated_at)
.bind(aggregated_at)
.execute(&mut *tx)
.await?;
let user_rows =
upsert_stats_hourly_user_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
let user_model_rows =
upsert_stats_hourly_user_model_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
let model_rows =
upsert_stats_hourly_model_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
let provider_rows =
upsert_stats_hourly_provider_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
tx.commit().await?;
Ok(StatsHourlyAggregationSummary {
hour_utc,
total_requests,
user_rows,
user_model_rows,
model_rows,
provider_rows,
})
}
async fn upsert_stats_hourly_user_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_USER_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_hourly_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_MODEL_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_hourly_user_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_USER_MODEL_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_hourly_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_PROVIDER_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}

View File

@@ -0,0 +1,906 @@
pub(super) const SELECT_LATEST_STATS_HOURLY_HOUR_SQL: &str = r#"
SELECT MAX(hour_utc) AS latest_hour
FROM stats_hourly
WHERE is_complete IS TRUE
"#;
pub(super) const SELECT_NEXT_STATS_HOURLY_BUCKET_SQL: &str = r#"
SELECT date_trunc('hour', MIN(created_at)) AS next_bucket
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
pub(super) const SELECT_STATS_HOURLY_AGGREGATE_SQL: &str = r#"
SELECT
(
SELECT CAST(COUNT(cache_hit_usage.id) AS BIGINT)
FROM usage_billing_facts AS cache_hit_usage
WHERE cache_hit_usage.created_at >= $1
AND cache_hit_usage.created_at < $2
) AS cache_hit_total_requests,
(
SELECT CAST(
COUNT(cache_hit_usage.id) FILTER (
WHERE GREATEST(COALESCE(cache_hit_usage.cache_read_input_tokens, 0), 0) > 0
) AS BIGINT
)
FROM usage_billing_facts AS cache_hit_usage
WHERE cache_hit_usage.created_at >= $1
AND cache_hit_usage.created_at < $2
) AS cache_hit_requests,
(
SELECT CAST(COUNT(completed_usage.id) AS BIGINT)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_total_requests,
(
SELECT CAST(
COUNT(completed_usage.id) FILTER (
WHERE GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0) > 0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_hit_requests,
(
SELECT CAST(
COALESCE(SUM(GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)), 0) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_input_tokens,
(
SELECT CAST(
COALESCE(
SUM(
CASE
WHEN COALESCE(completed_usage.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(completed_usage.cache_creation_input_tokens, 0)
END
),
0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_creation_tokens,
(
SELECT CAST(
COALESCE(
SUM(GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)),
0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_read_tokens,
(
SELECT CAST(
COALESCE(
SUM(
CASE
WHEN split_part(
lower(
COALESCE(
COALESCE(
completed_usage.endpoint_api_format,
completed_usage.api_format
),
''
)
),
':',
1
) IN ('claude', 'anthropic')
THEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(completed_usage.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(completed_usage.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
WHEN split_part(
lower(
COALESCE(
COALESCE(
completed_usage.endpoint_api_format,
completed_usage.api_format
),
''
)
),
':',
1
) IN ('openai', 'gemini', 'google')
THEN (
CASE
WHEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0) <= 0
THEN 0
WHEN GREATEST(
COALESCE(completed_usage.cache_read_input_tokens, 0),
0
) <= 0
THEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
ELSE GREATEST(
GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
- GREATEST(
COALESCE(completed_usage.cache_read_input_tokens, 0),
0
),
0
)
END
) + GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
ELSE CASE
WHEN (
CASE
WHEN COALESCE(
completed_usage.cache_creation_input_tokens,
0
) = 0
AND (
COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
) > 0
THEN COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
ELSE COALESCE(
completed_usage.cache_creation_input_tokens,
0
)
END
) > 0
THEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
+ (
CASE
WHEN COALESCE(
completed_usage.cache_creation_input_tokens,
0
) = 0
AND (
COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
) > 0
THEN COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
ELSE COALESCE(
completed_usage.cache_creation_input_tokens,
0
)
END
)
+ GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
ELSE GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
+ GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
END
END
),
0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_total_input_context,
(
SELECT CAST(
COALESCE(
SUM(
COALESCE(
CAST(completed_usage.cache_creation_cost_usd AS DOUBLE PRECISION),
0
)
),
0
) AS DOUBLE PRECISION
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_creation_cost,
(
SELECT CAST(
COALESCE(
SUM(
COALESCE(CAST(completed_usage.cache_read_cost_usd AS DOUBLE PRECISION), 0)
),
0
) AS DOUBLE PRECISION
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_read_cost,
(
SELECT CAST(
COALESCE(SUM(COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0)), 0)
AS DOUBLE PRECISION
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_total_cost,
(
SELECT CAST(COUNT(settled_usage.id) AS BIGINT)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_total_requests,
(
SELECT CAST(
COALESCE(SUM(GREATEST(COALESCE(settled_usage.input_tokens, 0), 0)), 0) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_input_tokens,
(
SELECT CAST(
COALESCE(SUM(GREATEST(COALESCE(settled_usage.output_tokens, 0), 0)), 0) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_output_tokens,
(
SELECT CAST(
COALESCE(
SUM(GREATEST(COALESCE(settled_usage.cache_creation_input_tokens, 0), 0)),
0
) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_cache_creation_tokens,
(
SELECT CAST(
COALESCE(
SUM(GREATEST(COALESCE(settled_usage.cache_read_input_tokens, 0), 0)),
0
) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_cache_read_tokens,
(
SELECT MIN(CAST(EXTRACT(EPOCH FROM settled_usage.finalized_at) AS BIGINT))
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_first_finalized_at_unix_secs,
(
SELECT MAX(CAST(EXTRACT(EPOCH FROM settled_usage.finalized_at) AS BIGINT))
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_last_finalized_at_unix_secs,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(
SUM(
CASE
WHEN status_code >= 400
OR lower(COALESCE(status, '')) = 'failed'
OR error_message IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS error_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN COALESCE(cache_creation_input_tokens, 0) = 0
AND (
COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
ELSE COALESCE(cache_creation_input_tokens, 0)
END
),
0
) AS BIGINT) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_input_tokens), 0) AS BIGINT) AS cache_read_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
CAST(COALESCE(SUM(actual_total_cost_usd), 0) AS DOUBLE PRECISION) AS actual_total_cost,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples,
CAST(COALESCE(AVG(response_time_ms), 0) AS DOUBLE PRECISION) AS avg_response_time_ms
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
pub(super) const UPSERT_STATS_HOURLY_SQL: &str = r#"
INSERT INTO stats_hourly (
id,
hour_utc,
total_requests,
cache_hit_total_requests,
cache_hit_requests,
completed_total_requests,
completed_cache_hit_requests,
completed_input_tokens,
completed_cache_creation_tokens,
completed_cache_read_tokens,
completed_total_input_context,
completed_cache_creation_cost,
completed_cache_read_cost,
settled_total_cost,
settled_total_requests,
settled_input_tokens,
settled_output_tokens,
settled_cache_creation_tokens,
settled_cache_read_tokens,
settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs,
success_requests,
error_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
total_cost,
actual_total_cost,
response_time_sum_ms,
response_time_samples,
avg_response_time_ms,
is_complete,
aggregated_at,
created_at,
updated_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13, $14, $15, $16,
$17, $18, $19, $20, $21, $22, $23, $24,
$25, $26, $27, $28, $29, $30, $31, $32,
$33, $34, $35, $36
)
ON CONFLICT (hour_utc)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
cache_hit_total_requests = EXCLUDED.cache_hit_total_requests,
cache_hit_requests = EXCLUDED.cache_hit_requests,
completed_total_requests = EXCLUDED.completed_total_requests,
completed_cache_hit_requests = EXCLUDED.completed_cache_hit_requests,
completed_input_tokens = EXCLUDED.completed_input_tokens,
completed_cache_creation_tokens = EXCLUDED.completed_cache_creation_tokens,
completed_cache_read_tokens = EXCLUDED.completed_cache_read_tokens,
completed_total_input_context = EXCLUDED.completed_total_input_context,
completed_cache_creation_cost = EXCLUDED.completed_cache_creation_cost,
completed_cache_read_cost = EXCLUDED.completed_cache_read_cost,
settled_total_cost = EXCLUDED.settled_total_cost,
settled_total_requests = EXCLUDED.settled_total_requests,
settled_input_tokens = EXCLUDED.settled_input_tokens,
settled_output_tokens = EXCLUDED.settled_output_tokens,
settled_cache_creation_tokens = EXCLUDED.settled_cache_creation_tokens,
settled_cache_read_tokens = EXCLUDED.settled_cache_read_tokens,
settled_first_finalized_at_unix_secs = EXCLUDED.settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs = EXCLUDED.settled_last_finalized_at_unix_secs,
success_requests = EXCLUDED.success_requests,
error_requests = EXCLUDED.error_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_creation_tokens = EXCLUDED.cache_creation_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
total_cost = EXCLUDED.total_cost,
actual_total_cost = EXCLUDED.actual_total_cost,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
avg_response_time_ms = EXCLUDED.avg_response_time_ms,
is_complete = EXCLUDED.is_complete,
aggregated_at = EXCLUDED.aggregated_at,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_USER_SQL: &str = r#"
WITH aggregated AS (
SELECT
user_id,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(
SUM(
CASE
WHEN status_code >= 400
OR lower(COALESCE(status, '')) = 'failed'
OR error_message IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS error_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN COALESCE(cache_creation_input_tokens, 0) = 0
AND (
COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
ELSE COALESCE(cache_creation_input_tokens, 0)
END
),
0
) AS BIGINT) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_input_tokens), 0) AS BIGINT) AS cache_read_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
CAST(COALESCE(SUM(actual_total_cost_usd), 0) AS DOUBLE PRECISION) AS actual_total_cost,
CAST(
COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0)
ELSE 0
END
),
0
) AS DOUBLE PRECISION
) AS settled_total_cost,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS settled_total_requests,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(input_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_input_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(output_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_output_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(cache_creation_input_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_cache_creation_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(cache_read_input_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_cache_read_tokens,
MIN(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
AND finalized_at IS NOT NULL
THEN CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT)
ELSE NULL
END
) AS settled_first_finalized_at_unix_secs,
MAX(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
AND finalized_at IS NOT NULL
THEN CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT)
ELSE NULL
END
) AS settled_last_finalized_at_unix_secs,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND user_id IS NOT NULL
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY user_id
)
INSERT INTO stats_hourly_user (
id,
hour_utc,
user_id,
total_requests,
success_requests,
error_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
total_cost,
actual_total_cost,
settled_total_cost,
settled_total_requests,
settled_input_tokens,
settled_output_tokens,
settled_cache_creation_tokens,
settled_cache_read_tokens,
settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs,
response_time_sum_ms,
response_time_samples,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-user:', aggregated.user_id, ':', CAST($1 AS TEXT))),
$1,
aggregated.user_id,
aggregated.total_requests,
GREATEST(aggregated.total_requests - aggregated.error_requests, 0),
aggregated.error_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.cache_creation_tokens,
aggregated.cache_read_tokens,
aggregated.total_cost,
aggregated.actual_total_cost,
aggregated.settled_total_cost,
aggregated.settled_total_requests,
aggregated.settled_input_tokens,
aggregated.settled_output_tokens,
aggregated.settled_cache_creation_tokens,
aggregated.settled_cache_read_tokens,
aggregated.settled_first_finalized_at_unix_secs,
aggregated.settled_last_finalized_at_unix_secs,
aggregated.response_time_sum_ms,
aggregated.response_time_samples,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, user_id)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
success_requests = EXCLUDED.success_requests,
error_requests = EXCLUDED.error_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_creation_tokens = EXCLUDED.cache_creation_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
total_cost = EXCLUDED.total_cost,
actual_total_cost = EXCLUDED.actual_total_cost,
settled_total_cost = EXCLUDED.settled_total_cost,
settled_total_requests = EXCLUDED.settled_total_requests,
settled_input_tokens = EXCLUDED.settled_input_tokens,
settled_output_tokens = EXCLUDED.settled_output_tokens,
settled_cache_creation_tokens = EXCLUDED.settled_cache_creation_tokens,
settled_cache_read_tokens = EXCLUDED.settled_cache_read_tokens,
settled_first_finalized_at_unix_secs = EXCLUDED.settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs = EXCLUDED.settled_last_finalized_at_unix_secs,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_MODEL_SQL: &str = r#"
WITH aggregated AS (
SELECT
model,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples,
CAST(COALESCE(AVG(response_time_ms), 0) AS DOUBLE PRECISION) AS avg_response_time_ms
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND model IS NOT NULL
AND model <> ''
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY model
)
INSERT INTO stats_hourly_model (
id,
hour_utc,
model,
total_requests,
input_tokens,
output_tokens,
total_cost,
response_time_sum_ms,
response_time_samples,
avg_response_time_ms,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-model:', aggregated.model, ':', CAST($1 AS TEXT))),
$1,
aggregated.model,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.total_cost,
aggregated.response_time_sum_ms,
aggregated.response_time_samples,
aggregated.avg_response_time_ms,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, model)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
total_cost = EXCLUDED.total_cost,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
avg_response_time_ms = EXCLUDED.avg_response_time_ms,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_USER_MODEL_SQL: &str = r#"
WITH aggregated AS (
SELECT
user_id,
model,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND user_id IS NOT NULL
AND model IS NOT NULL
AND model <> ''
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY user_id, model
)
INSERT INTO stats_hourly_user_model (
id,
hour_utc,
user_id,
model,
total_requests,
input_tokens,
output_tokens,
total_cost,
response_time_sum_ms,
response_time_samples,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-user-model:', aggregated.user_id, ':', aggregated.model, ':', CAST($1 AS TEXT))),
$1,
aggregated.user_id,
aggregated.model,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.total_cost,
aggregated.response_time_sum_ms,
aggregated.response_time_samples,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, user_id, model)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
total_cost = EXCLUDED.total_cost,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_PROVIDER_SQL: &str = r#"
WITH aggregated AS (
SELECT
provider_name,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND provider_name IS NOT NULL
AND provider_name <> ''
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY provider_name
)
INSERT INTO stats_hourly_provider (
id,
hour_utc,
provider_name,
total_requests,
input_tokens,
output_tokens,
total_cost,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-provider:', aggregated.provider_name, ':', CAST($1 AS TEXT))),
$1,
aggregated.provider_name,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.total_cost,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, provider_name)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
total_cost = EXCLUDED.total_cost,
updated_at = EXCLUDED.updated_at
"#;

View File

@@ -0,0 +1,379 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use crate::backend::stats_common::{stats_id, unix_ms, unix_secs, utc_from_unix_secs};
use crate::backend::SqliteBackend;
use crate::driver::sqlite::SqlitePool;
use crate::error::SqlResultExt;
use crate::{
DataLayerError, StatsDailyAggregationInput, StatsDailyAggregationSummary,
StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
};
impl SqliteBackend {
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
let Some(hour_utc_unix_secs) =
next_sqlite_stats_hourly_bucket(self.pool(), input.target_hour_utc).await?
else {
return Ok(None);
};
perform_sqlite_stats_hourly_aggregation(
self.pool(),
hour_utc_unix_secs,
input.aggregated_at,
)
.await
.map(Some)
}
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
let Some(day_start_unix_secs) =
next_sqlite_stats_daily_bucket(self.pool(), input.target_day_utc).await?
else {
return Ok(None);
};
perform_sqlite_stats_daily_aggregation(
self.pool(),
day_start_unix_secs,
input.aggregated_at,
)
.await
.map(Some)
}
}
async fn next_sqlite_stats_hourly_bucket(
pool: &SqlitePool,
target_hour_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_hour: Option<i64> =
sqlx::query_scalar("SELECT MAX(hour_utc) FROM stats_hourly WHERE is_complete <> 0")
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_hour.map(|value| value + 3600).unwrap_or(0);
let search_until = unix_secs(target_hour_utc) + 3600;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(CAST(created_at_unix_ms / 3600000 AS INTEGER) * 3600)
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_hour_utc)))
}
async fn next_sqlite_stats_daily_bucket(
pool: &SqlitePool,
target_day_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_day: Option<i64> =
sqlx::query_scalar(r#"SELECT MAX("date") FROM stats_daily WHERE is_complete <> 0"#)
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_day.map(|value| value + 86_400).unwrap_or(0);
let search_until = unix_secs(target_day_utc) + 86_400;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(CAST(created_at_unix_ms / 86400000 AS INTEGER) * 86400)
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_day_utc)))
}
const SQLITE_STATS_AGGREGATE_SQL: &str = r#"
SELECT
COUNT(*) AS total_requests,
COALESCE(SUM(CASE
WHEN status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
THEN 1 ELSE 0 END), 0) AS error_requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(total_cost_usd), 0.0) AS total_cost,
COALESCE(SUM(actual_total_cost_usd), 0.0) AS actual_total_cost,
COALESCE(AVG(response_time_ms), 0.0) AS avg_response_time_ms
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
async fn perform_sqlite_stats_hourly_aggregation(
pool: &SqlitePool,
hour_utc_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsHourlyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(hour_utc_unix_secs)?;
let end_ms = unix_ms(hour_utc_unix_secs + 3600)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(SQLITE_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
sqlx::query(
r#"
INSERT INTO stats_hourly (
id, hour_utc, total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, is_complete,
aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT (hour_utc) DO UPDATE SET
total_requests = excluded.total_requests,
success_requests = excluded.success_requests,
error_requests = excluded.error_requests,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_creation_tokens = excluded.cache_creation_tokens,
cache_read_tokens = excluded.cache_read_tokens,
total_cost = excluded.total_cost,
actual_total_cost = excluded.actual_total_cost,
avg_response_time_ms = excluded.avg_response_time_ms,
is_complete = excluded.is_complete,
aggregated_at = excluded.aggregated_at,
updated_at = excluded.updated_at
"#,
)
.bind(stats_id(&format!("stats-hourly:{hour_utc_unix_secs}")))
.bind(hour_utc_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let user_rows = sqlite_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
let user_model_rows = sqlite_group_count(&mut tx, "user_id, model", start_ms, end_ms).await?;
let model_rows = sqlite_group_count(&mut tx, "model", start_ms, end_ms).await?;
let provider_rows = sqlite_group_count(&mut tx, "provider_name", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsHourlyAggregationSummary {
hour_utc: utc_from_unix_secs(hour_utc_unix_secs, "stats_hourly.hour_utc")?,
total_requests,
user_rows,
user_model_rows,
model_rows,
provider_rows,
})
}
async fn perform_sqlite_stats_daily_aggregation(
pool: &SqlitePool,
day_start_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsDailyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(day_start_unix_secs)?;
let end_ms = unix_ms(day_start_unix_secs + 86_400)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(SQLITE_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
let unique_models = sqlite_group_count(&mut tx, "model", start_ms, end_ms).await? as i64;
let unique_providers =
sqlite_group_count(&mut tx, "provider_name", start_ms, end_ms).await? as i64;
sqlx::query(
r#"
INSERT INTO stats_daily (
id, "date", total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, fallback_count,
unique_models, unique_providers, is_complete, aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, ?, ?, ?)
ON CONFLICT ("date") DO UPDATE SET
total_requests = excluded.total_requests,
success_requests = excluded.success_requests,
error_requests = excluded.error_requests,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_creation_tokens = excluded.cache_creation_tokens,
cache_read_tokens = excluded.cache_read_tokens,
total_cost = excluded.total_cost,
actual_total_cost = excluded.actual_total_cost,
avg_response_time_ms = excluded.avg_response_time_ms,
fallback_count = excluded.fallback_count,
unique_models = excluded.unique_models,
unique_providers = excluded.unique_providers,
is_complete = excluded.is_complete,
aggregated_at = excluded.aggregated_at,
updated_at = excluded.updated_at
"#,
)
.bind(stats_id(&format!("stats-daily:{day_start_unix_secs}")))
.bind(day_start_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(unique_models)
.bind(unique_providers)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let model_rows = usize::try_from(unique_models).unwrap_or(usize::MAX);
let provider_rows = usize::try_from(unique_providers).unwrap_or(usize::MAX);
let api_key_rows = sqlite_group_count(&mut tx, "api_key_id", start_ms, end_ms).await?;
let error_rows = sqlite_error_group_count(&mut tx, start_ms, end_ms).await?;
let user_rows = sqlite_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsDailyAggregationSummary {
day_start_utc: utc_from_unix_secs(day_start_unix_secs, "stats_daily.date")?,
total_requests,
model_rows,
provider_rows,
api_key_rows,
error_rows,
user_rows,
})
}
async fn sqlite_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
group_columns: &str,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let not_empty = group_columns
.split(',')
.map(str::trim)
.map(|column| format!("{column} IS NOT NULL AND {column} <> ''"))
.collect::<Vec<_>>()
.join(" AND ");
let sql = format!(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND {not_empty}
GROUP BY {group_columns}
)
"#
);
let count: i64 = sqlx::query_scalar(&sql)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
async fn sqlite_error_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND (
status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
)
GROUP BY COALESCE(NULLIF(error_category, ''), 'unknown_error'), provider_name, model
)
"#,
)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}

View File

@@ -0,0 +1,33 @@
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use crate::DataLayerError;
pub(crate) fn unix_secs(value: DateTime<Utc>) -> i64 {
value.timestamp().max(0)
}
pub(crate) fn unix_ms(value: i64) -> Result<i64, DataLayerError> {
value.checked_mul(1000).ok_or_else(|| {
DataLayerError::InvalidInput(format!("timestamp overflow while converting {value} to ms"))
})
}
pub(crate) fn utc_from_unix_secs(
value: i64,
field_name: &str,
) -> Result<DateTime<Utc>, DataLayerError> {
DateTime::<Utc>::from_timestamp(value, 0).ok_or_else(|| {
DataLayerError::UnexpectedValue(format!("{field_name} contains invalid timestamp {value}"))
})
}
pub(crate) fn stats_id(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}

View File

@@ -0,0 +1,502 @@
use futures_util::TryStreamExt;
use sqlx::Row;
use super::{MysqlBackend, PostgresBackend, SqliteBackend};
use crate::error::{SqlResultExt, SqlxResultExt};
use crate::repository::system::{AdminSystemStats, StoredSystemConfigEntry};
use crate::DataLayerError;
const POSTGRES_FIND_SYSTEM_CONFIG_VALUE_SQL: &str = r#"
SELECT value
FROM system_configs
WHERE key = $1
LIMIT 1
"#;
const POSTGRES_UPSERT_SYSTEM_CONFIG_VALUE_SQL: &str = r#"
INSERT INTO system_configs (id, key, value, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
description = COALESCE(EXCLUDED.description, system_configs.description),
updated_at = NOW()
RETURNING value
"#;
const POSTGRES_LIST_SYSTEM_CONFIG_ENTRIES_SQL: &str = r#"
SELECT
key,
value,
description,
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
FROM system_configs
ORDER BY key ASC
"#;
const POSTGRES_UPSERT_SYSTEM_CONFIG_ENTRY_SQL: &str = r#"
INSERT INTO system_configs (id, key, value, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
description = COALESCE(EXCLUDED.description, system_configs.description),
updated_at = NOW()
RETURNING
key,
value,
description,
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
"#;
const POSTGRES_DELETE_SYSTEM_CONFIG_VALUE_SQL: &str = r#"
DELETE FROM system_configs
WHERE key = $1
"#;
const POSTGRES_READ_ADMIN_SYSTEM_STATS_SQL: &str = r#"
SELECT
(SELECT COUNT(*) FROM users) AS total_users,
(SELECT COUNT(*) FROM users WHERE is_active IS TRUE) AS active_users,
(SELECT COUNT(*) FROM api_keys) AS total_api_keys,
(SELECT COUNT(*) FROM usage) AS total_requests
"#;
const MYSQL_READ_ADMIN_SYSTEM_STATS_SQL: &str = r#"
SELECT
(SELECT COUNT(*) FROM users) AS total_users,
(SELECT COUNT(*) FROM users WHERE is_active = 1) AS active_users,
(SELECT COUNT(*) FROM api_keys) AS total_api_keys,
(SELECT COUNT(*) FROM `usage`) AS total_requests
"#;
const SQLITE_READ_ADMIN_SYSTEM_STATS_SQL: &str = r#"
SELECT
(SELECT COUNT(*) FROM users) AS total_users,
(SELECT COUNT(*) FROM users WHERE is_active = 1) AS active_users,
(SELECT COUNT(*) FROM api_keys) AS total_api_keys,
(SELECT COUNT(*) FROM "usage") AS total_requests
"#;
impl PostgresBackend {
pub async fn find_system_config_value(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let row = sqlx::query(POSTGRES_FIND_SYSTEM_CONFIG_VALUE_SQL)
.bind(key)
.fetch_optional(self.pool())
.await
.map_postgres_err()?;
row.map(|row| row.try_get("value"))
.transpose()
.map_postgres_err()
}
pub async fn upsert_system_config_value(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<serde_json::Value, DataLayerError> {
let row = sqlx::query(POSTGRES_UPSERT_SYSTEM_CONFIG_VALUE_SQL)
.bind(uuid::Uuid::new_v4().to_string())
.bind(key)
.bind(value)
.bind(description)
.fetch_one(self.pool())
.await
.map_postgres_err()?;
row.try_get("value").map_postgres_err()
}
pub async fn list_system_config_entries(
&self,
) -> Result<Vec<StoredSystemConfigEntry>, DataLayerError> {
let mut rows = sqlx::query(POSTGRES_LIST_SYSTEM_CONFIG_ENTRIES_SQL).fetch(self.pool());
let mut entries = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
entries.push(StoredSystemConfigEntry {
key: row.try_get("key").map_postgres_err()?,
value: row.try_get("value").map_postgres_err()?,
description: row.try_get("description").map_postgres_err()?,
updated_at_unix_secs: row
.try_get::<Option<i64>, _>("updated_at_unix_secs")
.map_postgres_err()?
.map(|value| value.max(0) as u64),
});
}
Ok(entries)
}
pub async fn upsert_system_config_entry(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<StoredSystemConfigEntry, DataLayerError> {
let row = sqlx::query(POSTGRES_UPSERT_SYSTEM_CONFIG_ENTRY_SQL)
.bind(uuid::Uuid::new_v4().to_string())
.bind(key)
.bind(value)
.bind(description)
.fetch_one(self.pool())
.await
.map_postgres_err()?;
Ok(StoredSystemConfigEntry {
key: row.try_get("key").map_postgres_err()?,
value: row.try_get("value").map_postgres_err()?,
description: row.try_get("description").map_postgres_err()?,
updated_at_unix_secs: row
.try_get::<Option<i64>, _>("updated_at_unix_secs")
.map_postgres_err()?
.map(|value| value.max(0) as u64),
})
}
pub async fn delete_system_config_value(&self, key: &str) -> Result<bool, DataLayerError> {
let result = sqlx::query(POSTGRES_DELETE_SYSTEM_CONFIG_VALUE_SQL)
.bind(key)
.execute(self.pool())
.await
.map_postgres_err()?;
Ok(result.rows_affected() > 0)
}
pub async fn read_admin_system_stats(&self) -> Result<AdminSystemStats, DataLayerError> {
let row = sqlx::query(POSTGRES_READ_ADMIN_SYSTEM_STATS_SQL)
.fetch_one(self.pool())
.await
.map_postgres_err()?;
postgres_admin_system_stats(row)
}
}
impl MysqlBackend {
pub async fn find_system_config_value(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT value
FROM system_configs
WHERE `key` = ?
LIMIT 1
"#,
)
.bind(key)
.fetch_optional(self.pool())
.await
.map_sql_err()?;
row.map(|row| {
row.try_get("value")
.map_sql_err()
.and_then(parse_json_value)
})
.transpose()
}
pub async fn upsert_system_config_value(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<serde_json::Value, DataLayerError> {
Ok(self
.upsert_system_config_entry(key, value, description)
.await?
.value)
}
pub async fn list_system_config_entries(
&self,
) -> Result<Vec<StoredSystemConfigEntry>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT `key`, value, description, updated_at
FROM system_configs
ORDER BY `key` ASC
"#,
)
.fetch_all(self.pool())
.await
.map_sql_err()?;
rows.into_iter()
.map(|row| {
Ok(StoredSystemConfigEntry {
key: row.try_get("key").map_sql_err()?,
value: parse_json_value(row.try_get("value").map_sql_err()?)?,
description: row.try_get("description").map_sql_err()?,
updated_at_unix_secs: row
.try_get::<Option<i64>, _>("updated_at")
.map_sql_err()?
.map(|value| value.max(0) as u64),
})
})
.collect()
}
pub async fn upsert_system_config_entry(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<StoredSystemConfigEntry, DataLayerError> {
let now = current_unix_secs();
let serialized = serialize_json_value(value)?;
sqlx::query(
r#"
INSERT INTO system_configs (id, `key`, value, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
value = VALUES(value),
description = COALESCE(VALUES(description), description),
updated_at = VALUES(updated_at)
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(key)
.bind(serialized)
.bind(description)
.bind(now as i64)
.bind(now as i64)
.execute(self.pool())
.await
.map_sql_err()?;
self.list_system_config_entries()
.await?
.into_iter()
.find(|entry| entry.key == key)
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"system config key '{key}' missing after mysql upsert"
))
})
}
pub async fn delete_system_config_value(&self, key: &str) -> Result<bool, DataLayerError> {
let result = sqlx::query(
r#"
DELETE FROM system_configs
WHERE `key` = ?
"#,
)
.bind(key)
.execute(self.pool())
.await
.map_sql_err()?;
Ok(result.rows_affected() > 0)
}
pub async fn read_admin_system_stats(&self) -> Result<AdminSystemStats, DataLayerError> {
let row = sqlx::query(MYSQL_READ_ADMIN_SYSTEM_STATS_SQL)
.fetch_one(self.pool())
.await
.map_sql_err()?;
mysql_admin_system_stats(row)
}
}
impl SqliteBackend {
pub async fn find_system_config_value(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let row = sqlx::query(
r#"
SELECT value
FROM system_configs
WHERE key = ?
LIMIT 1
"#,
)
.bind(key)
.fetch_optional(self.pool())
.await
.map_sql_err()?;
row.map(|row| {
row.try_get("value")
.map_sql_err()
.and_then(parse_json_value)
})
.transpose()
}
pub async fn upsert_system_config_value(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<serde_json::Value, DataLayerError> {
Ok(self
.upsert_system_config_entry(key, value, description)
.await?
.value)
}
pub async fn list_system_config_entries(
&self,
) -> Result<Vec<StoredSystemConfigEntry>, DataLayerError> {
let rows = sqlx::query(
r#"
SELECT key, value, description, updated_at
FROM system_configs
ORDER BY key ASC
"#,
)
.fetch_all(self.pool())
.await
.map_sql_err()?;
rows.into_iter()
.map(|row| {
Ok(StoredSystemConfigEntry {
key: row.try_get("key").map_sql_err()?,
value: parse_json_value(row.try_get("value").map_sql_err()?)?,
description: row.try_get("description").map_sql_err()?,
updated_at_unix_secs: row
.try_get::<Option<i64>, _>("updated_at")
.map_sql_err()?
.map(|value| value.max(0) as u64),
})
})
.collect()
}
pub async fn upsert_system_config_entry(
&self,
key: &str,
value: &serde_json::Value,
description: Option<&str>,
) -> Result<StoredSystemConfigEntry, DataLayerError> {
let now = current_unix_secs();
let serialized = serialize_json_value(value)?;
sqlx::query(
r#"
INSERT INTO system_configs (id, key, value, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (key) DO UPDATE
SET value = excluded.value,
description = COALESCE(excluded.description, system_configs.description),
updated_at = excluded.updated_at
"#,
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(key)
.bind(serialized)
.bind(description)
.bind(now as i64)
.bind(now as i64)
.execute(self.pool())
.await
.map_sql_err()?;
self.list_system_config_entries()
.await?
.into_iter()
.find(|entry| entry.key == key)
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"system config key '{key}' missing after sqlite upsert"
))
})
}
pub async fn delete_system_config_value(&self, key: &str) -> Result<bool, DataLayerError> {
let result = sqlx::query(
r#"
DELETE FROM system_configs
WHERE key = ?
"#,
)
.bind(key)
.execute(self.pool())
.await
.map_sql_err()?;
Ok(result.rows_affected() > 0)
}
pub async fn read_admin_system_stats(&self) -> Result<AdminSystemStats, DataLayerError> {
let row = sqlx::query(SQLITE_READ_ADMIN_SYSTEM_STATS_SQL)
.fetch_one(self.pool())
.await
.map_sql_err()?;
sqlite_admin_system_stats(row)
}
}
fn current_unix_secs() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
fn serialize_json_value(value: &serde_json::Value) -> Result<String, DataLayerError> {
serde_json::to_string(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("invalid system config JSON value: {err}"))
})
}
fn parse_json_value(value: String) -> Result<serde_json::Value, DataLayerError> {
serde_json::from_str(&value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("invalid system config JSON value: {err}"))
})
}
fn postgres_admin_system_stats(
row: sqlx::postgres::PgRow,
) -> Result<AdminSystemStats, DataLayerError> {
Ok(AdminSystemStats {
total_users: row
.try_get::<i64, _>("total_users")
.map_postgres_err()?
.max(0) as u64,
active_users: row
.try_get::<i64, _>("active_users")
.map_postgres_err()?
.max(0) as u64,
total_api_keys: row
.try_get::<i64, _>("total_api_keys")
.map_postgres_err()?
.max(0) as u64,
total_requests: row
.try_get::<i64, _>("total_requests")
.map_postgres_err()?
.max(0) as u64,
})
}
fn mysql_admin_system_stats(
row: sqlx::mysql::MySqlRow,
) -> Result<AdminSystemStats, DataLayerError> {
Ok(AdminSystemStats {
total_users: row.try_get::<i64, _>("total_users").map_sql_err()?.max(0) as u64,
active_users: row.try_get::<i64, _>("active_users").map_sql_err()?.max(0) as u64,
total_api_keys: row
.try_get::<i64, _>("total_api_keys")
.map_sql_err()?
.max(0) as u64,
total_requests: row
.try_get::<i64, _>("total_requests")
.map_sql_err()?
.max(0) as u64,
})
}
fn sqlite_admin_system_stats(
row: sqlx::sqlite::SqliteRow,
) -> Result<AdminSystemStats, DataLayerError> {
Ok(AdminSystemStats {
total_users: row.try_get::<i64, _>("total_users").map_sql_err()?.max(0) as u64,
active_users: row.try_get::<i64, _>("active_users").map_sql_err()?.max(0) as u64,
total_api_keys: row
.try_get::<i64, _>("total_api_keys")
.map_sql_err()?
.max(0) as u64,
total_requests: row
.try_get::<i64, _>("total_requests")
.map_sql_err()?
.max(0) as u64,
})
}

View File

@@ -0,0 +1,60 @@
use std::fmt;
use super::PostgresBackend;
use crate::driver::postgres::PostgresTransactionRunner;
#[derive(Clone, Default)]
pub struct DataTransactionBackends {
postgres: Option<PostgresTransactionRunner>,
}
impl fmt::Debug for DataTransactionBackends {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataTransactionBackends")
.field("has_postgres", &self.postgres.is_some())
.finish()
}
}
impl DataTransactionBackends {
pub(crate) fn from_postgres(postgres: Option<&PostgresBackend>) -> Self {
Self {
postgres: postgres.map(PostgresBackend::transaction_runner),
}
}
pub fn postgres(&self) -> Option<PostgresTransactionRunner> {
self.postgres.clone()
}
pub fn has_any(&self) -> bool {
self.postgres.is_some()
}
}
#[cfg(test)]
mod tests {
use super::DataTransactionBackends;
use crate::backend::PostgresBackend;
use crate::driver::postgres::PostgresPoolConfig;
#[tokio::test]
async fn builds_postgres_transaction_runner_from_backend() {
let backend = PostgresBackend::from_config(PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
})
.expect("postgres backend should build");
let transactions = DataTransactionBackends::from_postgres(Some(&backend));
assert!(transactions.has_any());
assert!(transactions.postgres().is_some());
}
}

View File

@@ -0,0 +1,462 @@
use sha2::{Digest, Sha256};
use sqlx::Row;
use crate::backend::{MysqlBackend, PostgresBackend, SqliteBackend};
use crate::error::{SqlResultExt, SqlxResultExt};
use crate::{DataLayerError, WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult};
const POSTGRES_UPSERT_WALLET_DAILY_USAGE_LEDGER_SQL: &str = r#"
WITH aggregated AS (
SELECT
usage_settlement_snapshots.wallet_id,
COUNT(*) AS total_requests,
CAST(COALESCE(SUM(usage.total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost_usd,
COALESCE(SUM(usage.input_tokens), 0) AS input_tokens,
COALESCE(SUM(usage.output_tokens), 0) AS output_tokens,
COALESCE(SUM(usage.cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(usage.cache_read_input_tokens), 0) AS cache_read_tokens,
MIN(COALESCE(usage_settlement_snapshots.finalized_at, usage.finalized_at)) AS first_finalized_at,
MAX(COALESCE(usage_settlement_snapshots.finalized_at, usage.finalized_at)) AS last_finalized_at
FROM usage_billing_facts AS usage
JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = usage.request_id
WHERE usage_settlement_snapshots.wallet_id IS NOT NULL
AND COALESCE(usage_settlement_snapshots.billing_status, usage.billing_status) = 'settled'
AND usage.total_cost_usd > 0
AND COALESCE(usage_settlement_snapshots.finalized_at, usage.finalized_at) >= $1
AND COALESCE(usage_settlement_snapshots.finalized_at, usage.finalized_at) < $2
GROUP BY usage_settlement_snapshots.wallet_id
)
INSERT INTO wallet_daily_usage_ledgers (
id,
wallet_id,
billing_date,
billing_timezone,
total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
first_finalized_at,
last_finalized_at,
aggregated_at,
created_at,
updated_at
)
SELECT
md5(CONCAT('wallet-daily-usage:', aggregated.wallet_id, ':', CAST($3 AS TEXT), ':', $4)),
aggregated.wallet_id,
$3,
$4,
aggregated.total_cost_usd,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.cache_creation_tokens,
aggregated.cache_read_tokens,
aggregated.first_finalized_at,
aggregated.last_finalized_at,
$5,
$5,
$5
FROM aggregated
ON CONFLICT (wallet_id, billing_date, billing_timezone)
DO UPDATE SET
total_cost_usd = EXCLUDED.total_cost_usd,
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_creation_tokens = EXCLUDED.cache_creation_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
first_finalized_at = EXCLUDED.first_finalized_at,
last_finalized_at = EXCLUDED.last_finalized_at,
aggregated_at = EXCLUDED.aggregated_at,
updated_at = EXCLUDED.updated_at
"#;
const POSTGRES_DELETE_STALE_WALLET_DAILY_USAGE_LEDGERS_SQL: &str = r#"
DELETE FROM wallet_daily_usage_ledgers AS ledgers
WHERE ledgers.billing_date = $1
AND ledgers.billing_timezone = $2
AND NOT EXISTS (
SELECT 1
FROM usage_billing_facts AS usage
JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = usage.request_id
WHERE usage_settlement_snapshots.wallet_id = ledgers.wallet_id
AND COALESCE(usage_settlement_snapshots.billing_status, usage.billing_status) = 'settled'
AND usage.total_cost_usd > 0
AND COALESCE(usage_settlement_snapshots.finalized_at, usage.finalized_at) >= $3
AND COALESCE(usage_settlement_snapshots.finalized_at, usage.finalized_at) < $4
)
"#;
const MYSQL_SELECT_WALLET_DAILY_USAGE_AGGREGATES_SQL: &str = r#"
SELECT
usage_settlement_snapshots.wallet_id AS wallet_id,
COUNT(*) AS total_requests,
COALESCE(SUM(`usage`.total_cost_usd), 0) AS total_cost_usd,
COALESCE(SUM(`usage`.input_tokens), 0) AS input_tokens,
COALESCE(SUM(`usage`.output_tokens), 0) AS output_tokens,
COALESCE(SUM(`usage`.cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(`usage`.cache_read_input_tokens), 0) AS cache_read_tokens,
MIN(COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)) AS first_finalized_at,
MAX(COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)) AS last_finalized_at
FROM `usage`
JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = `usage`.request_id
WHERE usage_settlement_snapshots.wallet_id IS NOT NULL
AND usage_settlement_snapshots.wallet_id <> ''
AND COALESCE(usage_settlement_snapshots.billing_status, `usage`.billing_status) = 'settled'
AND `usage`.total_cost_usd > 0
AND COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at) >= ?
AND COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at) < ?
GROUP BY usage_settlement_snapshots.wallet_id
"#;
const SQLITE_SELECT_WALLET_DAILY_USAGE_AGGREGATES_SQL: &str = r#"
SELECT
usage_settlement_snapshots.wallet_id AS wallet_id,
COUNT(*) AS total_requests,
COALESCE(SUM("usage".total_cost_usd), 0) AS total_cost_usd,
COALESCE(SUM("usage".input_tokens), 0) AS input_tokens,
COALESCE(SUM("usage".output_tokens), 0) AS output_tokens,
COALESCE(SUM("usage".cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM("usage".cache_read_input_tokens), 0) AS cache_read_tokens,
MIN(COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at)) AS first_finalized_at,
MAX(COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at)) AS last_finalized_at
FROM "usage"
JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = "usage".request_id
WHERE usage_settlement_snapshots.wallet_id IS NOT NULL
AND usage_settlement_snapshots.wallet_id <> ''
AND COALESCE(usage_settlement_snapshots.billing_status, "usage".billing_status) = 'settled'
AND "usage".total_cost_usd > 0
AND COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at) >= ?
AND COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at) < ?
GROUP BY usage_settlement_snapshots.wallet_id
"#;
fn u64_to_i64(value: u64, field_name: &str) -> Result<i64, DataLayerError> {
i64::try_from(value)
.map_err(|_| DataLayerError::InvalidInput(format!("invalid {field_name}: {value}")))
}
fn unix_secs_to_utc(
value: u64,
field_name: &str,
) -> Result<chrono::DateTime<chrono::Utc>, DataLayerError> {
let value = u64_to_i64(value, field_name)?;
chrono::DateTime::<chrono::Utc>::from_timestamp(value, 0)
.ok_or_else(|| DataLayerError::InvalidInput(format!("invalid {field_name}: {value}")))
}
fn wallet_daily_usage_id(wallet_id: &str, billing_date: &str, billing_timezone: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"wallet-daily-usage:");
hasher.update(wallet_id.as_bytes());
hasher.update(b":");
hasher.update(billing_date.as_bytes());
hasher.update(b":");
hasher.update(billing_timezone.as_bytes());
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
impl PostgresBackend {
pub async fn aggregate_wallet_daily_usage(
&self,
input: &WalletDailyUsageAggregationInput,
) -> Result<WalletDailyUsageAggregationResult, DataLayerError> {
let billing_date = chrono::NaiveDate::parse_from_str(&input.billing_date, "%Y-%m-%d")
.map_err(|err| {
DataLayerError::InvalidInput(format!("invalid wallet billing_date: {err}"))
})?;
let window_start = unix_secs_to_utc(input.window_start_unix_secs, "window_start")?;
let window_end = unix_secs_to_utc(input.window_end_unix_secs, "window_end")?;
let aggregated_at = unix_secs_to_utc(input.aggregated_at_unix_secs, "aggregated_at")?;
let mut tx = self.pool().begin().await.map_postgres_err()?;
let aggregated_wallets = sqlx::query(POSTGRES_UPSERT_WALLET_DAILY_USAGE_LEDGER_SQL)
.bind(window_start)
.bind(window_end)
.bind(billing_date)
.bind(input.billing_timezone.as_str())
.bind(aggregated_at)
.execute(&mut *tx)
.await
.map_postgres_err()?
.rows_affected();
let deleted_stale_ledgers =
sqlx::query(POSTGRES_DELETE_STALE_WALLET_DAILY_USAGE_LEDGERS_SQL)
.bind(billing_date)
.bind(input.billing_timezone.as_str())
.bind(window_start)
.bind(window_end)
.execute(&mut *tx)
.await
.map_postgres_err()?
.rows_affected();
tx.commit().await.map_postgres_err()?;
Ok(WalletDailyUsageAggregationResult {
aggregated_wallets: usize::try_from(aggregated_wallets).unwrap_or(usize::MAX),
deleted_stale_ledgers: usize::try_from(deleted_stale_ledgers).unwrap_or(usize::MAX),
})
}
}
impl MysqlBackend {
pub async fn aggregate_wallet_daily_usage(
&self,
input: &WalletDailyUsageAggregationInput,
) -> Result<WalletDailyUsageAggregationResult, DataLayerError> {
let window_start = u64_to_i64(input.window_start_unix_secs, "window_start")?;
let window_end = u64_to_i64(input.window_end_unix_secs, "window_end")?;
let aggregated_at = u64_to_i64(input.aggregated_at_unix_secs, "aggregated_at")?;
let mut tx = self.pool().begin().await.map_sql_err()?;
let rows = sqlx::query(MYSQL_SELECT_WALLET_DAILY_USAGE_AGGREGATES_SQL)
.bind(window_start)
.bind(window_end)
.fetch_all(&mut *tx)
.await
.map_sql_err()?;
let mut aggregated_wallets = 0usize;
for row in rows {
let wallet_id: String = row.try_get("wallet_id").map_sql_err()?;
sqlx::query(
r#"
DELETE FROM wallet_daily_usage_ledgers
WHERE wallet_id = ?
AND billing_date = ?
AND billing_timezone = ?
"#,
)
.bind(&wallet_id)
.bind(&input.billing_date)
.bind(&input.billing_timezone)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(
r#"
INSERT INTO wallet_daily_usage_ledgers (
id,
wallet_id,
billing_date,
billing_timezone,
total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
first_finalized_at,
last_finalized_at,
aggregated_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(wallet_daily_usage_id(
&wallet_id,
&input.billing_date,
&input.billing_timezone,
))
.bind(&wallet_id)
.bind(&input.billing_date)
.bind(&input.billing_timezone)
.bind(row.try_get::<f64, _>("total_cost_usd").map_sql_err()?)
.bind(row.try_get::<i64, _>("total_requests").map_sql_err()?)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(
row.try_get::<Option<i64>, _>("first_finalized_at")
.map_sql_err()?,
)
.bind(
row.try_get::<Option<i64>, _>("last_finalized_at")
.map_sql_err()?,
)
.bind(aggregated_at)
.bind(aggregated_at)
.bind(aggregated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
aggregated_wallets += 1;
}
let deleted_stale_ledgers = sqlx::query(
r#"
DELETE FROM wallet_daily_usage_ledgers
WHERE billing_date = ?
AND billing_timezone = ?
AND NOT EXISTS (
SELECT 1
FROM `usage`
JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = `usage`.request_id
WHERE usage_settlement_snapshots.wallet_id = wallet_daily_usage_ledgers.wallet_id
AND COALESCE(usage_settlement_snapshots.billing_status, `usage`.billing_status) = 'settled'
AND `usage`.total_cost_usd > 0
AND COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at) >= ?
AND COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at) < ?
)
"#,
)
.bind(&input.billing_date)
.bind(&input.billing_timezone)
.bind(window_start)
.bind(window_end)
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(WalletDailyUsageAggregationResult {
aggregated_wallets,
deleted_stale_ledgers: usize::try_from(deleted_stale_ledgers).unwrap_or(usize::MAX),
})
}
}
impl SqliteBackend {
pub async fn aggregate_wallet_daily_usage(
&self,
input: &WalletDailyUsageAggregationInput,
) -> Result<WalletDailyUsageAggregationResult, DataLayerError> {
let window_start = u64_to_i64(input.window_start_unix_secs, "window_start")?;
let window_end = u64_to_i64(input.window_end_unix_secs, "window_end")?;
let aggregated_at = u64_to_i64(input.aggregated_at_unix_secs, "aggregated_at")?;
let mut tx = self.pool().begin().await.map_sql_err()?;
let rows = sqlx::query(SQLITE_SELECT_WALLET_DAILY_USAGE_AGGREGATES_SQL)
.bind(window_start)
.bind(window_end)
.fetch_all(&mut *tx)
.await
.map_sql_err()?;
let mut aggregated_wallets = 0usize;
for row in rows {
let wallet_id: String = row.try_get("wallet_id").map_sql_err()?;
sqlx::query(
r#"
DELETE FROM wallet_daily_usage_ledgers
WHERE wallet_id = ?
AND billing_date = ?
AND billing_timezone = ?
"#,
)
.bind(&wallet_id)
.bind(&input.billing_date)
.bind(&input.billing_timezone)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query(
r#"
INSERT INTO wallet_daily_usage_ledgers (
id,
wallet_id,
billing_date,
billing_timezone,
total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
first_finalized_at,
last_finalized_at,
aggregated_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(wallet_daily_usage_id(
&wallet_id,
&input.billing_date,
&input.billing_timezone,
))
.bind(&wallet_id)
.bind(&input.billing_date)
.bind(&input.billing_timezone)
.bind(row.try_get::<f64, _>("total_cost_usd").map_sql_err()?)
.bind(row.try_get::<i64, _>("total_requests").map_sql_err()?)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(
row.try_get::<Option<i64>, _>("first_finalized_at")
.map_sql_err()?,
)
.bind(
row.try_get::<Option<i64>, _>("last_finalized_at")
.map_sql_err()?,
)
.bind(aggregated_at)
.bind(aggregated_at)
.bind(aggregated_at)
.execute(&mut *tx)
.await
.map_sql_err()?;
aggregated_wallets += 1;
}
let deleted_stale_ledgers = sqlx::query(
r#"
DELETE FROM wallet_daily_usage_ledgers
WHERE billing_date = ?
AND billing_timezone = ?
AND NOT EXISTS (
SELECT 1
FROM "usage"
JOIN usage_settlement_snapshots
ON usage_settlement_snapshots.request_id = "usage".request_id
WHERE usage_settlement_snapshots.wallet_id = wallet_daily_usage_ledgers.wallet_id
AND COALESCE(usage_settlement_snapshots.billing_status, "usage".billing_status) = 'settled'
AND "usage".total_cost_usd > 0
AND COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at) >= ?
AND COALESCE(usage_settlement_snapshots.finalized_at, "usage".finalized_at) < ?
)
"#,
)
.bind(&input.billing_date)
.bind(&input.billing_timezone)
.bind(window_start)
.bind(window_end)
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(WalletDailyUsageAggregationResult {
aggregated_wallets,
deleted_stale_ledgers: usize::try_from(deleted_stale_ledgers).unwrap_or(usize::MAX),
})
}
}

View File

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,258 @@
use std::fmt;
use std::sync::Arc;
use super::{MysqlBackend, PostgresBackend, SqliteBackend};
use crate::repository::announcements::AnnouncementWriteRepository;
use crate::repository::auth::AuthApiKeyWriteRepository;
use crate::repository::auth_modules::AuthModuleWriteRepository;
use crate::repository::candidates::RequestCandidateWriteRepository;
use crate::repository::gemini_file_mappings::GeminiFileMappingWriteRepository;
use crate::repository::global_models::GlobalModelWriteRepository;
use crate::repository::management_tokens::ManagementTokenWriteRepository;
use crate::repository::oauth_providers::OAuthProviderWriteRepository;
use crate::repository::provider_catalog::ProviderCatalogWriteRepository;
use crate::repository::proxy_nodes::ProxyNodeWriteRepository;
use crate::repository::quota::ProviderQuotaWriteRepository;
use crate::repository::settlement::SettlementWriteRepository;
use crate::repository::usage::UsageWriteRepository;
use crate::repository::video_tasks::VideoTaskWriteRepository;
use crate::repository::wallet::WalletWriteRepository;
#[derive(Clone, Default)]
pub struct DataWriteRepositories {
announcements: Option<Arc<dyn AnnouncementWriteRepository>>,
auth_api_keys: Option<Arc<dyn AuthApiKeyWriteRepository>>,
auth_modules: Option<Arc<dyn AuthModuleWriteRepository>>,
request_candidates: Option<Arc<dyn RequestCandidateWriteRepository>>,
gemini_file_mappings: Option<Arc<dyn GeminiFileMappingWriteRepository>>,
global_models: Option<Arc<dyn GlobalModelWriteRepository>>,
management_tokens: Option<Arc<dyn ManagementTokenWriteRepository>>,
oauth_providers: Option<Arc<dyn OAuthProviderWriteRepository>>,
proxy_nodes: Option<Arc<dyn ProxyNodeWriteRepository>>,
provider_catalog: Option<Arc<dyn ProviderCatalogWriteRepository>>,
provider_quotas: Option<Arc<dyn ProviderQuotaWriteRepository>>,
settlement: Option<Arc<dyn SettlementWriteRepository>>,
usage: Option<Arc<dyn UsageWriteRepository>>,
video_tasks: Option<Arc<dyn VideoTaskWriteRepository>>,
wallets: Option<Arc<dyn WalletWriteRepository>>,
}
impl fmt::Debug for DataWriteRepositories {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DataWriteRepositories")
.field("has_announcements", &self.announcements.is_some())
.field("has_auth_api_keys", &self.auth_api_keys.is_some())
.field("has_auth_modules", &self.auth_modules.is_some())
.field("has_request_candidates", &self.request_candidates.is_some())
.field(
"has_gemini_file_mappings",
&self.gemini_file_mappings.is_some(),
)
.field("has_global_models", &self.global_models.is_some())
.field("has_management_tokens", &self.management_tokens.is_some())
.field("has_oauth_providers", &self.oauth_providers.is_some())
.field("has_proxy_nodes", &self.proxy_nodes.is_some())
.field("has_provider_catalog", &self.provider_catalog.is_some())
.field("has_provider_quotas", &self.provider_quotas.is_some())
.field("has_settlement", &self.settlement.is_some())
.field("has_usage", &self.usage.is_some())
.field("has_video_tasks", &self.video_tasks.is_some())
.field("has_wallets", &self.wallets.is_some())
.finish()
}
}
impl DataWriteRepositories {
pub(crate) fn from_backends(
postgres: Option<&PostgresBackend>,
mysql: Option<&MysqlBackend>,
sqlite: Option<&SqliteBackend>,
) -> Self {
Self {
announcements: postgres
.map(PostgresBackend::announcement_write_repository)
.or_else(|| mysql.map(MysqlBackend::announcement_write_repository))
.or_else(|| sqlite.map(SqliteBackend::announcement_write_repository)),
auth_api_keys: postgres
.map(PostgresBackend::auth_api_key_write_repository)
.or_else(|| mysql.map(MysqlBackend::auth_api_key_write_repository))
.or_else(|| sqlite.map(SqliteBackend::auth_api_key_write_repository)),
auth_modules: postgres
.map(PostgresBackend::auth_module_write_repository)
.or_else(|| mysql.map(MysqlBackend::auth_module_write_repository))
.or_else(|| sqlite.map(SqliteBackend::auth_module_write_repository)),
request_candidates: postgres
.map(PostgresBackend::request_candidate_write_repository)
.or_else(|| mysql.map(MysqlBackend::request_candidate_write_repository))
.or_else(|| sqlite.map(SqliteBackend::request_candidate_write_repository)),
gemini_file_mappings: postgres
.map(PostgresBackend::gemini_file_mapping_write_repository)
.or_else(|| mysql.map(MysqlBackend::gemini_file_mapping_write_repository))
.or_else(|| sqlite.map(SqliteBackend::gemini_file_mapping_write_repository)),
global_models: postgres
.map(PostgresBackend::global_model_write_repository)
.or_else(|| mysql.map(MysqlBackend::global_model_write_repository))
.or_else(|| sqlite.map(SqliteBackend::global_model_write_repository)),
management_tokens: postgres
.map(PostgresBackend::management_token_write_repository)
.or_else(|| mysql.map(MysqlBackend::management_token_write_repository))
.or_else(|| sqlite.map(SqliteBackend::management_token_write_repository)),
oauth_providers: postgres
.map(PostgresBackend::oauth_provider_write_repository)
.or_else(|| mysql.map(MysqlBackend::oauth_provider_write_repository))
.or_else(|| sqlite.map(SqliteBackend::oauth_provider_write_repository)),
proxy_nodes: postgres
.map(PostgresBackend::proxy_node_write_repository)
.or_else(|| mysql.map(MysqlBackend::proxy_node_write_repository))
.or_else(|| sqlite.map(SqliteBackend::proxy_node_write_repository)),
provider_catalog: postgres
.map(PostgresBackend::provider_catalog_write_repository)
.or_else(|| mysql.map(MysqlBackend::provider_catalog_write_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_catalog_write_repository)),
provider_quotas: postgres
.map(PostgresBackend::provider_quota_write_repository)
.or_else(|| mysql.map(MysqlBackend::provider_quota_write_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_quota_write_repository)),
settlement: postgres
.map(PostgresBackend::settlement_write_repository)
.or_else(|| mysql.map(MysqlBackend::settlement_write_repository))
.or_else(|| sqlite.map(SqliteBackend::settlement_write_repository)),
usage: postgres
.map(PostgresBackend::usage_write_repository)
.or_else(|| mysql.map(MysqlBackend::usage_write_repository))
.or_else(|| sqlite.map(SqliteBackend::usage_write_repository)),
video_tasks: postgres
.map(PostgresBackend::video_task_write_repository)
.or_else(|| mysql.map(MysqlBackend::video_task_write_repository))
.or_else(|| sqlite.map(SqliteBackend::video_task_write_repository)),
wallets: postgres
.map(PostgresBackend::wallet_write_repository)
.or_else(|| mysql.map(MysqlBackend::wallet_write_repository))
.or_else(|| sqlite.map(SqliteBackend::wallet_write_repository)),
}
}
#[cfg(test)]
pub(crate) fn from_postgres(postgres: Option<&PostgresBackend>) -> Self {
Self::from_backends(postgres, None, None)
}
pub fn announcements(&self) -> Option<Arc<dyn AnnouncementWriteRepository>> {
self.announcements.clone()
}
pub fn auth_api_keys(&self) -> Option<Arc<dyn AuthApiKeyWriteRepository>> {
self.auth_api_keys.clone()
}
pub fn auth_modules(&self) -> Option<Arc<dyn AuthModuleWriteRepository>> {
self.auth_modules.clone()
}
pub fn usage(&self) -> Option<Arc<dyn UsageWriteRepository>> {
self.usage.clone()
}
pub fn request_candidates(&self) -> Option<Arc<dyn RequestCandidateWriteRepository>> {
self.request_candidates.clone()
}
pub fn gemini_file_mappings(&self) -> Option<Arc<dyn GeminiFileMappingWriteRepository>> {
self.gemini_file_mappings.clone()
}
pub fn global_models(&self) -> Option<Arc<dyn GlobalModelWriteRepository>> {
self.global_models.clone()
}
pub fn management_tokens(&self) -> Option<Arc<dyn ManagementTokenWriteRepository>> {
self.management_tokens.clone()
}
pub fn oauth_providers(&self) -> Option<Arc<dyn OAuthProviderWriteRepository>> {
self.oauth_providers.clone()
}
pub fn proxy_nodes(&self) -> Option<Arc<dyn ProxyNodeWriteRepository>> {
self.proxy_nodes.clone()
}
pub fn provider_quotas(&self) -> Option<Arc<dyn ProviderQuotaWriteRepository>> {
self.provider_quotas.clone()
}
pub fn provider_catalog(&self) -> Option<Arc<dyn ProviderCatalogWriteRepository>> {
self.provider_catalog.clone()
}
pub fn settlement(&self) -> Option<Arc<dyn SettlementWriteRepository>> {
self.settlement.clone()
}
pub fn video_tasks(&self) -> Option<Arc<dyn VideoTaskWriteRepository>> {
self.video_tasks.clone()
}
pub fn wallets(&self) -> Option<Arc<dyn WalletWriteRepository>> {
self.wallets.clone()
}
pub fn has_any(&self) -> bool {
self.announcements.is_some()
|| self.auth_api_keys.is_some()
|| self.auth_modules.is_some()
|| self.request_candidates.is_some()
|| self.gemini_file_mappings.is_some()
|| self.global_models.is_some()
|| self.management_tokens.is_some()
|| self.oauth_providers.is_some()
|| self.proxy_nodes.is_some()
|| self.provider_catalog.is_some()
|| self.provider_quotas.is_some()
|| self.settlement.is_some()
|| self.usage.is_some()
|| self.video_tasks.is_some()
|| self.wallets.is_some()
}
}
#[cfg(test)]
mod tests {
use super::DataWriteRepositories;
use crate::backend::PostgresBackend;
use crate::driver::postgres::PostgresPoolConfig;
#[tokio::test]
async fn builds_write_repositories_from_postgres_backend() {
let backend = PostgresBackend::from_config(PostgresPoolConfig {
database_url: "postgres://localhost/aether".to_string(),
min_connections: 1,
max_connections: 4,
acquire_timeout_ms: 1_000,
idle_timeout_ms: 5_000,
max_lifetime_ms: 30_000,
statement_cache_capacity: 64,
require_ssl: false,
})
.expect("postgres backend should build");
let write = DataWriteRepositories::from_postgres(Some(&backend));
assert!(write.has_any());
assert!(write.announcements().is_some());
assert!(write.auth_api_keys().is_some());
assert!(write.auth_modules().is_some());
assert!(write.request_candidates().is_some());
assert!(write.gemini_file_mappings().is_some());
assert!(write.global_models().is_some());
assert!(write.management_tokens().is_some());
assert!(write.oauth_providers().is_some());
assert!(write.proxy_nodes().is_some());
assert!(write.provider_catalog().is_some());
assert!(write.provider_quotas().is_some());
assert!(write.settlement().is_some());
assert!(write.usage().is_some());
assert!(write.video_tasks().is_some());
assert!(write.wallets().is_some());
}
}