Skip to main content

mcp_usage_store/
postgres.rs

1use 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
10/// Schema installation, serialized by a transaction-scoped advisory lock.
11///
12/// `CREATE TABLE IF NOT EXISTS` is not atomic in `PostgreSQL`. Racing sessions
13/// each consult the catalog, all conclude the table is absent, and every loser
14/// fails with a duplicate key violation on `pg_type_typname_nsp_index`. Taking
15/// an advisory lock first serializes the check, so late arrivals find the table
16/// already present and do nothing.
17///
18/// `sqlx::raw_sql` uses the simple query protocol, which `PostgreSQL` executes as
19/// a single implicit transaction. The lock is therefore held across the DDL and
20/// released at commit, with no explicit transaction object to thread through.
21///
22/// The lock key is an arbitrary but stable constant, assembled at compile time.
23/// It only needs to be distinct from other advisory locks the host application
24/// takes on the same database.
25const 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/// `PostgreSQL` durable-task attribution with atomic, once-only claims.
33#[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    /// Construct a store from an application-owned connection pool.
52    ///
53    /// `ttl` must outlive the longest task the upstream server can run, not
54    /// the client's polling interval. A task that outlives it bills zero. See
55    /// the [crate-level notes](crate#choosing-a-ttl).
56    ///
57    /// # Errors
58    ///
59    /// Returns [`StoreConfigError::InvalidTtl`] when the TTL is zero or cannot
60    /// be represented by `PostgreSQL`'s signed interval input.
61    pub fn new(pool: PgPool, ttl: Duration) -> Result<Self, StoreConfigError> {
62        Self::with_timeout(pool, ttl, DEFAULT_OPERATION_TIMEOUT)
63    }
64
65    /// Construct a store with an explicit bound for every database operation.
66    ///
67    /// # Errors
68    ///
69    /// Returns a configuration error for an invalid TTL or zero timeout.
70    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    /// Install the idempotent table and expiry index.
90    ///
91    /// Safe to call concurrently from every instance of a horizontally scaled
92    /// application, which is the situation this store exists for. `CREATE TABLE
93    /// IF NOT EXISTS` is *not* atomic in `PostgreSQL`: racing sessions check the
94    /// catalog, all decide the table is absent, and every loser fails with a
95    /// duplicate key violation on `pg_type_typname_nsp_index`. A transaction
96    /// scoped advisory lock serializes the check so late arrivals simply find
97    /// the table already present and do nothing.
98    ///
99    /// Applications with migration-controlled schemas can apply
100    /// `schema/postgres.sql` themselves and skip this operation.
101    ///
102    /// # Errors
103    ///
104    /// Returns a sanitized schema error without exposing connection details.
105    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    /// Delete expired task origins and return the affected row count.
117    ///
118    /// **The application must call this on a timer.** Unlike Redis, Postgres
119    /// has no native expiry: every read filters on `expires_at`, so nothing is
120    /// served stale, but the rows themselves stay until something deletes
121    /// them and the table otherwise grows without bound.
122    ///
123    /// # Errors
124    ///
125    /// Returns a sanitized backend error.
126    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                    // `DO NOTHING` would preserve an expired row as if it were
157                    // live. Every read filters on `expires_at`, so that row is
158                    // invisible to `get` and `claim` while still winning the
159                    // conflict: a task id reused after expiry becomes
160                    // permanently unstorable, and every completion of it bills
161                    // nothing. Replacing only an already-expired row keeps the
162                    // first-writer-wins semantics for live ones.
163                    "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}