mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 引入 aether-runtime/cache/data/http/testkit 基础 crate,完善并发门控与审计系统
新增 crate: - aether-runtime: 服务运行时基础设施(并发门控、分布式并发、指标、队列、优雅关闭、tracing) - aether-cache: 通用 TTL 缓存与命名空间抽象 - aether-data: 数据访问层(PostgreSQL/Redis 后端、repository 模式) - aether-http: HTTP 客户端封装(重试、配置) - aether-testkit: 集成测试工具集(gateway/executor/hub/proxy fixture、等待、负载测试) gateway 扩展: - 引入 audit 模块(shadow 执行审计、决策链路追踪、请求审计 bundle) - 引入 cache 模块(AuthContext 缓存、direct-plan bypass 缓存) - 引入 data 模块(auth/candidates/config/usage/video_tasks 数据访问) - 集成 ConcurrencyGate/DistributedConcurrencyGate 请求门控 - 新增本地 auth 拒绝、过载响应构建器 - 补充 control/auth_cache/video/concurrency 集成测试 aether-proxy 扩展: - AppState 集成 stream_gate / distributed_stream_gate 并发门控 - 新增 ProxyAdmissionError 及准入拒绝流程 - stream_handler 补充门控饱和/不可用场景测试 - 配置与注册客户端逻辑完善 aether-hub 扩展: - main.rs 引入运行时初始化、指标端点、健康检查 - local_relay 重构为 lib.rs 暴露公共接口
This commit is contained in:
170
crates/aether-data/src/repository/shadow_results/memory.rs
Normal file
170
crates/aether-data/src/repository/shadow_results/memory.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{
|
||||
ShadowResultLookupKey, ShadowResultReadRepository, ShadowResultWriteRepository,
|
||||
StoredShadowResult, UpsertShadowResult,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryShadowResultRepository {
|
||||
results: RwLock<BTreeMap<(String, String), StoredShadowResult>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShadowResultReadRepository for InMemoryShadowResultRepository {
|
||||
async fn find(
|
||||
&self,
|
||||
key: ShadowResultLookupKey<'_>,
|
||||
) -> Result<Option<StoredShadowResult>, DataLayerError> {
|
||||
let results = self.results.read().expect("shadow result repository lock");
|
||||
Ok(match key {
|
||||
ShadowResultLookupKey::TraceFingerprint {
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
} => results
|
||||
.get(&(trace_id.to_string(), request_fingerprint.to_string()))
|
||||
.cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_recent(&self, limit: usize) -> Result<Vec<StoredShadowResult>, DataLayerError> {
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut results = self
|
||||
.results
|
||||
.read()
|
||||
.expect("shadow result repository lock")
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
results.sort_by(|left, right| right.updated_at_unix_secs.cmp(&left.updated_at_unix_secs));
|
||||
results.truncate(limit);
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShadowResultWriteRepository for InMemoryShadowResultRepository {
|
||||
async fn upsert(
|
||||
&self,
|
||||
result: UpsertShadowResult,
|
||||
) -> Result<StoredShadowResult, DataLayerError> {
|
||||
let stored = result.into_stored();
|
||||
let mut results = self.results.write().expect("shadow result repository lock");
|
||||
results.insert(
|
||||
(stored.trace_id.clone(), stored.request_fingerprint.clone()),
|
||||
stored.clone(),
|
||||
);
|
||||
Ok(stored)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::InMemoryShadowResultRepository;
|
||||
use crate::repository::shadow_results::{
|
||||
ShadowResultLookupKey, ShadowResultMatchStatus, ShadowResultReadRepository,
|
||||
ShadowResultWriteRepository, UpsertShadowResult,
|
||||
};
|
||||
|
||||
fn sample_result(
|
||||
trace_id: &str,
|
||||
request_fingerprint: &str,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> UpsertShadowResult {
|
||||
UpsertShadowResult {
|
||||
trace_id: trace_id.to_string(),
|
||||
request_fingerprint: request_fingerprint.to_string(),
|
||||
request_id: Some(format!("req-{trace_id}")),
|
||||
route_family: Some("openai".to_string()),
|
||||
route_kind: Some("chat".to_string()),
|
||||
candidate_id: Some("cand-1".to_string()),
|
||||
rust_result_digest: Some("rust-digest".to_string()),
|
||||
python_result_digest: Some("python-digest".to_string()),
|
||||
match_status: ShadowResultMatchStatus::Match,
|
||||
status_code: Some(200),
|
||||
error_message: None,
|
||||
created_at_unix_secs: updated_at_unix_secs.saturating_sub(10),
|
||||
updated_at_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_result_by_trace_and_fingerprint() {
|
||||
let repo = InMemoryShadowResultRepository::default();
|
||||
repo.upsert(sample_result("trace-1", "fp-1", 100))
|
||||
.await
|
||||
.expect("upsert should succeed");
|
||||
|
||||
assert!(repo
|
||||
.find(ShadowResultLookupKey::TraceFingerprint {
|
||||
trace_id: "trace-1",
|
||||
request_fingerprint: "fp-1",
|
||||
})
|
||||
.await
|
||||
.expect("find should succeed")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_recent_returns_results_in_descending_update_order() {
|
||||
let repo = InMemoryShadowResultRepository::default();
|
||||
repo.upsert(sample_result("trace-1", "fp-1", 100))
|
||||
.await
|
||||
.expect("upsert should succeed");
|
||||
repo.upsert(sample_result("trace-2", "fp-2", 200))
|
||||
.await
|
||||
.expect("upsert should succeed");
|
||||
|
||||
let recent = repo
|
||||
.list_recent(10)
|
||||
.await
|
||||
.expect("list recent should succeed");
|
||||
assert_eq!(recent.len(), 2);
|
||||
assert_eq!(recent[0].trace_id, "trace-2");
|
||||
assert_eq!(recent[1].trace_id, "trace-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_replaces_existing_shadow_result() {
|
||||
let repo = InMemoryShadowResultRepository::default();
|
||||
repo.upsert(sample_result("trace-1", "fp-1", 100))
|
||||
.await
|
||||
.expect("upsert should succeed");
|
||||
repo.upsert(UpsertShadowResult {
|
||||
trace_id: "trace-1".to_string(),
|
||||
request_fingerprint: "fp-1".to_string(),
|
||||
request_id: Some("req-trace-1".to_string()),
|
||||
route_family: Some("openai".to_string()),
|
||||
route_kind: Some("chat".to_string()),
|
||||
candidate_id: Some("cand-2".to_string()),
|
||||
rust_result_digest: Some("rust-digest-2".to_string()),
|
||||
python_result_digest: Some("python-digest-2".to_string()),
|
||||
match_status: ShadowResultMatchStatus::Mismatch,
|
||||
status_code: Some(502),
|
||||
error_message: Some("mismatch".to_string()),
|
||||
created_at_unix_secs: 100,
|
||||
updated_at_unix_secs: 200,
|
||||
})
|
||||
.await
|
||||
.expect("upsert should succeed");
|
||||
|
||||
let stored = repo
|
||||
.find(ShadowResultLookupKey::TraceFingerprint {
|
||||
trace_id: "trace-1",
|
||||
request_fingerprint: "fp-1",
|
||||
})
|
||||
.await
|
||||
.expect("find should succeed")
|
||||
.expect("stored result should exist");
|
||||
assert_eq!(stored.request_id.as_deref(), Some("req-trace-1"));
|
||||
assert_eq!(stored.candidate_id.as_deref(), Some("cand-2"));
|
||||
assert_eq!(stored.match_status, ShadowResultMatchStatus::Mismatch);
|
||||
}
|
||||
}
|
||||
12
crates/aether-data/src/repository/shadow_results/mod.rs
Normal file
12
crates/aether-data/src/repository/shadow_results/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
mod memory;
|
||||
mod record;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryShadowResultRepository;
|
||||
pub use record::{merge_shadow_result_sample, RecordShadowResultSample, ShadowResultSampleOrigin};
|
||||
pub use sql::SqlxShadowResultRepository;
|
||||
pub use types::{
|
||||
ShadowResultLookupKey, ShadowResultMatchStatus, ShadowResultReadRepository,
|
||||
ShadowResultRepository, ShadowResultWriteRepository, StoredShadowResult, UpsertShadowResult,
|
||||
};
|
||||
188
crates/aether-data/src/repository/shadow_results/record.rs
Normal file
188
crates/aether-data/src/repository/shadow_results/record.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use super::types::{ShadowResultMatchStatus, StoredShadowResult, UpsertShadowResult};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShadowResultSampleOrigin {
|
||||
Rust,
|
||||
Python,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecordShadowResultSample {
|
||||
pub trace_id: String,
|
||||
pub request_fingerprint: String,
|
||||
pub request_id: Option<String>,
|
||||
pub route_family: Option<String>,
|
||||
pub route_kind: Option<String>,
|
||||
pub candidate_id: Option<String>,
|
||||
pub origin: ShadowResultSampleOrigin,
|
||||
pub result_digest: String,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_message: Option<String>,
|
||||
pub recorded_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
pub fn merge_shadow_result_sample(
|
||||
existing: Option<&StoredShadowResult>,
|
||||
sample: RecordShadowResultSample,
|
||||
) -> UpsertShadowResult {
|
||||
let RecordShadowResultSample {
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
request_id,
|
||||
route_family,
|
||||
route_kind,
|
||||
candidate_id,
|
||||
origin,
|
||||
result_digest,
|
||||
status_code,
|
||||
error_message,
|
||||
recorded_at_unix_secs,
|
||||
} = sample;
|
||||
|
||||
let (rust_result_digest, python_result_digest) = match origin {
|
||||
ShadowResultSampleOrigin::Rust => (
|
||||
Some(result_digest),
|
||||
existing.and_then(|row| row.python_result_digest.clone()),
|
||||
),
|
||||
ShadowResultSampleOrigin::Python => (
|
||||
existing.and_then(|row| row.rust_result_digest.clone()),
|
||||
Some(result_digest),
|
||||
),
|
||||
};
|
||||
|
||||
let match_status = resolve_match_status(
|
||||
rust_result_digest.as_deref(),
|
||||
python_result_digest.as_deref(),
|
||||
);
|
||||
|
||||
UpsertShadowResult {
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
request_id: request_id.or_else(|| existing.and_then(|row| row.request_id.clone())),
|
||||
route_family: route_family.or_else(|| existing.and_then(|row| row.route_family.clone())),
|
||||
route_kind: route_kind.or_else(|| existing.and_then(|row| row.route_kind.clone())),
|
||||
candidate_id: candidate_id.or_else(|| existing.and_then(|row| row.candidate_id.clone())),
|
||||
rust_result_digest,
|
||||
python_result_digest,
|
||||
match_status,
|
||||
status_code: status_code.or(existing.and_then(|row| row.status_code)),
|
||||
error_message: resolve_error_message(existing, error_message, match_status),
|
||||
created_at_unix_secs: existing
|
||||
.map(|row| row.created_at_unix_secs)
|
||||
.unwrap_or(recorded_at_unix_secs),
|
||||
updated_at_unix_secs: recorded_at_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_match_status(
|
||||
rust_result_digest: Option<&str>,
|
||||
python_result_digest: Option<&str>,
|
||||
) -> ShadowResultMatchStatus {
|
||||
match (rust_result_digest, python_result_digest) {
|
||||
(Some(rust_digest), Some(python_digest)) if rust_digest == python_digest => {
|
||||
ShadowResultMatchStatus::Match
|
||||
}
|
||||
(Some(_), Some(_)) => ShadowResultMatchStatus::Mismatch,
|
||||
_ => ShadowResultMatchStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_error_message(
|
||||
existing: Option<&StoredShadowResult>,
|
||||
error_message: Option<String>,
|
||||
match_status: ShadowResultMatchStatus,
|
||||
) -> Option<String> {
|
||||
if match_status == ShadowResultMatchStatus::Mismatch {
|
||||
error_message
|
||||
.or_else(|| existing.and_then(|row| row.error_message.clone()))
|
||||
.or_else(|| Some("shadow result digest mismatch".to_string()))
|
||||
} else {
|
||||
error_message.or_else(|| existing.and_then(|row| row.error_message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{merge_shadow_result_sample, RecordShadowResultSample, ShadowResultSampleOrigin};
|
||||
use crate::repository::shadow_results::{ShadowResultMatchStatus, UpsertShadowResult};
|
||||
|
||||
fn rust_sample(result_digest: &str, recorded_at_unix_secs: u64) -> RecordShadowResultSample {
|
||||
RecordShadowResultSample {
|
||||
trace_id: "trace-1".to_string(),
|
||||
request_fingerprint: "fp-1".to_string(),
|
||||
request_id: Some("req-1".to_string()),
|
||||
route_family: Some("openai".to_string()),
|
||||
route_kind: Some("chat".to_string()),
|
||||
candidate_id: None,
|
||||
origin: ShadowResultSampleOrigin::Rust,
|
||||
result_digest: result_digest.to_string(),
|
||||
status_code: Some(200),
|
||||
error_message: None,
|
||||
recorded_at_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn python_sample(result_digest: &str, recorded_at_unix_secs: u64) -> RecordShadowResultSample {
|
||||
RecordShadowResultSample {
|
||||
trace_id: "trace-1".to_string(),
|
||||
request_fingerprint: "fp-1".to_string(),
|
||||
request_id: Some("req-1".to_string()),
|
||||
route_family: Some("openai".to_string()),
|
||||
route_kind: Some("chat".to_string()),
|
||||
candidate_id: None,
|
||||
origin: ShadowResultSampleOrigin::Python,
|
||||
result_digest: result_digest.to_string(),
|
||||
status_code: Some(200),
|
||||
error_message: None,
|
||||
recorded_at_unix_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn stored(upsert: UpsertShadowResult) -> crate::repository::shadow_results::StoredShadowResult {
|
||||
upsert.into_stored()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_pending_until_both_samples_exist() {
|
||||
let merged = merge_shadow_result_sample(None, rust_sample("digest-1", 100));
|
||||
|
||||
assert_eq!(merged.match_status, ShadowResultMatchStatus::Pending);
|
||||
assert_eq!(merged.request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(merged.rust_result_digest.as_deref(), Some("digest-1"));
|
||||
assert!(merged.python_result_digest.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_match_when_rust_and_python_digests_are_equal() {
|
||||
let existing = stored(merge_shadow_result_sample(
|
||||
None,
|
||||
rust_sample("digest-1", 100),
|
||||
));
|
||||
let merged = merge_shadow_result_sample(Some(&existing), python_sample("digest-1", 200));
|
||||
|
||||
assert_eq!(merged.match_status, ShadowResultMatchStatus::Match);
|
||||
assert_eq!(merged.created_at_unix_secs, 100);
|
||||
assert_eq!(merged.updated_at_unix_secs, 200);
|
||||
assert_eq!(merged.request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(merged.rust_result_digest.as_deref(), Some("digest-1"));
|
||||
assert_eq!(merged.python_result_digest.as_deref(), Some("digest-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_mismatch_when_rust_and_python_digests_differ() {
|
||||
let existing = stored(merge_shadow_result_sample(
|
||||
None,
|
||||
rust_sample("digest-1", 100),
|
||||
));
|
||||
let merged = merge_shadow_result_sample(Some(&existing), python_sample("digest-2", 200));
|
||||
|
||||
assert_eq!(merged.match_status, ShadowResultMatchStatus::Mismatch);
|
||||
assert_eq!(
|
||||
merged.error_message.as_deref(),
|
||||
Some("shadow result digest mismatch")
|
||||
);
|
||||
assert_eq!(merged.request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(merged.rust_result_digest.as_deref(), Some("digest-1"));
|
||||
assert_eq!(merged.python_result_digest.as_deref(), Some("digest-2"));
|
||||
}
|
||||
}
|
||||
283
crates/aether-data/src/repository/shadow_results/sql.rs
Normal file
283
crates/aether-data/src/repository/shadow_results/sql.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use async_trait::async_trait;
|
||||
use futures_util::future::BoxFuture;
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use super::types::{
|
||||
ShadowResultLookupKey, ShadowResultMatchStatus, ShadowResultReadRepository,
|
||||
ShadowResultWriteRepository, StoredShadowResult, UpsertShadowResult,
|
||||
};
|
||||
use crate::postgres::PostgresTransactionRunner;
|
||||
use crate::DataLayerError;
|
||||
|
||||
const FIND_BY_TRACE_FINGERPRINT_SQL: &str = r#"
|
||||
SELECT
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
NULL::TEXT AS request_id,
|
||||
route_family,
|
||||
route_kind,
|
||||
candidate_id,
|
||||
rust_result_digest,
|
||||
python_result_digest,
|
||||
match_status,
|
||||
status_code,
|
||||
error_message,
|
||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
|
||||
FROM gateway_shadow_results
|
||||
WHERE trace_id = $1 AND request_fingerprint = $2
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const LIST_RECENT_SQL: &str = r#"
|
||||
SELECT
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
NULL::TEXT AS request_id,
|
||||
route_family,
|
||||
route_kind,
|
||||
candidate_id,
|
||||
rust_result_digest,
|
||||
python_result_digest,
|
||||
match_status,
|
||||
status_code,
|
||||
error_message,
|
||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
|
||||
FROM gateway_shadow_results
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $1
|
||||
"#;
|
||||
|
||||
const UPSERT_SQL: &str = r#"
|
||||
INSERT INTO gateway_shadow_results (
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
route_family,
|
||||
route_kind,
|
||||
candidate_id,
|
||||
rust_result_digest,
|
||||
python_result_digest,
|
||||
match_status,
|
||||
status_code,
|
||||
error_message,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
TO_TIMESTAMP($11::double precision),
|
||||
TO_TIMESTAMP($12::double precision)
|
||||
)
|
||||
ON CONFLICT (trace_id, request_fingerprint)
|
||||
DO UPDATE SET
|
||||
route_family = EXCLUDED.route_family,
|
||||
route_kind = EXCLUDED.route_kind,
|
||||
candidate_id = EXCLUDED.candidate_id,
|
||||
rust_result_digest = EXCLUDED.rust_result_digest,
|
||||
python_result_digest = EXCLUDED.python_result_digest,
|
||||
match_status = EXCLUDED.match_status,
|
||||
status_code = EXCLUDED.status_code,
|
||||
error_message = EXCLUDED.error_message,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
NULL::TEXT AS request_id,
|
||||
route_family,
|
||||
route_kind,
|
||||
candidate_id,
|
||||
rust_result_digest,
|
||||
python_result_digest,
|
||||
match_status,
|
||||
status_code,
|
||||
error_message,
|
||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxShadowResultRepository {
|
||||
pool: PgPool,
|
||||
tx_runner: PostgresTransactionRunner,
|
||||
}
|
||||
|
||||
impl SqlxShadowResultRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
let tx_runner = PostgresTransactionRunner::new(pool.clone());
|
||||
Self { pool, tx_runner }
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
pub fn transaction_runner(&self) -> &PostgresTransactionRunner {
|
||||
&self.tx_runner
|
||||
}
|
||||
|
||||
pub async fn find(
|
||||
&self,
|
||||
key: ShadowResultLookupKey<'_>,
|
||||
) -> Result<Option<StoredShadowResult>, DataLayerError> {
|
||||
match key {
|
||||
ShadowResultLookupKey::TraceFingerprint {
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
} => {
|
||||
self.find_by_trace_fingerprint(trace_id, request_fingerprint)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_trace_fingerprint(
|
||||
&self,
|
||||
trace_id: &str,
|
||||
request_fingerprint: &str,
|
||||
) -> Result<Option<StoredShadowResult>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_BY_TRACE_FINGERPRINT_SQL)
|
||||
.bind(trace_id)
|
||||
.bind(request_fingerprint)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.as_ref().map(map_shadow_result_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn list_recent(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredShadowResult>, DataLayerError> {
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query(LIST_RECENT_SQL)
|
||||
.bind(i64::try_from(limit).map_err(|_| {
|
||||
DataLayerError::UnexpectedValue(format!(
|
||||
"invalid recent shadow result limit: {limit}"
|
||||
))
|
||||
})?)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(map_shadow_result_row).collect()
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
result: UpsertShadowResult,
|
||||
) -> Result<StoredShadowResult, DataLayerError> {
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
Box::pin(async move {
|
||||
let row = sqlx::query(UPSERT_SQL)
|
||||
.bind(&result.trace_id)
|
||||
.bind(&result.request_fingerprint)
|
||||
.bind(&result.route_family)
|
||||
.bind(&result.route_kind)
|
||||
.bind(&result.candidate_id)
|
||||
.bind(&result.rust_result_digest)
|
||||
.bind(&result.python_result_digest)
|
||||
.bind(match_status_to_database(result.match_status))
|
||||
.bind(result.status_code.map(i32::from))
|
||||
.bind(&result.error_message)
|
||||
.bind(result.created_at_unix_secs as f64)
|
||||
.bind(result.updated_at_unix_secs as f64)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
map_shadow_result_row(&row)
|
||||
}) as BoxFuture<'_, Result<StoredShadowResult, DataLayerError>>
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShadowResultReadRepository for SqlxShadowResultRepository {
|
||||
async fn find(
|
||||
&self,
|
||||
key: ShadowResultLookupKey<'_>,
|
||||
) -> Result<Option<StoredShadowResult>, DataLayerError> {
|
||||
Self::find(self, key).await
|
||||
}
|
||||
|
||||
async fn list_recent(&self, limit: usize) -> Result<Vec<StoredShadowResult>, DataLayerError> {
|
||||
Self::list_recent(self, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShadowResultWriteRepository for SqlxShadowResultRepository {
|
||||
async fn upsert(
|
||||
&self,
|
||||
result: UpsertShadowResult,
|
||||
) -> Result<StoredShadowResult, DataLayerError> {
|
||||
Self::upsert(self, result).await
|
||||
}
|
||||
}
|
||||
|
||||
fn match_status_to_database(status: ShadowResultMatchStatus) -> &'static str {
|
||||
match status {
|
||||
ShadowResultMatchStatus::Pending => "pending",
|
||||
ShadowResultMatchStatus::Match => "match",
|
||||
ShadowResultMatchStatus::Mismatch => "mismatch",
|
||||
ShadowResultMatchStatus::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_shadow_result_row(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<StoredShadowResult, DataLayerError> {
|
||||
let match_status =
|
||||
ShadowResultMatchStatus::from_database(row.try_get::<String, _>("match_status")?.as_str())?;
|
||||
StoredShadowResult::new(
|
||||
row.try_get("trace_id")?,
|
||||
row.try_get("request_fingerprint")?,
|
||||
row.try_get("request_id")?,
|
||||
row.try_get("route_family")?,
|
||||
row.try_get("route_kind")?,
|
||||
row.try_get("candidate_id")?,
|
||||
row.try_get("rust_result_digest")?,
|
||||
row.try_get("python_result_digest")?,
|
||||
match_status,
|
||||
row.try_get("status_code")?,
|
||||
row.try_get("error_message")?,
|
||||
row.try_get("created_at_unix_secs")?,
|
||||
row.try_get("updated_at_unix_secs")?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SqlxShadowResultRepository;
|
||||
use crate::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
let factory = PostgresPoolFactory::new(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("factory should build");
|
||||
|
||||
let pool = factory.connect_lazy().expect("pool should build");
|
||||
let repository = SqlxShadowResultRepository::new(pool);
|
||||
let _ = repository.pool();
|
||||
let _ = repository.transaction_runner();
|
||||
}
|
||||
}
|
||||
227
crates/aether-data/src/repository/shadow_results/types.rs
Normal file
227
crates/aether-data/src/repository/shadow_results/types.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ShadowResultMatchStatus {
|
||||
Pending,
|
||||
Match,
|
||||
Mismatch,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ShadowResultMatchStatus {
|
||||
pub fn from_database(value: &str) -> Result<Self, crate::DataLayerError> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"match" => Ok(Self::Match),
|
||||
"mismatch" => Ok(Self::Mismatch),
|
||||
"error" => Ok(Self::Error),
|
||||
other => Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"unsupported gateway_shadow_results.match_status: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredShadowResult {
|
||||
pub trace_id: String,
|
||||
pub request_fingerprint: String,
|
||||
pub request_id: Option<String>,
|
||||
pub route_family: Option<String>,
|
||||
pub route_kind: Option<String>,
|
||||
pub candidate_id: Option<String>,
|
||||
pub rust_result_digest: Option<String>,
|
||||
pub python_result_digest: Option<String>,
|
||||
pub match_status: ShadowResultMatchStatus,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
impl StoredShadowResult {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
trace_id: String,
|
||||
request_fingerprint: String,
|
||||
request_id: Option<String>,
|
||||
route_family: Option<String>,
|
||||
route_kind: Option<String>,
|
||||
candidate_id: Option<String>,
|
||||
rust_result_digest: Option<String>,
|
||||
python_result_digest: Option<String>,
|
||||
match_status: ShadowResultMatchStatus,
|
||||
status_code: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
created_at_unix_secs: i64,
|
||||
updated_at_unix_secs: i64,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
let status_code = status_code
|
||||
.map(|value| {
|
||||
u16::try_from(value).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!("invalid status_code: {value}"))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let created_at_unix_secs = u64::try_from(created_at_unix_secs).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid created_at_unix_secs: {created_at_unix_secs}"
|
||||
))
|
||||
})?;
|
||||
let updated_at_unix_secs = u64::try_from(updated_at_unix_secs).map_err(|_| {
|
||||
crate::DataLayerError::UnexpectedValue(format!(
|
||||
"invalid updated_at_unix_secs: {updated_at_unix_secs}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
trace_id,
|
||||
request_fingerprint,
|
||||
request_id,
|
||||
route_family,
|
||||
route_kind,
|
||||
candidate_id,
|
||||
rust_result_digest,
|
||||
python_result_digest,
|
||||
match_status,
|
||||
status_code,
|
||||
error_message,
|
||||
created_at_unix_secs,
|
||||
updated_at_unix_secs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpsertShadowResult {
|
||||
pub trace_id: String,
|
||||
pub request_fingerprint: String,
|
||||
pub request_id: Option<String>,
|
||||
pub route_family: Option<String>,
|
||||
pub route_kind: Option<String>,
|
||||
pub candidate_id: Option<String>,
|
||||
pub rust_result_digest: Option<String>,
|
||||
pub python_result_digest: Option<String>,
|
||||
pub match_status: ShadowResultMatchStatus,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at_unix_secs: u64,
|
||||
pub updated_at_unix_secs: u64,
|
||||
}
|
||||
|
||||
impl UpsertShadowResult {
|
||||
pub fn into_stored(self) -> StoredShadowResult {
|
||||
StoredShadowResult {
|
||||
trace_id: self.trace_id,
|
||||
request_fingerprint: self.request_fingerprint,
|
||||
request_id: self.request_id,
|
||||
route_family: self.route_family,
|
||||
route_kind: self.route_kind,
|
||||
candidate_id: self.candidate_id,
|
||||
rust_result_digest: self.rust_result_digest,
|
||||
python_result_digest: self.python_result_digest,
|
||||
match_status: self.match_status,
|
||||
status_code: self.status_code,
|
||||
error_message: self.error_message,
|
||||
created_at_unix_secs: self.created_at_unix_secs,
|
||||
updated_at_unix_secs: self.updated_at_unix_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShadowResultLookupKey<'a> {
|
||||
TraceFingerprint {
|
||||
trace_id: &'a str,
|
||||
request_fingerprint: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShadowResultReadRepository: Send + Sync {
|
||||
async fn find(
|
||||
&self,
|
||||
key: ShadowResultLookupKey<'_>,
|
||||
) -> Result<Option<StoredShadowResult>, crate::DataLayerError>;
|
||||
|
||||
async fn list_recent(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredShadowResult>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ShadowResultWriteRepository: Send + Sync {
|
||||
async fn upsert(
|
||||
&self,
|
||||
result: UpsertShadowResult,
|
||||
) -> Result<StoredShadowResult, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
pub trait ShadowResultRepository:
|
||||
ShadowResultReadRepository + ShadowResultWriteRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
impl<T> ShadowResultRepository for T where
|
||||
T: ShadowResultReadRepository + ShadowResultWriteRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ShadowResultMatchStatus, StoredShadowResult};
|
||||
|
||||
#[test]
|
||||
fn parses_match_status_from_database_text() {
|
||||
assert_eq!(
|
||||
ShadowResultMatchStatus::from_database("match").expect("status should parse"),
|
||||
ShadowResultMatchStatus::Match
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_database_status() {
|
||||
assert!(ShadowResultMatchStatus::from_database("mystery").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_numeric_fields() {
|
||||
assert!(StoredShadowResult::new(
|
||||
"trace-1".to_string(),
|
||||
"fp-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ShadowResultMatchStatus::Pending,
|
||||
Some(-1),
|
||||
None,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_negative_updated_at_values() {
|
||||
assert!(StoredShadowResult::new(
|
||||
"trace-1".to_string(),
|
||||
"fp-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ShadowResultMatchStatus::Pending,
|
||||
Some(200),
|
||||
None,
|
||||
1,
|
||||
-1,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user