use std::time::Duration;
use std::{future::Future, result::Result as StdResult};
use mcp_usage_core::TaskAttribution;
use mcp_usage_tower::{TaskAttributionStore, TaskStoreError, TaskStoreFuture};
use sqlx::PgPool;
use crate::{StoreConfigError, decode_attribution, encode_attribution, identifier_hash};
const INSTALL_SQL: &str = concat!(
"SELECT pg_advisory_xact_lock(7594834021775991);\n",
include_str!("../schema/postgres.sql"),
"\nSELECT attribution FROM mcp_usage_task_attribution LIMIT 0;"
);
const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub struct PostgresTaskStore {
pool: PgPool,
ttl_seconds: i64,
operation_timeout: Duration,
}
impl std::fmt::Debug for PostgresTaskStore {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PostgresTaskStore")
.field("ttl_seconds", &self.ttl_seconds)
.field("operation_timeout", &self.operation_timeout)
.finish_non_exhaustive()
}
}
impl PostgresTaskStore {
pub fn new(pool: PgPool, ttl: Duration) -> Result<Self, StoreConfigError> {
Self::with_timeout(pool, ttl, DEFAULT_OPERATION_TIMEOUT)
}
pub fn with_timeout(
pool: PgPool,
ttl: Duration,
operation_timeout: Duration,
) -> Result<Self, StoreConfigError> {
let ttl_seconds = i64::try_from(ttl.as_secs()).map_err(|_| StoreConfigError::InvalidTtl)?;
if ttl_seconds == 0 {
return Err(StoreConfigError::InvalidTtl);
}
if operation_timeout.is_zero() {
return Err(StoreConfigError::InvalidTimeout);
}
Ok(Self {
pool,
ttl_seconds,
operation_timeout,
})
}
pub async fn install(&self) -> Result<(), StoreConfigError> {
tokio::time::timeout(
self.operation_timeout,
sqlx::raw_sql(INSTALL_SQL).execute(&self.pool),
)
.await
.map_err(|_| StoreConfigError::Schema)?
.map_err(|_| StoreConfigError::Schema)?;
Ok(())
}
pub async fn prune_expired(&self) -> Result<u64, TaskStoreError> {
self.run(
sqlx::query("DELETE FROM mcp_usage_task_attribution WHERE expires_at <= NOW()")
.execute(&self.pool),
)
.await
.map(|result| result.rows_affected())
}
async fn run<T, F>(&self, operation: F) -> Result<T, TaskStoreError>
where
F: Future<Output = StdResult<T, sqlx::Error>>,
{
tokio::time::timeout(self.operation_timeout, operation)
.await
.map_err(|_| TaskStoreError::BackendUnavailable)?
.map_err(|_| TaskStoreError::BackendUnavailable)
}
}
impl TaskAttributionStore for PostgresTaskStore {
fn insert<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
attribution: TaskAttribution,
) -> TaskStoreFuture<'a, ()> {
Box::pin(async move {
self.run(
sqlx::query(
"INSERT INTO mcp_usage_task_attribution \
(tenant_hash, task_hash, attribution, expires_at) \
VALUES ($1, $2, $3, NOW() + ($4 * INTERVAL '1 second')) \
ON CONFLICT (tenant_hash, task_hash) DO UPDATE \
SET attribution = EXCLUDED.attribution, \
expires_at = EXCLUDED.expires_at \
WHERE mcp_usage_task_attribution.expires_at <= NOW()",
)
.bind(identifier_hash(tenant_id).to_vec())
.bind(identifier_hash(task_id).to_vec())
.bind(encode_attribution(attribution).to_vec())
.bind(self.ttl_seconds)
.execute(&self.pool),
)
.await?;
Ok(())
})
}
fn get<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
Box::pin(async move {
let row: Option<(Vec<u8>,)> = self
.run(
sqlx::query_as(
"SELECT attribution FROM mcp_usage_task_attribution \
WHERE tenant_hash = $1 AND task_hash = $2 AND expires_at > NOW()",
)
.bind(identifier_hash(tenant_id).to_vec())
.bind(identifier_hash(task_id).to_vec())
.fetch_optional(&self.pool),
)
.await?;
row.map(|(attribution,)| decode_attribution(&attribution))
.transpose()
})
}
fn claim<'a>(
&'a self,
tenant_id: &'a str,
task_id: &'a str,
) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
Box::pin(async move {
let row: Option<(Vec<u8>,)> = self
.run(
sqlx::query_as(
"DELETE FROM mcp_usage_task_attribution \
WHERE tenant_hash = $1 AND task_hash = $2 AND expires_at > NOW() \
RETURNING attribution",
)
.bind(identifier_hash(tenant_id).to_vec())
.bind(identifier_hash(task_id).to_vec())
.fetch_optional(&self.pool),
)
.await?;
row.map(|(attribution,)| decode_attribution(&attribution))
.transpose()
})
}
fn remove<'a>(&'a self, tenant_id: &'a str, task_id: &'a str) -> TaskStoreFuture<'a, ()> {
Box::pin(async move {
self.run(
sqlx::query(
"DELETE FROM mcp_usage_task_attribution \
WHERE tenant_hash = $1 AND task_hash = $2",
)
.bind(identifier_hash(tenant_id).to_vec())
.bind(identifier_hash(task_id).to_vec())
.execute(&self.pool),
)
.await?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::postgres::PgPoolOptions;
#[tokio::test]
async fn rejects_zero_operation_timeout_without_contacting_postgres() {
let pool = PgPoolOptions::new()
.connect_lazy("postgres://usagekit:usagekit@127.0.0.1/usagekit")
.unwrap();
let error = PostgresTaskStore::with_timeout(pool, Duration::from_secs(60), Duration::ZERO)
.unwrap_err();
assert_eq!(error, StoreConfigError::InvalidTimeout);
}
}