mcp_usage_store/
postgres.rs1use std::time::Duration;
2use std::{future::Future, result::Result as StdResult};
3
4use mcp_usage_core::TaskAttribution;
5use mcp_usage_tower::{TaskAttributionStore, TaskStoreError, TaskStoreFuture};
6use sqlx::PgPool;
7
8use crate::{StoreConfigError, decode_attribution, encode_attribution, identifier_hash};
9
10const INSTALL_SQL: &str = concat!(
26 "SELECT pg_advisory_xact_lock(7594834021775991);\n",
27 include_str!("../schema/postgres.sql"),
28 "\nSELECT attribution FROM mcp_usage_task_attribution LIMIT 0;"
29);
30const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::from_secs(2);
31
32#[derive(Clone)]
34pub struct PostgresTaskStore {
35 pool: PgPool,
36 ttl_seconds: i64,
37 operation_timeout: Duration,
38}
39
40impl std::fmt::Debug for PostgresTaskStore {
41 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 formatter
43 .debug_struct("PostgresTaskStore")
44 .field("ttl_seconds", &self.ttl_seconds)
45 .field("operation_timeout", &self.operation_timeout)
46 .finish_non_exhaustive()
47 }
48}
49
50impl PostgresTaskStore {
51 pub fn new(pool: PgPool, ttl: Duration) -> Result<Self, StoreConfigError> {
62 Self::with_timeout(pool, ttl, DEFAULT_OPERATION_TIMEOUT)
63 }
64
65 pub fn with_timeout(
71 pool: PgPool,
72 ttl: Duration,
73 operation_timeout: Duration,
74 ) -> Result<Self, StoreConfigError> {
75 let ttl_seconds = i64::try_from(ttl.as_secs()).map_err(|_| StoreConfigError::InvalidTtl)?;
76 if ttl_seconds == 0 {
77 return Err(StoreConfigError::InvalidTtl);
78 }
79 if operation_timeout.is_zero() {
80 return Err(StoreConfigError::InvalidTimeout);
81 }
82 Ok(Self {
83 pool,
84 ttl_seconds,
85 operation_timeout,
86 })
87 }
88
89 pub async fn install(&self) -> Result<(), StoreConfigError> {
106 tokio::time::timeout(
107 self.operation_timeout,
108 sqlx::raw_sql(INSTALL_SQL).execute(&self.pool),
109 )
110 .await
111 .map_err(|_| StoreConfigError::Schema)?
112 .map_err(|_| StoreConfigError::Schema)?;
113 Ok(())
114 }
115
116 pub async fn prune_expired(&self) -> Result<u64, TaskStoreError> {
127 self.run(
128 sqlx::query("DELETE FROM mcp_usage_task_attribution WHERE expires_at <= NOW()")
129 .execute(&self.pool),
130 )
131 .await
132 .map(|result| result.rows_affected())
133 }
134
135 async fn run<T, F>(&self, operation: F) -> Result<T, TaskStoreError>
136 where
137 F: Future<Output = StdResult<T, sqlx::Error>>,
138 {
139 tokio::time::timeout(self.operation_timeout, operation)
140 .await
141 .map_err(|_| TaskStoreError::BackendUnavailable)?
142 .map_err(|_| TaskStoreError::BackendUnavailable)
143 }
144}
145
146impl TaskAttributionStore for PostgresTaskStore {
147 fn insert<'a>(
148 &'a self,
149 tenant_id: &'a str,
150 task_id: &'a str,
151 attribution: TaskAttribution,
152 ) -> TaskStoreFuture<'a, ()> {
153 Box::pin(async move {
154 self.run(
155 sqlx::query(
156 "INSERT INTO mcp_usage_task_attribution \
164 (tenant_hash, task_hash, attribution, expires_at) \
165 VALUES ($1, $2, $3, NOW() + ($4 * INTERVAL '1 second')) \
166 ON CONFLICT (tenant_hash, task_hash) DO UPDATE \
167 SET attribution = EXCLUDED.attribution, \
168 expires_at = EXCLUDED.expires_at \
169 WHERE mcp_usage_task_attribution.expires_at <= NOW()",
170 )
171 .bind(identifier_hash(tenant_id).to_vec())
172 .bind(identifier_hash(task_id).to_vec())
173 .bind(encode_attribution(attribution).to_vec())
174 .bind(self.ttl_seconds)
175 .execute(&self.pool),
176 )
177 .await?;
178 Ok(())
179 })
180 }
181
182 fn get<'a>(
183 &'a self,
184 tenant_id: &'a str,
185 task_id: &'a str,
186 ) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
187 Box::pin(async move {
188 let row: Option<(Vec<u8>,)> = self
189 .run(
190 sqlx::query_as(
191 "SELECT attribution FROM mcp_usage_task_attribution \
192 WHERE tenant_hash = $1 AND task_hash = $2 AND expires_at > NOW()",
193 )
194 .bind(identifier_hash(tenant_id).to_vec())
195 .bind(identifier_hash(task_id).to_vec())
196 .fetch_optional(&self.pool),
197 )
198 .await?;
199 row.map(|(attribution,)| decode_attribution(&attribution))
200 .transpose()
201 })
202 }
203
204 fn claim<'a>(
205 &'a self,
206 tenant_id: &'a str,
207 task_id: &'a str,
208 ) -> TaskStoreFuture<'a, Option<TaskAttribution>> {
209 Box::pin(async move {
210 let row: Option<(Vec<u8>,)> = self
211 .run(
212 sqlx::query_as(
213 "DELETE FROM mcp_usage_task_attribution \
214 WHERE tenant_hash = $1 AND task_hash = $2 AND expires_at > NOW() \
215 RETURNING attribution",
216 )
217 .bind(identifier_hash(tenant_id).to_vec())
218 .bind(identifier_hash(task_id).to_vec())
219 .fetch_optional(&self.pool),
220 )
221 .await?;
222 row.map(|(attribution,)| decode_attribution(&attribution))
223 .transpose()
224 })
225 }
226
227 fn remove<'a>(&'a self, tenant_id: &'a str, task_id: &'a str) -> TaskStoreFuture<'a, ()> {
228 Box::pin(async move {
229 self.run(
230 sqlx::query(
231 "DELETE FROM mcp_usage_task_attribution \
232 WHERE tenant_hash = $1 AND task_hash = $2",
233 )
234 .bind(identifier_hash(tenant_id).to_vec())
235 .bind(identifier_hash(task_id).to_vec())
236 .execute(&self.pool),
237 )
238 .await?;
239 Ok(())
240 })
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use sqlx::postgres::PgPoolOptions;
248
249 #[tokio::test]
250 async fn rejects_zero_operation_timeout_without_contacting_postgres() {
251 let pool = PgPoolOptions::new()
252 .connect_lazy("postgres://usagekit:usagekit@127.0.0.1/usagekit")
253 .unwrap();
254 let error = PostgresTaskStore::with_timeout(pool, Duration::from_secs(60), Duration::ZERO)
255 .unwrap_err();
256
257 assert_eq!(error, StoreConfigError::InvalidTimeout);
258 }
259}