mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 20:50:20 +08:00
chore: update gateway pressure observability
This commit is contained in:
@@ -3,7 +3,8 @@ use super::{
|
||||
};
|
||||
use crate::error::{SqlResultExt, SqlxResultExt};
|
||||
use crate::maintenance::{
|
||||
DatabaseMaintenanceSummary, DatabasePoolSummary, StatsDailyAggregationInput,
|
||||
DatabaseMaintenanceSummary, DatabasePoolSummary, DatabasePostgresActivityGroup,
|
||||
DatabasePostgresObservabilitySnapshot, StatsDailyAggregationInput,
|
||||
StatsDailyAggregationSummary, StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
|
||||
WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult,
|
||||
};
|
||||
@@ -14,6 +15,7 @@ use crate::repository::system::{
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
use sqlx::migrate::MigrateError;
|
||||
use sqlx::Row;
|
||||
|
||||
fn maintenance_identifier(value: &str) -> Result<&str, DataLayerError> {
|
||||
let valid = !value.is_empty()
|
||||
@@ -109,6 +111,25 @@ impl DataBackends {
|
||||
self.sql_backend().map(SqlBackendRef::database_pool_summary)
|
||||
}
|
||||
|
||||
pub async fn postgres_observability_snapshot(
|
||||
&self,
|
||||
) -> Result<Option<DatabasePostgresObservabilitySnapshot>, DataLayerError> {
|
||||
match self.postgres() {
|
||||
Some(postgres) => postgres.postgres_observability_snapshot().await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn postgres_activity_groups(
|
||||
&self,
|
||||
limit: i64,
|
||||
) -> Result<Vec<DatabasePostgresActivityGroup>, DataLayerError> {
|
||||
match self.postgres() {
|
||||
Some(postgres) => postgres.postgres_activity_groups(limit).await,
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn aggregate_wallet_daily_usage(
|
||||
&self,
|
||||
input: &WalletDailyUsageAggregationInput,
|
||||
@@ -260,6 +281,633 @@ impl PostgresBackend {
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
pub async fn postgres_observability_snapshot(
|
||||
&self,
|
||||
) -> Result<DatabasePostgresObservabilitySnapshot, DataLayerError> {
|
||||
const ACTIVITY_SQL: &str = r#"
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE state = 'active')::BIGINT AS active_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'idle')::BIGINT AS idle_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'idle in transaction')::BIGINT AS idle_in_transaction_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'active' AND wait_event_type IS NOT NULL)::BIGINT AS waiting_connections,
|
||||
COUNT(*) FILTER (WHERE state = 'active' AND wait_event_type = 'Lock')::BIGINT AS lock_waiting_connections,
|
||||
COALESCE(MAX(EXTRACT(EPOCH FROM now() - query_start) * 1000) FILTER (WHERE state = 'active' AND query_start IS NOT NULL), 0)::BIGINT AS oldest_active_query_age_ms,
|
||||
COALESCE(MAX(EXTRACT(EPOCH FROM now() - xact_start) * 1000) FILTER (WHERE xact_start IS NOT NULL), 0)::BIGINT AS oldest_transaction_age_ms
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid()
|
||||
"#;
|
||||
const DEADLOCKS_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(deadlocks), 0)::BIGINT AS deadlocks_total,
|
||||
COALESCE(SUM(blks_read), 0)::BIGINT AS block_read_total,
|
||||
COALESCE(SUM(blks_hit), 0)::BIGINT AS block_hit_total,
|
||||
COALESCE(SUM(temp_files), 0)::BIGINT AS temp_files_total,
|
||||
COALESCE(SUM(temp_bytes), 0)::BIGINT AS temp_bytes_total,
|
||||
COALESCE(SUM(xact_commit), 0)::BIGINT AS xact_commit_total,
|
||||
COALESCE(SUM(xact_rollback), 0)::BIGINT AS xact_rollback_total
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
"#;
|
||||
|
||||
let activity = sqlx::query(ACTIVITY_SQL)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let database = sqlx::query(DEADLOCKS_SQL)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
let wal = self.postgres_wal_observability_snapshot().await;
|
||||
let checkpoint = self.postgres_checkpoint_observability_snapshot().await;
|
||||
let statements = self.postgres_statement_observability_snapshot().await;
|
||||
let block_read_total = row_u64(&database, "block_read_total")?;
|
||||
let block_hit_total = row_u64(&database, "block_hit_total")?;
|
||||
|
||||
Ok(DatabasePostgresObservabilitySnapshot {
|
||||
active_connections: row_u64(&activity, "active_connections")?,
|
||||
idle_connections: row_u64(&activity, "idle_connections")?,
|
||||
idle_in_transaction_connections: row_u64(&activity, "idle_in_transaction_connections")?,
|
||||
waiting_connections: row_u64(&activity, "waiting_connections")?,
|
||||
lock_waiting_connections: row_u64(&activity, "lock_waiting_connections")?,
|
||||
oldest_active_query_age_ms: row_u64(&activity, "oldest_active_query_age_ms")?,
|
||||
oldest_transaction_age_ms: row_u64(&activity, "oldest_transaction_age_ms")?,
|
||||
deadlocks_total: row_u64(&database, "deadlocks_total")?,
|
||||
block_read_total,
|
||||
block_hit_total,
|
||||
block_cache_hit_rate_basis_points: ratio_to_basis_points(
|
||||
block_hit_total,
|
||||
block_read_total.saturating_add(block_hit_total),
|
||||
),
|
||||
temp_files_total: row_u64(&database, "temp_files_total")?,
|
||||
temp_bytes_total: row_u64(&database, "temp_bytes_total")?,
|
||||
xact_commit_total: row_u64(&database, "xact_commit_total")?,
|
||||
xact_rollback_total: row_u64(&database, "xact_rollback_total")?,
|
||||
wal_observability_available: wal.available,
|
||||
wal_observability_unavailable: wal.unavailable,
|
||||
wal_records_total: wal.records_total,
|
||||
wal_fpi_total: wal.fpi_total,
|
||||
wal_bytes_total: wal.bytes_total,
|
||||
wal_buffers_full_total: wal.buffers_full_total,
|
||||
wal_write_total: wal.write_total,
|
||||
wal_sync_total: wal.sync_total,
|
||||
wal_write_time_ms_total: wal.write_time_ms_total,
|
||||
wal_sync_time_ms_total: wal.sync_time_ms_total,
|
||||
checkpoint_observability_available: checkpoint.available,
|
||||
checkpoint_observability_unavailable: checkpoint.unavailable,
|
||||
checkpoints_timed_total: checkpoint.timed_total,
|
||||
checkpoints_requested_total: checkpoint.requested_total,
|
||||
checkpoint_write_time_ms_total: checkpoint.write_time_ms_total,
|
||||
checkpoint_sync_time_ms_total: checkpoint.sync_time_ms_total,
|
||||
buffers_checkpoint_total: checkpoint.buffers_checkpoint_total,
|
||||
buffers_backend_total: checkpoint.buffers_backend_total,
|
||||
statement_observability_available: statements.available,
|
||||
statement_observability_unavailable: statements.unavailable,
|
||||
statement_top_calls_total: statements.top_calls_total,
|
||||
statement_top_exec_time_ms_total: statements.top_exec_time_ms_total,
|
||||
statement_top_max_mean_exec_time_ms: statements.top_max_mean_exec_time_ms,
|
||||
statement_top_max_exec_time_ms: statements.top_max_exec_time_ms,
|
||||
statement_top_shared_blks_read_total: statements.top_shared_blks_read_total,
|
||||
statement_top_shared_blks_hit_total: statements.top_shared_blks_hit_total,
|
||||
statement_top_temp_blks_total: statements.top_temp_blks_total,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn postgres_activity_groups(
|
||||
&self,
|
||||
limit: i64,
|
||||
) -> Result<Vec<DatabasePostgresActivityGroup>, DataLayerError> {
|
||||
const ACTIVITY_GROUP_SQL: &str = r#"
|
||||
WITH normalized_activity AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(state, ''), 'unknown') AS state,
|
||||
COALESCE(NULLIF(wait_event_type, ''), 'none') AS wait_event_type,
|
||||
COALESCE(NULLIF(wait_event, ''), 'none') AS wait_event,
|
||||
LEFT(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
COALESCE(NULLIF(query, ''), '<empty>'),
|
||||
'\s+',
|
||||
' ',
|
||||
'g'
|
||||
),
|
||||
'([0-9a-fA-F]{8,}|[0-9]+)',
|
||||
'?',
|
||||
'g'
|
||||
),
|
||||
160
|
||||
) AS query_prefix,
|
||||
COALESCE(EXTRACT(EPOCH FROM now() - query_start) * 1000, 0)::BIGINT AS query_age_ms,
|
||||
COALESCE(EXTRACT(EPOCH FROM now() - xact_start) * 1000, 0)::BIGINT AS transaction_age_ms
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid()
|
||||
)
|
||||
SELECT
|
||||
state,
|
||||
wait_event_type,
|
||||
wait_event,
|
||||
query_prefix,
|
||||
COUNT(*)::BIGINT AS connections,
|
||||
COALESCE(MAX(query_age_ms), 0)::BIGINT AS max_query_age_ms,
|
||||
COALESCE(MAX(transaction_age_ms), 0)::BIGINT AS max_transaction_age_ms
|
||||
FROM normalized_activity
|
||||
GROUP BY state, wait_event_type, wait_event, query_prefix
|
||||
ORDER BY connections DESC, max_transaction_age_ms DESC, max_query_age_ms DESC
|
||||
LIMIT $1
|
||||
"#;
|
||||
let rows = sqlx::query(ACTIVITY_GROUP_SQL)
|
||||
.bind(limit.clamp(1, 20))
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_postgres_err()?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(DatabasePostgresActivityGroup {
|
||||
state: row.try_get::<String, _>("state").map_postgres_err()?,
|
||||
wait_event_type: row
|
||||
.try_get::<String, _>("wait_event_type")
|
||||
.map_postgres_err()?,
|
||||
wait_event: row.try_get::<String, _>("wait_event").map_postgres_err()?,
|
||||
query_prefix: row
|
||||
.try_get::<String, _>("query_prefix")
|
||||
.map_postgres_err()?,
|
||||
connections: row_u64(&row, "connections")?,
|
||||
max_query_age_ms: row_u64(&row, "max_query_age_ms")?,
|
||||
max_transaction_age_ms: row_u64(&row, "max_transaction_age_ms")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn postgres_wal_observability_snapshot(&self) -> PostgresWalObservabilitySnapshot {
|
||||
if !self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_catalog.pg_stat_wal",
|
||||
&["wal_records", "wal_fpi", "wal_bytes", "wal_buffers_full"],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return PostgresWalObservabilitySnapshot::default();
|
||||
}
|
||||
|
||||
const WAL_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(wal_records), 0)::BIGINT AS records_total,
|
||||
COALESCE(SUM(wal_fpi), 0)::BIGINT AS fpi_total,
|
||||
COALESCE(SUM(wal_bytes), 0)::BIGINT AS bytes_total,
|
||||
COALESCE(SUM(wal_buffers_full), 0)::BIGINT AS buffers_full_total
|
||||
FROM pg_stat_wal
|
||||
"#;
|
||||
match sqlx::query(WAL_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => {
|
||||
let io = self.postgres_wal_io_observability_snapshot().await;
|
||||
PostgresWalObservabilitySnapshot {
|
||||
available: 1,
|
||||
records_total: row_u64(&row, "records_total").unwrap_or_default(),
|
||||
fpi_total: row_u64(&row, "fpi_total").unwrap_or_default(),
|
||||
bytes_total: row_u64(&row, "bytes_total").unwrap_or_default(),
|
||||
buffers_full_total: row_u64(&row, "buffers_full_total").unwrap_or_default(),
|
||||
write_total: io.write_total,
|
||||
sync_total: io.sync_total,
|
||||
write_time_ms_total: io.write_time_ms_total,
|
||||
sync_time_ms_total: io.sync_time_ms_total,
|
||||
..PostgresWalObservabilitySnapshot::default()
|
||||
}
|
||||
}
|
||||
Err(_) => PostgresWalObservabilitySnapshot {
|
||||
unavailable: 1,
|
||||
..PostgresWalObservabilitySnapshot::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_wal_io_observability_snapshot(&self) -> PostgresWalIoObservabilitySnapshot {
|
||||
if self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_catalog.pg_stat_wal",
|
||||
&["wal_write", "wal_sync", "wal_write_time", "wal_sync_time"],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return self.postgres_wal_legacy_io_observability_snapshot().await;
|
||||
}
|
||||
|
||||
if !self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_catalog.pg_stat_io",
|
||||
&["object", "writes", "fsyncs", "write_time", "fsync_time"],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return PostgresWalIoObservabilitySnapshot::default();
|
||||
}
|
||||
|
||||
const WAL_IO_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(writes), 0)::BIGINT AS write_total,
|
||||
COALESCE(SUM(fsyncs), 0)::BIGINT AS sync_total,
|
||||
COALESCE(SUM(write_time), 0)::BIGINT AS write_time_ms_total,
|
||||
COALESCE(SUM(fsync_time), 0)::BIGINT AS sync_time_ms_total
|
||||
FROM pg_stat_io
|
||||
WHERE object = 'wal'
|
||||
"#;
|
||||
match sqlx::query(WAL_IO_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => PostgresWalIoObservabilitySnapshot {
|
||||
write_total: row_u64(&row, "write_total").unwrap_or_default(),
|
||||
sync_total: row_u64(&row, "sync_total").unwrap_or_default(),
|
||||
write_time_ms_total: row_u64(&row, "write_time_ms_total").unwrap_or_default(),
|
||||
sync_time_ms_total: row_u64(&row, "sync_time_ms_total").unwrap_or_default(),
|
||||
},
|
||||
Err(_) => PostgresWalIoObservabilitySnapshot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_wal_legacy_io_observability_snapshot(
|
||||
&self,
|
||||
) -> PostgresWalIoObservabilitySnapshot {
|
||||
const WAL_IO_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(wal_write), 0)::BIGINT AS write_total,
|
||||
COALESCE(SUM(wal_sync), 0)::BIGINT AS sync_total,
|
||||
COALESCE(SUM(wal_write_time), 0)::BIGINT AS write_time_ms_total,
|
||||
COALESCE(SUM(wal_sync_time), 0)::BIGINT AS sync_time_ms_total
|
||||
FROM pg_stat_wal
|
||||
"#;
|
||||
match sqlx::query(WAL_IO_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => PostgresWalIoObservabilitySnapshot {
|
||||
write_total: row_u64(&row, "write_total").unwrap_or_default(),
|
||||
sync_total: row_u64(&row, "sync_total").unwrap_or_default(),
|
||||
write_time_ms_total: row_u64(&row, "write_time_ms_total").unwrap_or_default(),
|
||||
sync_time_ms_total: row_u64(&row, "sync_time_ms_total").unwrap_or_default(),
|
||||
},
|
||||
Err(_) => PostgresWalIoObservabilitySnapshot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_checkpoint_observability_snapshot(
|
||||
&self,
|
||||
) -> PostgresCheckpointObservabilitySnapshot {
|
||||
if self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_catalog.pg_stat_checkpointer",
|
||||
&[
|
||||
"num_timed",
|
||||
"num_requested",
|
||||
"write_time",
|
||||
"sync_time",
|
||||
"buffers_written",
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return self
|
||||
.postgres_checkpoint_observability_snapshot_from_checkpointer()
|
||||
.await;
|
||||
}
|
||||
|
||||
if !self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_catalog.pg_stat_bgwriter",
|
||||
&[
|
||||
"checkpoints_timed",
|
||||
"checkpoints_req",
|
||||
"checkpoint_write_time",
|
||||
"checkpoint_sync_time",
|
||||
"buffers_checkpoint",
|
||||
"buffers_backend",
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return PostgresCheckpointObservabilitySnapshot::default();
|
||||
}
|
||||
|
||||
const CHECKPOINT_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(checkpoints_timed), 0)::BIGINT AS timed_total,
|
||||
COALESCE(SUM(checkpoints_req), 0)::BIGINT AS requested_total,
|
||||
COALESCE(SUM(checkpoint_write_time), 0)::BIGINT AS write_time_ms_total,
|
||||
COALESCE(SUM(checkpoint_sync_time), 0)::BIGINT AS sync_time_ms_total,
|
||||
COALESCE(SUM(buffers_checkpoint), 0)::BIGINT AS buffers_checkpoint_total,
|
||||
COALESCE(SUM(buffers_backend), 0)::BIGINT AS buffers_backend_total
|
||||
FROM pg_stat_bgwriter
|
||||
"#;
|
||||
match sqlx::query(CHECKPOINT_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => PostgresCheckpointObservabilitySnapshot {
|
||||
available: 1,
|
||||
timed_total: row_u64(&row, "timed_total").unwrap_or_default(),
|
||||
requested_total: row_u64(&row, "requested_total").unwrap_or_default(),
|
||||
write_time_ms_total: row_u64(&row, "write_time_ms_total").unwrap_or_default(),
|
||||
sync_time_ms_total: row_u64(&row, "sync_time_ms_total").unwrap_or_default(),
|
||||
buffers_checkpoint_total: row_u64(&row, "buffers_checkpoint_total")
|
||||
.unwrap_or_default(),
|
||||
buffers_backend_total: row_u64(&row, "buffers_backend_total").unwrap_or_default(),
|
||||
..PostgresCheckpointObservabilitySnapshot::default()
|
||||
},
|
||||
Err(_) => PostgresCheckpointObservabilitySnapshot {
|
||||
unavailable: 1,
|
||||
..PostgresCheckpointObservabilitySnapshot::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_checkpoint_observability_snapshot_from_checkpointer(
|
||||
&self,
|
||||
) -> PostgresCheckpointObservabilitySnapshot {
|
||||
const CHECKPOINT_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(num_timed), 0)::BIGINT AS timed_total,
|
||||
COALESCE(SUM(num_requested), 0)::BIGINT AS requested_total,
|
||||
COALESCE(SUM(write_time), 0)::BIGINT AS write_time_ms_total,
|
||||
COALESCE(SUM(sync_time), 0)::BIGINT AS sync_time_ms_total,
|
||||
COALESCE(SUM(buffers_written), 0)::BIGINT AS buffers_checkpoint_total
|
||||
FROM pg_stat_checkpointer
|
||||
"#;
|
||||
match sqlx::query(CHECKPOINT_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => PostgresCheckpointObservabilitySnapshot {
|
||||
available: 1,
|
||||
timed_total: row_u64(&row, "timed_total").unwrap_or_default(),
|
||||
requested_total: row_u64(&row, "requested_total").unwrap_or_default(),
|
||||
write_time_ms_total: row_u64(&row, "write_time_ms_total").unwrap_or_default(),
|
||||
sync_time_ms_total: row_u64(&row, "sync_time_ms_total").unwrap_or_default(),
|
||||
buffers_checkpoint_total: row_u64(&row, "buffers_checkpoint_total")
|
||||
.unwrap_or_default(),
|
||||
..PostgresCheckpointObservabilitySnapshot::default()
|
||||
},
|
||||
Err(_) => PostgresCheckpointObservabilitySnapshot {
|
||||
unavailable: 1,
|
||||
..PostgresCheckpointObservabilitySnapshot::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_statement_observability_snapshot(
|
||||
&self,
|
||||
) -> PostgresStatementObservabilitySnapshot {
|
||||
let extension_installed = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')",
|
||||
)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !extension_installed {
|
||||
return PostgresStatementObservabilitySnapshot::default();
|
||||
}
|
||||
if self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_stat_statements",
|
||||
&[
|
||||
"calls",
|
||||
"total_exec_time",
|
||||
"mean_exec_time",
|
||||
"max_exec_time",
|
||||
"shared_blks_read",
|
||||
"shared_blks_hit",
|
||||
"temp_blks_read",
|
||||
"temp_blks_written",
|
||||
"dbid",
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return self
|
||||
.postgres_statement_observability_snapshot_with_exec_time()
|
||||
.await;
|
||||
}
|
||||
if self
|
||||
.postgres_catalog_relation_has_columns(
|
||||
"pg_stat_statements",
|
||||
&[
|
||||
"calls",
|
||||
"total_time",
|
||||
"mean_time",
|
||||
"max_time",
|
||||
"shared_blks_read",
|
||||
"shared_blks_hit",
|
||||
"temp_blks_read",
|
||||
"temp_blks_written",
|
||||
"dbid",
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return self
|
||||
.postgres_statement_observability_snapshot_with_total_time()
|
||||
.await;
|
||||
}
|
||||
|
||||
PostgresStatementObservabilitySnapshot::default()
|
||||
}
|
||||
|
||||
async fn postgres_statement_observability_snapshot_with_exec_time(
|
||||
&self,
|
||||
) -> PostgresStatementObservabilitySnapshot {
|
||||
const STATEMENTS_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(calls), 0)::BIGINT AS top_calls_total,
|
||||
COALESCE(SUM(total_exec_time), 0)::BIGINT AS top_exec_time_ms_total,
|
||||
COALESCE(MAX(mean_exec_time), 0)::BIGINT AS top_max_mean_exec_time_ms,
|
||||
COALESCE(MAX(max_exec_time), 0)::BIGINT AS top_max_exec_time_ms,
|
||||
COALESCE(SUM(shared_blks_read), 0)::BIGINT AS top_shared_blks_read_total,
|
||||
COALESCE(SUM(shared_blks_hit), 0)::BIGINT AS top_shared_blks_hit_total,
|
||||
COALESCE(SUM(temp_blks_read + temp_blks_written), 0)::BIGINT AS top_temp_blks_total
|
||||
FROM (
|
||||
SELECT
|
||||
calls,
|
||||
total_exec_time,
|
||||
mean_exec_time,
|
||||
max_exec_time,
|
||||
shared_blks_read,
|
||||
shared_blks_hit,
|
||||
temp_blks_read,
|
||||
temp_blks_written
|
||||
FROM pg_stat_statements
|
||||
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
ORDER BY total_exec_time DESC
|
||||
LIMIT 20
|
||||
) top_statements
|
||||
"#;
|
||||
match sqlx::query(STATEMENTS_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => PostgresStatementObservabilitySnapshot {
|
||||
available: 1,
|
||||
top_calls_total: row_u64(&row, "top_calls_total").unwrap_or_default(),
|
||||
top_exec_time_ms_total: row_u64(&row, "top_exec_time_ms_total").unwrap_or_default(),
|
||||
top_max_mean_exec_time_ms: row_u64(&row, "top_max_mean_exec_time_ms")
|
||||
.unwrap_or_default(),
|
||||
top_max_exec_time_ms: row_u64(&row, "top_max_exec_time_ms").unwrap_or_default(),
|
||||
top_shared_blks_read_total: row_u64(&row, "top_shared_blks_read_total")
|
||||
.unwrap_or_default(),
|
||||
top_shared_blks_hit_total: row_u64(&row, "top_shared_blks_hit_total")
|
||||
.unwrap_or_default(),
|
||||
top_temp_blks_total: row_u64(&row, "top_temp_blks_total").unwrap_or_default(),
|
||||
..PostgresStatementObservabilitySnapshot::default()
|
||||
},
|
||||
Err(_) => PostgresStatementObservabilitySnapshot {
|
||||
unavailable: 1,
|
||||
..PostgresStatementObservabilitySnapshot::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_statement_observability_snapshot_with_total_time(
|
||||
&self,
|
||||
) -> PostgresStatementObservabilitySnapshot {
|
||||
const STATEMENTS_SQL: &str = r#"
|
||||
SELECT
|
||||
COALESCE(SUM(calls), 0)::BIGINT AS top_calls_total,
|
||||
COALESCE(SUM(total_time), 0)::BIGINT AS top_exec_time_ms_total,
|
||||
COALESCE(MAX(mean_time), 0)::BIGINT AS top_max_mean_exec_time_ms,
|
||||
COALESCE(MAX(max_time), 0)::BIGINT AS top_max_exec_time_ms,
|
||||
COALESCE(SUM(shared_blks_read), 0)::BIGINT AS top_shared_blks_read_total,
|
||||
COALESCE(SUM(shared_blks_hit), 0)::BIGINT AS top_shared_blks_hit_total,
|
||||
COALESCE(SUM(temp_blks_read + temp_blks_written), 0)::BIGINT AS top_temp_blks_total
|
||||
FROM (
|
||||
SELECT
|
||||
calls,
|
||||
total_time,
|
||||
mean_time,
|
||||
max_time,
|
||||
shared_blks_read,
|
||||
shared_blks_hit,
|
||||
temp_blks_read,
|
||||
temp_blks_written
|
||||
FROM pg_stat_statements
|
||||
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
ORDER BY total_time DESC
|
||||
LIMIT 20
|
||||
) top_statements
|
||||
"#;
|
||||
match sqlx::query(STATEMENTS_SQL).fetch_one(self.pool()).await {
|
||||
Ok(row) => PostgresStatementObservabilitySnapshot {
|
||||
available: 1,
|
||||
top_calls_total: row_u64(&row, "top_calls_total").unwrap_or_default(),
|
||||
top_exec_time_ms_total: row_u64(&row, "top_exec_time_ms_total").unwrap_or_default(),
|
||||
top_max_mean_exec_time_ms: row_u64(&row, "top_max_mean_exec_time_ms")
|
||||
.unwrap_or_default(),
|
||||
top_max_exec_time_ms: row_u64(&row, "top_max_exec_time_ms").unwrap_or_default(),
|
||||
top_shared_blks_read_total: row_u64(&row, "top_shared_blks_read_total")
|
||||
.unwrap_or_default(),
|
||||
top_shared_blks_hit_total: row_u64(&row, "top_shared_blks_hit_total")
|
||||
.unwrap_or_default(),
|
||||
top_temp_blks_total: row_u64(&row, "top_temp_blks_total").unwrap_or_default(),
|
||||
..PostgresStatementObservabilitySnapshot::default()
|
||||
},
|
||||
Err(_) => PostgresStatementObservabilitySnapshot {
|
||||
unavailable: 1,
|
||||
..PostgresStatementObservabilitySnapshot::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn postgres_catalog_relation_has_columns(
|
||||
&self,
|
||||
relation: &str,
|
||||
columns: &[&str],
|
||||
) -> bool {
|
||||
if !self.postgres_catalog_relation_exists(relation).await {
|
||||
return false;
|
||||
}
|
||||
|
||||
for column in columns {
|
||||
if !self.postgres_catalog_column_exists(relation, column).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn postgres_catalog_column_exists(&self, relation: &str, column: &str) -> bool {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_attribute
|
||||
WHERE attrelid = to_regclass($1)
|
||||
AND attname = $2
|
||||
AND NOT attisdropped
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(relation)
|
||||
.bind(column)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn postgres_catalog_relation_exists(&self, relation: &str) -> bool {
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT to_regclass($1)::TEXT")
|
||||
.bind(relation)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn row_u64(row: &sqlx::postgres::PgRow, name: &str) -> Result<u64, DataLayerError> {
|
||||
row.try_get::<i64, _>(name)
|
||||
.map(u64_from_i64)
|
||||
.map_postgres_err()
|
||||
}
|
||||
|
||||
fn u64_from_i64(value: i64) -> u64 {
|
||||
u64::try_from(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ratio_to_basis_points(value: u64, total: u64) -> u64 {
|
||||
value.saturating_mul(10_000).checked_div(total).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct PostgresWalObservabilitySnapshot {
|
||||
available: u64,
|
||||
unavailable: u64,
|
||||
records_total: u64,
|
||||
fpi_total: u64,
|
||||
bytes_total: u64,
|
||||
buffers_full_total: u64,
|
||||
write_total: u64,
|
||||
sync_total: u64,
|
||||
write_time_ms_total: u64,
|
||||
sync_time_ms_total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct PostgresWalIoObservabilitySnapshot {
|
||||
write_total: u64,
|
||||
sync_total: u64,
|
||||
write_time_ms_total: u64,
|
||||
sync_time_ms_total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct PostgresCheckpointObservabilitySnapshot {
|
||||
available: u64,
|
||||
unavailable: u64,
|
||||
timed_total: u64,
|
||||
requested_total: u64,
|
||||
write_time_ms_total: u64,
|
||||
sync_time_ms_total: u64,
|
||||
buffers_checkpoint_total: u64,
|
||||
buffers_backend_total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct PostgresStatementObservabilitySnapshot {
|
||||
available: u64,
|
||||
unavailable: u64,
|
||||
top_calls_total: u64,
|
||||
top_exec_time_ms_total: u64,
|
||||
top_max_mean_exec_time_ms: u64,
|
||||
top_max_exec_time_ms: u64,
|
||||
top_shared_blks_read_total: u64,
|
||||
top_shared_blks_hit_total: u64,
|
||||
top_temp_blks_total: u64,
|
||||
}
|
||||
|
||||
impl MysqlBackend {
|
||||
|
||||
@@ -24,7 +24,8 @@ pub use config::DataLayerConfig;
|
||||
pub use database::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig, DEFAULT_SQLITE_DATABASE_URL};
|
||||
pub use error::DataLayerError;
|
||||
pub use maintenance::{
|
||||
DatabaseMaintenanceSummary, DatabasePoolSummary, StatsDailyAggregationInput,
|
||||
DatabaseMaintenanceSummary, DatabasePoolSummary, DatabasePostgresActivityGroup,
|
||||
DatabasePostgresObservabilitySnapshot, StatsDailyAggregationInput,
|
||||
StatsDailyAggregationSummary, StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
|
||||
WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,63 @@ pub struct DatabasePoolSummary {
|
||||
pub usage_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct DatabasePostgresObservabilitySnapshot {
|
||||
pub active_connections: u64,
|
||||
pub idle_connections: u64,
|
||||
pub idle_in_transaction_connections: u64,
|
||||
pub waiting_connections: u64,
|
||||
pub lock_waiting_connections: u64,
|
||||
pub oldest_active_query_age_ms: u64,
|
||||
pub oldest_transaction_age_ms: u64,
|
||||
pub deadlocks_total: u64,
|
||||
pub block_read_total: u64,
|
||||
pub block_hit_total: u64,
|
||||
pub block_cache_hit_rate_basis_points: u64,
|
||||
pub temp_files_total: u64,
|
||||
pub temp_bytes_total: u64,
|
||||
pub xact_commit_total: u64,
|
||||
pub xact_rollback_total: u64,
|
||||
pub wal_observability_available: u64,
|
||||
pub wal_observability_unavailable: u64,
|
||||
pub wal_records_total: u64,
|
||||
pub wal_fpi_total: u64,
|
||||
pub wal_bytes_total: u64,
|
||||
pub wal_buffers_full_total: u64,
|
||||
pub wal_write_total: u64,
|
||||
pub wal_sync_total: u64,
|
||||
pub wal_write_time_ms_total: u64,
|
||||
pub wal_sync_time_ms_total: u64,
|
||||
pub checkpoint_observability_available: u64,
|
||||
pub checkpoint_observability_unavailable: u64,
|
||||
pub checkpoints_timed_total: u64,
|
||||
pub checkpoints_requested_total: u64,
|
||||
pub checkpoint_write_time_ms_total: u64,
|
||||
pub checkpoint_sync_time_ms_total: u64,
|
||||
pub buffers_checkpoint_total: u64,
|
||||
pub buffers_backend_total: u64,
|
||||
pub statement_observability_available: u64,
|
||||
pub statement_observability_unavailable: u64,
|
||||
pub statement_top_calls_total: u64,
|
||||
pub statement_top_exec_time_ms_total: u64,
|
||||
pub statement_top_max_mean_exec_time_ms: u64,
|
||||
pub statement_top_max_exec_time_ms: u64,
|
||||
pub statement_top_shared_blks_read_total: u64,
|
||||
pub statement_top_shared_blks_hit_total: u64,
|
||||
pub statement_top_temp_blks_total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct DatabasePostgresActivityGroup {
|
||||
pub state: String,
|
||||
pub wait_event_type: String,
|
||||
pub wait_event: String,
|
||||
pub query_prefix: String,
|
||||
pub connections: u64,
|
||||
pub max_query_age_ms: u64,
|
||||
pub max_transaction_age_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WalletDailyUsageAggregationInput {
|
||||
pub billing_date: String,
|
||||
|
||||
@@ -374,6 +374,17 @@ fn merge_candidate(
|
||||
candidate: UpsertRequestCandidateRecord,
|
||||
existing: Option<StoredRequestCandidate>,
|
||||
) -> Result<StoredRequestCandidate, DataLayerError> {
|
||||
let preserve_existing_lifecycle = existing.as_ref().is_some_and(|value| {
|
||||
request_candidate_lifecycle_would_regress(value.status, candidate.status)
|
||||
});
|
||||
let merged_status = if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.map(|value| value.status)
|
||||
.unwrap_or(candidate.status)
|
||||
} else {
|
||||
candidate.status
|
||||
};
|
||||
let created_at_unix_ms = candidate
|
||||
.created_at_unix_ms
|
||||
.filter(|value| *value > 1000)
|
||||
@@ -426,7 +437,7 @@ fn merge_candidate(
|
||||
candidate
|
||||
.key_id
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.key_id.clone())),
|
||||
candidate.status,
|
||||
merged_status,
|
||||
candidate.skip_reason.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
@@ -435,25 +446,48 @@ fn merge_candidate(
|
||||
candidate
|
||||
.is_cached
|
||||
.unwrap_or_else(|| existing.as_ref().is_some_and(|value| value.is_cached)),
|
||||
candidate.status_code.map(i32::from).or_else(|| {
|
||||
if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.status_code.map(i32::from))
|
||||
}),
|
||||
candidate
|
||||
.error_type
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.error_type.clone())),
|
||||
candidate.error_message.or_else(|| {
|
||||
} else {
|
||||
candidate.status_code.map(i32::from).or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.status_code.map(i32::from))
|
||||
})
|
||||
},
|
||||
if preserve_existing_lifecycle {
|
||||
existing.as_ref().and_then(|value| value.error_type.clone())
|
||||
} else {
|
||||
candidate
|
||||
.error_type
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.error_type.clone()))
|
||||
},
|
||||
if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.error_message.clone())
|
||||
}),
|
||||
candidate.latency_ms.map(to_i32_u64).transpose()?.or(
|
||||
} else {
|
||||
candidate.error_message.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.error_message.clone())
|
||||
})
|
||||
},
|
||||
if preserve_existing_lifecycle {
|
||||
match existing.as_ref().and_then(|value| value.latency_ms) {
|
||||
Some(value) => Some(to_i32_u64(value)?),
|
||||
None => None,
|
||||
},
|
||||
),
|
||||
}
|
||||
} else {
|
||||
candidate.latency_ms.map(to_i32_u64).transpose()?.or(
|
||||
match existing.as_ref().and_then(|value| value.latency_ms) {
|
||||
Some(value) => Some(to_i32_u64(value)?),
|
||||
None => None,
|
||||
},
|
||||
)
|
||||
},
|
||||
candidate.concurrent_requests.map(to_i32).transpose()?.or(
|
||||
match existing
|
||||
.as_ref()
|
||||
@@ -475,18 +509,42 @@ fn merge_candidate(
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.started_at_unix_ms))
|
||||
.map(|value| u64_to_i64(value, "request candidate started_at"))
|
||||
.transpose()?,
|
||||
candidate
|
||||
.finished_at_unix_ms
|
||||
.or_else(|| {
|
||||
if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.finished_at_unix_ms)
|
||||
} else {
|
||||
candidate.finished_at_unix_ms.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.finished_at_unix_ms)
|
||||
})
|
||||
.map(|value| u64_to_i64(value, "request candidate finished_at"))
|
||||
.transpose()?,
|
||||
}
|
||||
.map(|value| u64_to_i64(value, "request candidate finished_at"))
|
||||
.transpose()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn request_candidate_lifecycle_would_regress(
|
||||
existing: RequestCandidateStatus,
|
||||
incoming: RequestCandidateStatus,
|
||||
) -> bool {
|
||||
matches!(
|
||||
existing,
|
||||
RequestCandidateStatus::Success
|
||||
| RequestCandidateStatus::Failed
|
||||
| RequestCandidateStatus::Cancelled
|
||||
| RequestCandidateStatus::Skipped
|
||||
) && matches!(
|
||||
incoming,
|
||||
RequestCandidateStatus::Available
|
||||
| RequestCandidateStatus::Unused
|
||||
| RequestCandidateStatus::Pending
|
||||
| RequestCandidateStatus::Streaming
|
||||
) || existing == RequestCandidateStatus::Streaming
|
||||
&& incoming == RequestCandidateStatus::Pending
|
||||
}
|
||||
|
||||
fn aggregate_timeline(
|
||||
candidates: Vec<StoredRequestCandidate>,
|
||||
since_unix_secs: u64,
|
||||
@@ -681,6 +739,9 @@ fn optional_u64_to_i64(value: Option<u64>, name: &str) -> Result<Option<i64>, Da
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MysqlRequestCandidateRepository;
|
||||
use crate::repository::candidates::{
|
||||
RequestCandidateStatus, StoredRequestCandidate, UpsertRequestCandidateRecord,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_builds_from_lazy_pool() {
|
||||
@@ -692,4 +753,75 @@ mod tests {
|
||||
|
||||
let _repository = MysqlRequestCandidateRepository::new(pool);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_candidate_keeps_terminal_status_when_streaming_arrives_late() {
|
||||
let existing = StoredRequestCandidate::new(
|
||||
"candidate-1".to_string(),
|
||||
"request-1".to_string(),
|
||||
Some("user-1".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
Some("provider-1".to_string()),
|
||||
Some("endpoint-1".to_string()),
|
||||
Some("provider-key-1".to_string()),
|
||||
RequestCandidateStatus::Success,
|
||||
None,
|
||||
false,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(123),
|
||||
None,
|
||||
Some(serde_json::json!({"terminal": true})),
|
||||
None,
|
||||
1_000,
|
||||
Some(1_001),
|
||||
Some(1_123),
|
||||
)
|
||||
.expect("existing candidate should build");
|
||||
|
||||
let merged = super::merge_candidate(
|
||||
UpsertRequestCandidateRecord {
|
||||
id: "candidate-late".to_string(),
|
||||
request_id: "request-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("key-1".to_string()),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
candidate_index: 0,
|
||||
retry_index: 0,
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
endpoint_id: Some("endpoint-1".to_string()),
|
||||
key_id: Some("provider-key-1".to_string()),
|
||||
status: RequestCandidateStatus::Streaming,
|
||||
skip_reason: None,
|
||||
is_cached: Some(false),
|
||||
status_code: Some(200),
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: Some(9_999),
|
||||
concurrent_requests: None,
|
||||
extra_data: Some(serde_json::json!({"late": true})),
|
||||
required_capabilities: None,
|
||||
created_at_unix_ms: Some(1_050),
|
||||
started_at_unix_ms: Some(1_051),
|
||||
finished_at_unix_ms: None,
|
||||
},
|
||||
Some(existing),
|
||||
)
|
||||
.expect("candidate should merge");
|
||||
|
||||
assert_eq!(merged.id, "candidate-1");
|
||||
assert_eq!(merged.status, RequestCandidateStatus::Success);
|
||||
assert_eq!(merged.latency_ms, Some(123));
|
||||
assert_eq!(merged.finished_at_unix_ms, Some(1_123));
|
||||
assert_eq!(
|
||||
merged.extra_data,
|
||||
Some(serde_json::json!({"terminal": true, "late": true}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,13 +132,48 @@ DO UPDATE SET
|
||||
provider_id = COALESCE(EXCLUDED.provider_id, request_candidates.provider_id),
|
||||
endpoint_id = COALESCE(EXCLUDED.endpoint_id, request_candidates.endpoint_id),
|
||||
key_id = COALESCE(EXCLUDED.key_id, request_candidates.key_id),
|
||||
status = EXCLUDED.status,
|
||||
status = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.status
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.status
|
||||
ELSE EXCLUDED.status
|
||||
END,
|
||||
skip_reason = COALESCE(EXCLUDED.skip_reason, request_candidates.skip_reason),
|
||||
is_cached = COALESCE($14, request_candidates.is_cached),
|
||||
status_code = COALESCE(EXCLUDED.status_code, request_candidates.status_code),
|
||||
error_type = COALESCE(EXCLUDED.error_type, request_candidates.error_type),
|
||||
error_message = COALESCE(EXCLUDED.error_message, request_candidates.error_message),
|
||||
latency_ms = COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms),
|
||||
status_code = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.status_code
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.status_code
|
||||
ELSE COALESCE(EXCLUDED.status_code, request_candidates.status_code)
|
||||
END,
|
||||
error_type = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.error_type
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.error_type
|
||||
ELSE COALESCE(EXCLUDED.error_type, request_candidates.error_type)
|
||||
END,
|
||||
error_message = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.error_message
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.error_message
|
||||
ELSE COALESCE(EXCLUDED.error_message, request_candidates.error_message)
|
||||
END,
|
||||
latency_ms = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.latency_ms
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.latency_ms
|
||||
ELSE COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms)
|
||||
END,
|
||||
concurrent_requests = COALESCE(EXCLUDED.concurrent_requests, request_candidates.concurrent_requests),
|
||||
extra_data = CASE
|
||||
WHEN request_candidates.extra_data IS NULL THEN EXCLUDED.extra_data
|
||||
@@ -155,7 +190,14 @@ DO UPDATE SET
|
||||
ELSE request_candidates.created_at
|
||||
END,
|
||||
started_at = COALESCE(EXCLUDED.started_at, request_candidates.started_at),
|
||||
finished_at = COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)
|
||||
finished_at = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.finished_at
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.finished_at
|
||||
ELSE COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)
|
||||
END
|
||||
RETURNING
|
||||
id,
|
||||
request_id,
|
||||
@@ -193,13 +235,48 @@ DO UPDATE SET
|
||||
provider_id = COALESCE(EXCLUDED.provider_id, request_candidates.provider_id),
|
||||
endpoint_id = COALESCE(EXCLUDED.endpoint_id, request_candidates.endpoint_id),
|
||||
key_id = COALESCE(EXCLUDED.key_id, request_candidates.key_id),
|
||||
status = EXCLUDED.status,
|
||||
status = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.status
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.status
|
||||
ELSE EXCLUDED.status
|
||||
END,
|
||||
skip_reason = COALESCE(EXCLUDED.skip_reason, request_candidates.skip_reason),
|
||||
is_cached = COALESCE(EXCLUDED.is_cached, request_candidates.is_cached),
|
||||
status_code = COALESCE(EXCLUDED.status_code, request_candidates.status_code),
|
||||
error_type = COALESCE(EXCLUDED.error_type, request_candidates.error_type),
|
||||
error_message = COALESCE(EXCLUDED.error_message, request_candidates.error_message),
|
||||
latency_ms = COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms),
|
||||
status_code = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.status_code
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.status_code
|
||||
ELSE COALESCE(EXCLUDED.status_code, request_candidates.status_code)
|
||||
END,
|
||||
error_type = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.error_type
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.error_type
|
||||
ELSE COALESCE(EXCLUDED.error_type, request_candidates.error_type)
|
||||
END,
|
||||
error_message = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.error_message
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.error_message
|
||||
ELSE COALESCE(EXCLUDED.error_message, request_candidates.error_message)
|
||||
END,
|
||||
latency_ms = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.latency_ms
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.latency_ms
|
||||
ELSE COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms)
|
||||
END,
|
||||
concurrent_requests = COALESCE(EXCLUDED.concurrent_requests, request_candidates.concurrent_requests),
|
||||
extra_data = CASE
|
||||
WHEN request_candidates.extra_data IS NULL THEN EXCLUDED.extra_data
|
||||
@@ -216,7 +293,14 @@ DO UPDATE SET
|
||||
ELSE request_candidates.created_at
|
||||
END,
|
||||
started_at = COALESCE(EXCLUDED.started_at, request_candidates.started_at),
|
||||
finished_at = COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)
|
||||
finished_at = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.finished_at
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.finished_at
|
||||
ELSE COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)
|
||||
END
|
||||
"#;
|
||||
|
||||
const UPSERT_CONFLICT_INHERIT_IS_CACHED_SQL: &str = r#"
|
||||
@@ -229,13 +313,48 @@ DO UPDATE SET
|
||||
provider_id = COALESCE(EXCLUDED.provider_id, request_candidates.provider_id),
|
||||
endpoint_id = COALESCE(EXCLUDED.endpoint_id, request_candidates.endpoint_id),
|
||||
key_id = COALESCE(EXCLUDED.key_id, request_candidates.key_id),
|
||||
status = EXCLUDED.status,
|
||||
status = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.status
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.status
|
||||
ELSE EXCLUDED.status
|
||||
END,
|
||||
skip_reason = COALESCE(EXCLUDED.skip_reason, request_candidates.skip_reason),
|
||||
is_cached = request_candidates.is_cached,
|
||||
status_code = COALESCE(EXCLUDED.status_code, request_candidates.status_code),
|
||||
error_type = COALESCE(EXCLUDED.error_type, request_candidates.error_type),
|
||||
error_message = COALESCE(EXCLUDED.error_message, request_candidates.error_message),
|
||||
latency_ms = COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms),
|
||||
status_code = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.status_code
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.status_code
|
||||
ELSE COALESCE(EXCLUDED.status_code, request_candidates.status_code)
|
||||
END,
|
||||
error_type = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.error_type
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.error_type
|
||||
ELSE COALESCE(EXCLUDED.error_type, request_candidates.error_type)
|
||||
END,
|
||||
error_message = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.error_message
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.error_message
|
||||
ELSE COALESCE(EXCLUDED.error_message, request_candidates.error_message)
|
||||
END,
|
||||
latency_ms = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.latency_ms
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.latency_ms
|
||||
ELSE COALESCE(EXCLUDED.latency_ms, request_candidates.latency_ms)
|
||||
END,
|
||||
concurrent_requests = COALESCE(EXCLUDED.concurrent_requests, request_candidates.concurrent_requests),
|
||||
extra_data = CASE
|
||||
WHEN request_candidates.extra_data IS NULL THEN EXCLUDED.extra_data
|
||||
@@ -252,7 +371,14 @@ DO UPDATE SET
|
||||
ELSE request_candidates.created_at
|
||||
END,
|
||||
started_at = COALESCE(EXCLUDED.started_at, request_candidates.started_at),
|
||||
finished_at = COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)
|
||||
finished_at = CASE
|
||||
WHEN request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')
|
||||
AND EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')
|
||||
THEN request_candidates.finished_at
|
||||
WHEN request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'
|
||||
THEN request_candidates.finished_at
|
||||
ELSE COALESCE(EXCLUDED.finished_at, request_candidates.finished_at)
|
||||
END
|
||||
"#;
|
||||
|
||||
const UPSERT_MANY_PREFIX_SQL: &str = r#"
|
||||
@@ -1015,7 +1141,10 @@ fn to_i32_u64(value: u64) -> Result<i32, DataLayerError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SqlxRequestCandidateReadRepository, UPSERT_SQL};
|
||||
use super::{
|
||||
SqlxRequestCandidateReadRepository, UPSERT_CONFLICT_INHERIT_IS_CACHED_SQL,
|
||||
UPSERT_CONFLICT_SQL, UPSERT_SQL,
|
||||
};
|
||||
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
|
||||
#[test]
|
||||
@@ -1030,6 +1159,27 @@ mod tests {
|
||||
assert!(UPSERT_SQL.contains("THEN EXCLUDED.created_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_sql_keeps_terminal_candidate_state_when_lifecycle_events_arrive_late() {
|
||||
for sql in [
|
||||
UPSERT_SQL,
|
||||
UPSERT_CONFLICT_SQL,
|
||||
UPSERT_CONFLICT_INHERIT_IS_CACHED_SQL,
|
||||
] {
|
||||
assert!(sql.contains(
|
||||
"request_candidates.status IN ('success', 'failed', 'cancelled', 'skipped')"
|
||||
));
|
||||
assert!(
|
||||
sql.contains("EXCLUDED.status IN ('available', 'unused', 'pending', 'streaming')")
|
||||
);
|
||||
assert!(sql.contains(
|
||||
"request_candidates.status = 'streaming' AND EXCLUDED.status = 'pending'"
|
||||
));
|
||||
assert!(sql.contains("THEN request_candidates.status"));
|
||||
assert!(sql.contains("THEN request_candidates.latency_ms"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_constructs_from_lazy_pool() {
|
||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||
|
||||
@@ -361,6 +361,17 @@ fn merge_candidate(
|
||||
candidate: UpsertRequestCandidateRecord,
|
||||
existing: Option<StoredRequestCandidate>,
|
||||
) -> Result<StoredRequestCandidate, DataLayerError> {
|
||||
let preserve_existing_lifecycle = existing.as_ref().is_some_and(|value| {
|
||||
request_candidate_lifecycle_would_regress(value.status, candidate.status)
|
||||
});
|
||||
let merged_status = if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.map(|value| value.status)
|
||||
.unwrap_or(candidate.status)
|
||||
} else {
|
||||
candidate.status
|
||||
};
|
||||
let created_at_unix_ms = candidate
|
||||
.created_at_unix_ms
|
||||
.filter(|value| *value > 1000)
|
||||
@@ -413,7 +424,7 @@ fn merge_candidate(
|
||||
candidate
|
||||
.key_id
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.key_id.clone())),
|
||||
candidate.status,
|
||||
merged_status,
|
||||
candidate.skip_reason.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
@@ -422,25 +433,48 @@ fn merge_candidate(
|
||||
candidate
|
||||
.is_cached
|
||||
.unwrap_or_else(|| existing.as_ref().is_some_and(|value| value.is_cached)),
|
||||
candidate.status_code.map(i32::from).or_else(|| {
|
||||
if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.status_code.map(i32::from))
|
||||
}),
|
||||
candidate
|
||||
.error_type
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.error_type.clone())),
|
||||
candidate.error_message.or_else(|| {
|
||||
} else {
|
||||
candidate.status_code.map(i32::from).or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.status_code.map(i32::from))
|
||||
})
|
||||
},
|
||||
if preserve_existing_lifecycle {
|
||||
existing.as_ref().and_then(|value| value.error_type.clone())
|
||||
} else {
|
||||
candidate
|
||||
.error_type
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.error_type.clone()))
|
||||
},
|
||||
if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.error_message.clone())
|
||||
}),
|
||||
candidate.latency_ms.map(to_i32_u64).transpose()?.or(
|
||||
} else {
|
||||
candidate.error_message.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.error_message.clone())
|
||||
})
|
||||
},
|
||||
if preserve_existing_lifecycle {
|
||||
match existing.as_ref().and_then(|value| value.latency_ms) {
|
||||
Some(value) => Some(to_i32_u64(value)?),
|
||||
None => None,
|
||||
},
|
||||
),
|
||||
}
|
||||
} else {
|
||||
candidate.latency_ms.map(to_i32_u64).transpose()?.or(
|
||||
match existing.as_ref().and_then(|value| value.latency_ms) {
|
||||
Some(value) => Some(to_i32_u64(value)?),
|
||||
None => None,
|
||||
},
|
||||
)
|
||||
},
|
||||
candidate.concurrent_requests.map(to_i32).transpose()?.or(
|
||||
match existing
|
||||
.as_ref()
|
||||
@@ -462,18 +496,42 @@ fn merge_candidate(
|
||||
.or_else(|| existing.as_ref().and_then(|value| value.started_at_unix_ms))
|
||||
.map(|value| u64_to_i64(value, "request candidate started_at"))
|
||||
.transpose()?,
|
||||
candidate
|
||||
.finished_at_unix_ms
|
||||
.or_else(|| {
|
||||
if preserve_existing_lifecycle {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.finished_at_unix_ms)
|
||||
} else {
|
||||
candidate.finished_at_unix_ms.or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.and_then(|value| value.finished_at_unix_ms)
|
||||
})
|
||||
.map(|value| u64_to_i64(value, "request candidate finished_at"))
|
||||
.transpose()?,
|
||||
}
|
||||
.map(|value| u64_to_i64(value, "request candidate finished_at"))
|
||||
.transpose()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn request_candidate_lifecycle_would_regress(
|
||||
existing: RequestCandidateStatus,
|
||||
incoming: RequestCandidateStatus,
|
||||
) -> bool {
|
||||
matches!(
|
||||
existing,
|
||||
RequestCandidateStatus::Success
|
||||
| RequestCandidateStatus::Failed
|
||||
| RequestCandidateStatus::Cancelled
|
||||
| RequestCandidateStatus::Skipped
|
||||
) && matches!(
|
||||
incoming,
|
||||
RequestCandidateStatus::Available
|
||||
| RequestCandidateStatus::Unused
|
||||
| RequestCandidateStatus::Pending
|
||||
| RequestCandidateStatus::Streaming
|
||||
) || existing == RequestCandidateStatus::Streaming
|
||||
&& incoming == RequestCandidateStatus::Pending
|
||||
}
|
||||
|
||||
fn aggregate_timeline(
|
||||
candidates: Vec<StoredRequestCandidate>,
|
||||
since_unix_secs: u64,
|
||||
@@ -710,6 +768,23 @@ mod tests {
|
||||
assert_eq!(updated.id, "candidate-1");
|
||||
assert_eq!(updated.extra_data, Some(json!({"a": 1, "b": 2})));
|
||||
|
||||
let late_streaming = repository
|
||||
.upsert(sample_upsert(
|
||||
"candidate-late-streaming",
|
||||
RequestCandidateStatus::Streaming,
|
||||
Some(json!({"late": true})),
|
||||
1_000_250,
|
||||
))
|
||||
.await
|
||||
.expect("late streaming candidate should not regress terminal status");
|
||||
assert_eq!(late_streaming.id, "candidate-1");
|
||||
assert_eq!(late_streaming.status, RequestCandidateStatus::Success);
|
||||
assert_eq!(late_streaming.finished_at_unix_ms, Some(1_000_502));
|
||||
assert_eq!(
|
||||
late_streaming.extra_data,
|
||||
Some(json!({"a": 1, "b": 2, "late": true}))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
repository
|
||||
.list_by_request_id("request-1")
|
||||
|
||||
@@ -151,33 +151,85 @@ ON DUPLICATE KEY UPDATE
|
||||
has_format_conversion = VALUES(has_format_conversion),
|
||||
is_stream = VALUES(is_stream),
|
||||
upstream_is_stream = VALUES(upstream_is_stream),
|
||||
input_tokens = VALUES(input_tokens),
|
||||
output_tokens = VALUES(output_tokens),
|
||||
total_tokens = VALUES(total_tokens),
|
||||
cache_creation_input_tokens = VALUES(cache_creation_input_tokens),
|
||||
cache_creation_ephemeral_5m_input_tokens = VALUES(cache_creation_ephemeral_5m_input_tokens),
|
||||
cache_creation_ephemeral_1h_input_tokens = VALUES(cache_creation_ephemeral_1h_input_tokens),
|
||||
cache_read_input_tokens = VALUES(cache_read_input_tokens),
|
||||
cache_creation_cost_usd = VALUES(cache_creation_cost_usd),
|
||||
cache_read_cost_usd = VALUES(cache_read_cost_usd),
|
||||
output_price_per_1m = VALUES(output_price_per_1m),
|
||||
total_cost_usd = VALUES(total_cost_usd),
|
||||
actual_total_cost_usd = VALUES(actual_total_cost_usd),
|
||||
status_code = VALUES(status_code),
|
||||
error_message = VALUES(error_message),
|
||||
error_category = VALUES(error_category),
|
||||
input_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN input_tokens
|
||||
ELSE VALUES(input_tokens)
|
||||
END,
|
||||
output_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN output_tokens
|
||||
ELSE VALUES(output_tokens)
|
||||
END,
|
||||
total_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN total_tokens
|
||||
ELSE VALUES(total_tokens)
|
||||
END,
|
||||
cache_creation_input_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_input_tokens
|
||||
ELSE VALUES(cache_creation_input_tokens)
|
||||
END,
|
||||
cache_creation_ephemeral_5m_input_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_ephemeral_5m_input_tokens
|
||||
ELSE VALUES(cache_creation_ephemeral_5m_input_tokens)
|
||||
END,
|
||||
cache_creation_ephemeral_1h_input_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_ephemeral_1h_input_tokens
|
||||
ELSE VALUES(cache_creation_ephemeral_1h_input_tokens)
|
||||
END,
|
||||
cache_read_input_tokens = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_read_input_tokens
|
||||
ELSE VALUES(cache_read_input_tokens)
|
||||
END,
|
||||
cache_creation_cost_usd = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_creation_cost_usd
|
||||
ELSE VALUES(cache_creation_cost_usd)
|
||||
END,
|
||||
cache_read_cost_usd = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN cache_read_cost_usd
|
||||
ELSE VALUES(cache_read_cost_usd)
|
||||
END,
|
||||
output_price_per_1m = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN output_price_per_1m
|
||||
ELSE VALUES(output_price_per_1m)
|
||||
END,
|
||||
total_cost_usd = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN total_cost_usd
|
||||
ELSE VALUES(total_cost_usd)
|
||||
END,
|
||||
actual_total_cost_usd = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN actual_total_cost_usd
|
||||
ELSE VALUES(actual_total_cost_usd)
|
||||
END,
|
||||
status_code = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN status_code
|
||||
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN status_code
|
||||
ELSE VALUES(status_code)
|
||||
END,
|
||||
error_message = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN error_message
|
||||
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN error_message
|
||||
ELSE VALUES(error_message)
|
||||
END,
|
||||
error_category = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN error_category
|
||||
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN error_category
|
||||
ELSE VALUES(error_category)
|
||||
END,
|
||||
response_time_ms = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN response_time_ms
|
||||
WHEN VALUES(response_time_ms) IS NULL OR VALUES(response_time_ms) = 0
|
||||
THEN COALESCE(response_time_ms, VALUES(response_time_ms))
|
||||
ELSE VALUES(response_time_ms)
|
||||
END,
|
||||
first_byte_time_ms = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN first_byte_time_ms
|
||||
WHEN VALUES(first_byte_time_ms) IS NULL OR VALUES(first_byte_time_ms) = 0
|
||||
THEN COALESCE(first_byte_time_ms, VALUES(first_byte_time_ms))
|
||||
ELSE VALUES(first_byte_time_ms)
|
||||
END,
|
||||
status = VALUES(status),
|
||||
billing_status = VALUES(billing_status),
|
||||
billing_status = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN billing_status
|
||||
ELSE VALUES(billing_status)
|
||||
END,
|
||||
request_metadata = VALUES(request_metadata),
|
||||
candidate_id = VALUES(candidate_id),
|
||||
candidate_index = VALUES(candidate_index),
|
||||
@@ -187,8 +239,19 @@ ON DUPLICATE KEY UPDATE
|
||||
route_kind = VALUES(route_kind),
|
||||
execution_path = VALUES(execution_path),
|
||||
local_execution_runtime_miss_reason = VALUES(local_execution_runtime_miss_reason),
|
||||
finalized_at = VALUES(finalized_at),
|
||||
updated_at_unix_secs = VALUES(updated_at_unix_secs)
|
||||
finalized_at = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN finalized_at
|
||||
ELSE VALUES(finalized_at)
|
||||
END,
|
||||
updated_at_unix_secs = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN updated_at_unix_secs
|
||||
ELSE VALUES(updated_at_unix_secs)
|
||||
END,
|
||||
status = CASE
|
||||
WHEN status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming') THEN status
|
||||
WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN status
|
||||
ELSE VALUES(status)
|
||||
END
|
||||
"#;
|
||||
|
||||
const SELECT_STALE_PENDING_USAGE_BATCH_SQL: &str = r#"
|
||||
@@ -1550,6 +1613,20 @@ mod tests {
|
||||
assert!(source.contains("CAST(COALESCE(SUM(total_requests), 0) AS SIGNED) AS requests"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_usage_upsert_keeps_terminal_state_when_streaming_arrives_late() {
|
||||
assert!(super::UPSERT_USAGE_SQL.contains(
|
||||
"status IN ('completed', 'failed', 'cancelled') AND VALUES(status) IN ('pending', 'streaming')"
|
||||
));
|
||||
assert!(super::UPSERT_USAGE_SQL.contains("input_tokens = CASE"));
|
||||
assert!(super::UPSERT_USAGE_SQL.contains("status_code = CASE"));
|
||||
assert!(super::UPSERT_USAGE_SQL.contains("billing_status = CASE"));
|
||||
assert!(super::UPSERT_USAGE_SQL.contains("finalized_at = CASE"));
|
||||
assert!(super::UPSERT_USAGE_SQL.contains("updated_at_unix_secs = CASE"));
|
||||
assert!(super::UPSERT_USAGE_SQL
|
||||
.contains("WHEN status = 'streaming' AND VALUES(status) = 'pending' THEN status"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mysql_usage_write_repository_upserts_when_url_is_set() {
|
||||
let Some(database_url) = std::env::var("AETHER_TEST_MYSQL_URL")
|
||||
|
||||
@@ -8029,8 +8029,26 @@ ORDER BY "usage".user_id ASC
|
||||
) -> Result<StoredRequestUsageAudit, DataLayerError> {
|
||||
usage.validate()?;
|
||||
let usage = strip_deprecated_usage_display_fields(usage);
|
||||
let prepared = prepare_usage_upsert_context(&usage)?;
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
let PreparedUsageUpsert {
|
||||
request_headers_json,
|
||||
provider_request_headers_json,
|
||||
response_headers_json,
|
||||
client_response_headers_json,
|
||||
request_body_storage,
|
||||
provider_request_body_storage,
|
||||
response_body_storage,
|
||||
client_response_body_storage,
|
||||
http_audit_refs,
|
||||
http_audit_states,
|
||||
http_audit_capture_mode,
|
||||
routing_snapshot,
|
||||
settlement_pricing_snapshot,
|
||||
request_metadata_value,
|
||||
request_metadata_json,
|
||||
} = prepared;
|
||||
Box::pin(async move {
|
||||
lock_usage_request_id_in_tx(tx, &usage.request_id).await?;
|
||||
|
||||
@@ -8052,102 +8070,6 @@ ORDER BY "usage".user_id ASC
|
||||
|
||||
let previous_usage =
|
||||
find_usage_by_request_id_in_tx(tx, &usage.request_id).await?;
|
||||
|
||||
let request_headers_json = json_bind_text(usage.request_headers.as_ref())?;
|
||||
let request_body_storage =
|
||||
prepare_usage_body_storage(usage.request_body.as_ref())?;
|
||||
let provider_request_headers_json =
|
||||
json_bind_text(usage.provider_request_headers.as_ref())?;
|
||||
let provider_request_body_storage =
|
||||
prepare_usage_body_storage(usage.provider_request_body.as_ref())?;
|
||||
let response_headers_json = json_bind_text(usage.response_headers.as_ref())?;
|
||||
let response_body_storage =
|
||||
prepare_usage_body_storage(usage.response_body.as_ref())?;
|
||||
let client_response_headers_json =
|
||||
json_bind_text(usage.client_response_headers.as_ref())?;
|
||||
let client_response_body_storage =
|
||||
prepare_usage_body_storage(usage.client_response_body.as_ref())?;
|
||||
let http_audit_refs = UsageHttpAuditRefs {
|
||||
request_body_ref: resolved_write_usage_body_ref(
|
||||
usage.request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
request_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
provider_request_body_ref: resolved_write_usage_body_ref(
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
provider_request_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
response_body_ref: resolved_write_usage_body_ref(
|
||||
usage.response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
response_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
client_response_body_ref: resolved_write_usage_body_ref(
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
client_response_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let http_audit_states = UsageHttpAuditStates {
|
||||
request_body_state: usage.request_body_state,
|
||||
provider_request_body_state: usage.provider_request_body_state,
|
||||
response_body_state: usage.response_body_state,
|
||||
client_response_body_state: usage.client_response_body_state,
|
||||
};
|
||||
let request_metadata_value = prepare_request_metadata_for_body_storage(
|
||||
usage.request_metadata.clone(),
|
||||
[
|
||||
(
|
||||
UsageBodyField::RequestBody,
|
||||
&request_body_storage,
|
||||
usage.request_body.as_ref(),
|
||||
usage.request_body_ref.as_deref(),
|
||||
),
|
||||
(
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
&provider_request_body_storage,
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
),
|
||||
(
|
||||
UsageBodyField::ResponseBody,
|
||||
&response_body_storage,
|
||||
usage.response_body.as_ref(),
|
||||
usage.response_body_ref.as_deref(),
|
||||
),
|
||||
(
|
||||
UsageBodyField::ClientResponseBody,
|
||||
&client_response_body_storage,
|
||||
usage.client_response_body.as_ref(),
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
),
|
||||
],
|
||||
);
|
||||
let http_audit_capture_mode = usage_http_audit_capture_mode(
|
||||
&http_audit_refs,
|
||||
[
|
||||
usage.request_body.as_ref(),
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.response_body.as_ref(),
|
||||
usage.client_response_body.as_ref(),
|
||||
],
|
||||
);
|
||||
let routing_snapshot =
|
||||
usage_routing_snapshot_from_usage(&usage, request_metadata_value.as_ref());
|
||||
let settlement_pricing_snapshot = usage_settlement_pricing_snapshot_from_usage(
|
||||
&usage,
|
||||
request_metadata_value.as_ref(),
|
||||
)?;
|
||||
let request_metadata_json = json_bind_text(request_metadata_value.as_ref())?;
|
||||
let _row = sqlx::query(UPSERT_SQL)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&usage.request_id)
|
||||
@@ -10486,6 +10408,25 @@ impl UsageSettlementPricingSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedUsageUpsert {
|
||||
request_headers_json: Option<String>,
|
||||
provider_request_headers_json: Option<String>,
|
||||
response_headers_json: Option<String>,
|
||||
client_response_headers_json: Option<String>,
|
||||
request_body_storage: UsageBodyStorage,
|
||||
provider_request_body_storage: UsageBodyStorage,
|
||||
response_body_storage: UsageBodyStorage,
|
||||
client_response_body_storage: UsageBodyStorage,
|
||||
http_audit_refs: UsageHttpAuditRefs,
|
||||
http_audit_states: UsageHttpAuditStates,
|
||||
http_audit_capture_mode: &'static str,
|
||||
routing_snapshot: UsageRoutingSnapshot,
|
||||
settlement_pricing_snapshot: UsageSettlementPricingSnapshot,
|
||||
request_metadata_value: Option<Value>,
|
||||
request_metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
fn prepare_usage_body_storage(value: Option<&Value>) -> Result<UsageBodyStorage, DataLayerError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(UsageBodyStorage {
|
||||
@@ -10530,6 +10471,118 @@ fn json_bind_text(value: Option<&Value>) -> Result<Option<String>, DataLayerErro
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn prepare_usage_upsert_context(
|
||||
usage: &UpsertUsageRecord,
|
||||
) -> Result<PreparedUsageUpsert, DataLayerError> {
|
||||
let request_headers_json = json_bind_text(usage.request_headers.as_ref())?;
|
||||
let request_body_storage = prepare_usage_body_storage(usage.request_body.as_ref())?;
|
||||
let provider_request_headers_json = json_bind_text(usage.provider_request_headers.as_ref())?;
|
||||
let provider_request_body_storage =
|
||||
prepare_usage_body_storage(usage.provider_request_body.as_ref())?;
|
||||
let response_headers_json = json_bind_text(usage.response_headers.as_ref())?;
|
||||
let response_body_storage = prepare_usage_body_storage(usage.response_body.as_ref())?;
|
||||
let client_response_headers_json = json_bind_text(usage.client_response_headers.as_ref())?;
|
||||
let client_response_body_storage =
|
||||
prepare_usage_body_storage(usage.client_response_body.as_ref())?;
|
||||
let http_audit_refs = UsageHttpAuditRefs {
|
||||
request_body_ref: resolved_write_usage_body_ref(
|
||||
usage.request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::RequestBody,
|
||||
request_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
provider_request_body_ref: resolved_write_usage_body_ref(
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
provider_request_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
response_body_ref: resolved_write_usage_body_ref(
|
||||
usage.response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ResponseBody,
|
||||
response_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
client_response_body_ref: resolved_write_usage_body_ref(
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
&usage.request_id,
|
||||
UsageBodyField::ClientResponseBody,
|
||||
client_response_body_storage.has_detached_blob(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let http_audit_states = UsageHttpAuditStates {
|
||||
request_body_state: usage.request_body_state,
|
||||
provider_request_body_state: usage.provider_request_body_state,
|
||||
response_body_state: usage.response_body_state,
|
||||
client_response_body_state: usage.client_response_body_state,
|
||||
};
|
||||
let request_metadata_value = prepare_request_metadata_for_body_storage(
|
||||
usage.request_metadata.clone(),
|
||||
[
|
||||
(
|
||||
UsageBodyField::RequestBody,
|
||||
&request_body_storage,
|
||||
usage.request_body.as_ref(),
|
||||
usage.request_body_ref.as_deref(),
|
||||
),
|
||||
(
|
||||
UsageBodyField::ProviderRequestBody,
|
||||
&provider_request_body_storage,
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.provider_request_body_ref.as_deref(),
|
||||
),
|
||||
(
|
||||
UsageBodyField::ResponseBody,
|
||||
&response_body_storage,
|
||||
usage.response_body.as_ref(),
|
||||
usage.response_body_ref.as_deref(),
|
||||
),
|
||||
(
|
||||
UsageBodyField::ClientResponseBody,
|
||||
&client_response_body_storage,
|
||||
usage.client_response_body.as_ref(),
|
||||
usage.client_response_body_ref.as_deref(),
|
||||
),
|
||||
],
|
||||
);
|
||||
let http_audit_capture_mode = usage_http_audit_capture_mode(
|
||||
&http_audit_refs,
|
||||
[
|
||||
usage.request_body.as_ref(),
|
||||
usage.provider_request_body.as_ref(),
|
||||
usage.response_body.as_ref(),
|
||||
usage.client_response_body.as_ref(),
|
||||
],
|
||||
);
|
||||
let routing_snapshot =
|
||||
usage_routing_snapshot_from_usage(usage, request_metadata_value.as_ref());
|
||||
let settlement_pricing_snapshot =
|
||||
usage_settlement_pricing_snapshot_from_usage(usage, request_metadata_value.as_ref())?;
|
||||
let request_metadata_json = json_bind_text(request_metadata_value.as_ref())?;
|
||||
|
||||
Ok(PreparedUsageUpsert {
|
||||
request_headers_json,
|
||||
provider_request_headers_json,
|
||||
response_headers_json,
|
||||
client_response_headers_json,
|
||||
request_body_storage,
|
||||
provider_request_body_storage,
|
||||
response_body_storage,
|
||||
client_response_body_storage,
|
||||
http_audit_refs,
|
||||
http_audit_states,
|
||||
http_audit_capture_mode,
|
||||
routing_snapshot,
|
||||
settlement_pricing_snapshot,
|
||||
request_metadata_value,
|
||||
request_metadata_json,
|
||||
})
|
||||
}
|
||||
|
||||
fn usage_body_capture_state_bind_text(
|
||||
value: Option<UsageBodyCaptureState>,
|
||||
) -> Option<&'static str> {
|
||||
|
||||
@@ -172,18 +172,54 @@ ON CONFLICT (request_id) DO UPDATE SET
|
||||
has_format_conversion = excluded.has_format_conversion,
|
||||
is_stream = excluded.is_stream,
|
||||
upstream_is_stream = excluded.upstream_is_stream,
|
||||
input_tokens = excluded.input_tokens,
|
||||
output_tokens = excluded.output_tokens,
|
||||
total_tokens = excluded.total_tokens,
|
||||
cache_creation_input_tokens = excluded.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens = excluded.cache_creation_ephemeral_5m_input_tokens,
|
||||
cache_creation_ephemeral_1h_input_tokens = excluded.cache_creation_ephemeral_1h_input_tokens,
|
||||
cache_read_input_tokens = excluded.cache_read_input_tokens,
|
||||
cache_creation_cost_usd = excluded.cache_creation_cost_usd,
|
||||
cache_read_cost_usd = excluded.cache_read_cost_usd,
|
||||
output_price_per_1m = excluded.output_price_per_1m,
|
||||
total_cost_usd = excluded.total_cost_usd,
|
||||
actual_total_cost_usd = excluded.actual_total_cost_usd,
|
||||
input_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".input_tokens
|
||||
ELSE excluded.input_tokens
|
||||
END,
|
||||
output_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".output_tokens
|
||||
ELSE excluded.output_tokens
|
||||
END,
|
||||
total_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".total_tokens
|
||||
ELSE excluded.total_tokens
|
||||
END,
|
||||
cache_creation_input_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".cache_creation_input_tokens
|
||||
ELSE excluded.cache_creation_input_tokens
|
||||
END,
|
||||
cache_creation_ephemeral_5m_input_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".cache_creation_ephemeral_5m_input_tokens
|
||||
ELSE excluded.cache_creation_ephemeral_5m_input_tokens
|
||||
END,
|
||||
cache_creation_ephemeral_1h_input_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".cache_creation_ephemeral_1h_input_tokens
|
||||
ELSE excluded.cache_creation_ephemeral_1h_input_tokens
|
||||
END,
|
||||
cache_read_input_tokens = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".cache_read_input_tokens
|
||||
ELSE excluded.cache_read_input_tokens
|
||||
END,
|
||||
cache_creation_cost_usd = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".cache_creation_cost_usd
|
||||
ELSE excluded.cache_creation_cost_usd
|
||||
END,
|
||||
cache_read_cost_usd = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".cache_read_cost_usd
|
||||
ELSE excluded.cache_read_cost_usd
|
||||
END,
|
||||
output_price_per_1m = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".output_price_per_1m
|
||||
ELSE excluded.output_price_per_1m
|
||||
END,
|
||||
total_cost_usd = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".total_cost_usd
|
||||
ELSE excluded.total_cost_usd
|
||||
END,
|
||||
actual_total_cost_usd = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".actual_total_cost_usd
|
||||
ELSE excluded.actual_total_cost_usd
|
||||
END,
|
||||
status_code = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".status_code
|
||||
WHEN "usage".status = 'streaming' AND excluded.status = 'pending' THEN "usage".status_code
|
||||
@@ -214,7 +250,10 @@ ON CONFLICT (request_id) DO UPDATE SET
|
||||
WHEN "usage".status = 'streaming' AND excluded.status = 'pending' THEN "usage".status
|
||||
ELSE excluded.status
|
||||
END,
|
||||
billing_status = excluded.billing_status,
|
||||
billing_status = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".billing_status
|
||||
ELSE excluded.billing_status
|
||||
END,
|
||||
request_metadata = excluded.request_metadata,
|
||||
candidate_id = COALESCE(excluded.candidate_id, "usage".candidate_id),
|
||||
candidate_index = COALESCE(excluded.candidate_index, "usage".candidate_index),
|
||||
@@ -224,8 +263,14 @@ ON CONFLICT (request_id) DO UPDATE SET
|
||||
route_kind = excluded.route_kind,
|
||||
execution_path = excluded.execution_path,
|
||||
local_execution_runtime_miss_reason = excluded.local_execution_runtime_miss_reason,
|
||||
finalized_at = excluded.finalized_at,
|
||||
updated_at_unix_secs = excluded.updated_at_unix_secs
|
||||
finalized_at = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".finalized_at
|
||||
ELSE excluded.finalized_at
|
||||
END,
|
||||
updated_at_unix_secs = CASE
|
||||
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".updated_at_unix_secs
|
||||
ELSE excluded.updated_at_unix_secs
|
||||
END
|
||||
"#;
|
||||
|
||||
const SELECT_STALE_PENDING_USAGE_BATCH_SQL: &str = r#"
|
||||
@@ -4520,6 +4565,53 @@ mod tests {
|
||||
assert_eq!(existing.updated_at_unix_secs, 1_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_write_repository_does_not_regress_terminal_usage_from_late_streaming() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("sqlite pool should connect");
|
||||
run_sqlite_migrations(&pool)
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
seed_stats_targets(&pool).await;
|
||||
|
||||
let repository = SqliteUsageWriteRepository::new(pool);
|
||||
repository
|
||||
.upsert(sample_usage("request-1", "completed", "pending", 1_000))
|
||||
.await
|
||||
.expect("terminal usage should upsert");
|
||||
|
||||
let mut late_streaming = sample_usage("request-1", "streaming", "pending", 1_001);
|
||||
late_streaming.input_tokens = Some(0);
|
||||
late_streaming.output_tokens = Some(0);
|
||||
late_streaming.total_tokens = Some(0);
|
||||
late_streaming.cache_read_input_tokens = Some(0);
|
||||
late_streaming.cache_read_cost_usd = Some(0.0);
|
||||
late_streaming.total_cost_usd = Some(0.0);
|
||||
late_streaming.actual_total_cost_usd = Some(0.0);
|
||||
late_streaming.response_time_ms = Some(9_999);
|
||||
late_streaming.first_byte_time_ms = Some(9_999);
|
||||
late_streaming.finalized_at_unix_secs = None;
|
||||
|
||||
let current = repository
|
||||
.upsert(late_streaming)
|
||||
.await
|
||||
.expect("late streaming usage should not regress terminal usage");
|
||||
|
||||
assert_eq!(current.status, "completed");
|
||||
assert_eq!(current.billing_status, "pending");
|
||||
assert_eq!(current.total_tokens, 7);
|
||||
assert_eq!(current.cache_read_input_tokens, 2);
|
||||
assert_eq!(current.total_cost_usd, 0.5);
|
||||
assert_eq!(current.actual_total_cost_usd, 0.4);
|
||||
assert_eq!(current.response_time_ms, Some(42));
|
||||
assert_eq!(current.first_byte_time_ms, Some(12));
|
||||
assert_eq!(current.finalized_at_unix_secs, Some(1_000));
|
||||
assert_eq!(current.updated_at_unix_secs, 1_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_write_repository_cleans_stale_pending_requests() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
|
||||
Reference in New Issue
Block a user