Skip to main content

agentplane/store/
postgres_push.rs

1//! `PostgreSQL` webhook registrations and durable journal cursors.
2
3use async_trait::async_trait;
4
5use crate::core::{RunId, Secret, Seq, StoreError};
6use crate::push::{
7    DueBatch, OPERATOR_PREFIX, PushAuthentication, PushConfig, PushNamespace, PushRegistration,
8    PushStore,
9};
10
11use super::postgres::{PostgresStore, amount_of, be};
12
13/// One due row into a registration, shared by [`PushStore::due`] and
14/// [`PushStore::due_in`] so the two reads cannot decode one schema two ways.
15fn registration_from(row: &tokio_postgres::Row) -> Result<PushRegistration, StoreError> {
16    let task_id: String = row.get(0);
17    Ok(PushRegistration {
18        config: PushConfig {
19            id: row.get(1),
20            task: RunId::parse(&task_id).map_err(|error| StoreError::Backend(error.to_string()))?,
21            url: row.get(2),
22            token: row.get::<_, Option<String>>(3).map(Secret::new),
23            authentication: row
24                .get::<_, Option<String>>(4)
25                .zip(row.get::<_, Option<String>>(5))
26                .map(|(scheme, credentials)| PushAuthentication {
27                    scheme,
28                    credentials: Secret::new(credentials),
29                }),
30        },
31        // Clamped, not bit-cast, for the reason `amount_of` gives: the columns
32        // carry CHECKs, but a row edited around them must read as *nothing*
33        // rather than as billions — `cast_unsigned` on a negative cursor would
34        // fast-forward the delivery past every unacknowledged event.
35        next_seq: amount_of(row.get::<_, i64>(6)),
36        attempts: u32::try_from(row.get::<_, i32>(7)).unwrap_or(0),
37        next_attempt_at: amount_of(row.get::<_, i64>(8)),
38        last_error: row.get(9),
39    })
40}
41
42#[async_trait]
43impl PushStore for PostgresStore {
44    fn tenant(&self) -> &str {
45        self.tenant_str()
46    }
47
48    async fn put(&self, config: &PushConfig, next_seq: Seq) -> Result<(), StoreError> {
49        let client = self
50            .pool_ref()
51            .get()
52            .await
53            .map_err(|error| StoreError::Backend(error.to_string()))?;
54        client
55            .execute(
56                "INSERT INTO push_delivery
57                          (tenant, task_id, config_id, url, token, auth_scheme, auth_credentials,
58                            next_seq, attempts, next_attempt_at, last_error)
59                      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, NULL)
60                 ON CONFLICT (tenant, task_id, config_id) DO UPDATE SET
61                    url = EXCLUDED.url,
62                    token = EXCLUDED.token,
63                    auth_scheme = EXCLUDED.auth_scheme,
64                    auth_credentials = EXCLUDED.auth_credentials,
65                    next_seq = push_delivery.next_seq,
66                    attempts = 0,
67                    next_attempt_at = 0,
68                    last_error = NULL,
69                    parked = FALSE",
70                &[
71                    &self.tenant_name(),
72                    &config.task.to_string(),
73                    &config.id,
74                    &config.url,
75                    &config.token.as_ref().map(Secret::expose),
76                    &config
77                        .authentication
78                        .as_ref()
79                        .map(|auth| auth.scheme.as_str()),
80                    &config
81                        .authentication
82                        .as_ref()
83                        .map(|auth| auth.credentials.expose()),
84                    &next_seq.cast_signed(),
85                ],
86            )
87            .await
88            .map_err(|error| be(&error))?;
89        Ok(())
90    }
91
92    async fn get(&self, task: RunId, id: &str) -> Result<Option<PushConfig>, StoreError> {
93        let client = self
94            .pool_ref()
95            .get()
96            .await
97            .map_err(|error| StoreError::Backend(error.to_string()))?;
98        let row = client
99            .query_opt(
100                "SELECT url, token, auth_scheme, auth_credentials FROM push_delivery
101                 WHERE tenant = $1 AND task_id = $2 AND config_id = $3",
102                &[&self.tenant_name(), &task.to_string(), &id],
103            )
104            .await
105            .map_err(|error| be(&error))?;
106        Ok(row.map(|row| PushConfig {
107            id: id.to_owned(),
108            task,
109            url: row.get(0),
110            token: row.get::<_, Option<String>>(1).map(Secret::new),
111            authentication: row
112                .get::<_, Option<String>>(2)
113                .zip(row.get::<_, Option<String>>(3))
114                .map(|(scheme, credentials)| PushAuthentication {
115                    scheme,
116                    credentials: Secret::new(credentials),
117                }),
118        }))
119    }
120
121    async fn list(&self, task: RunId) -> Result<Vec<PushConfig>, StoreError> {
122        let client = self
123            .pool_ref()
124            .get()
125            .await
126            .map_err(|error| StoreError::Backend(error.to_string()))?;
127        let rows = client
128            .query(
129                "SELECT config_id, url, token, auth_scheme, auth_credentials FROM push_delivery
130                 WHERE tenant = $1 AND task_id = $2 ORDER BY config_id",
131                &[&self.tenant_name(), &task.to_string()],
132            )
133            .await
134            .map_err(|error| be(&error))?;
135        Ok(rows
136            .into_iter()
137            .map(|row| PushConfig {
138                id: row.get(0),
139                task,
140                url: row.get(1),
141                token: row.get::<_, Option<String>>(2).map(Secret::new),
142                authentication: row
143                    .get::<_, Option<String>>(3)
144                    .zip(row.get::<_, Option<String>>(4))
145                    .map(|(scheme, credentials)| PushAuthentication {
146                        scheme,
147                        credentials: Secret::new(credentials),
148                    }),
149            })
150            .collect())
151    }
152
153    async fn due(&self, at: u64, limit: usize) -> Result<Vec<PushRegistration>, StoreError> {
154        let client = self
155            .pool_ref()
156            .get()
157            .await
158            .map_err(|error| StoreError::Backend(error.to_string()))?;
159        let rows = client
160            .query(
161                "SELECT task_id, config_id, url, token, auth_scheme, auth_credentials,
162                    next_seq, attempts, next_attempt_at, last_error
163                 FROM push_delivery
164                 WHERE tenant = $1 AND next_attempt_at <= $2 AND NOT parked
165                 ORDER BY next_attempt_at, task_id, config_id
166                 LIMIT $3",
167                &[
168                    &self.tenant_name(),
169                    &at.cast_signed(),
170                    &i64::try_from(limit).unwrap_or(i64::MAX),
171                ],
172            )
173            .await
174            .map_err(|error| be(&error))?;
175        rows.iter().map(registration_from).collect()
176    }
177
178    async fn due_in(
179        &self,
180        at: u64,
181        limit: usize,
182        namespace: PushNamespace,
183    ) -> Result<DueBatch, StoreError> {
184        let client = self
185            .pool_ref()
186            .get()
187            .await
188            .map_err(|error| StoreError::Backend(error.to_string()))?;
189        // The namespace filter rides in the query, which is the reason this
190        // override exists: the paging default is correct and linear in the
191        // other namespace's backlog, and this backend has an index. The prefix
192        // test is a LIKE pattern *derived from* [`OPERATOR_PREFIX`] so the
193        // query and [`is_operator_id`](crate::push::is_operator_id) cannot
194        // drift — safe because the prefix contains no `%` or `_`, the two
195        // bytes LIKE would read as instructions.
196        let wants_operator = namespace == PushNamespace::Operator;
197        let pattern = format!("{OPERATOR_PREFIX}%");
198        let rows = client
199            .query(
200                "SELECT task_id, config_id, url, token, auth_scheme, auth_credentials,
201                    next_seq, attempts, next_attempt_at, last_error
202                 FROM push_delivery
203                 WHERE tenant = $1 AND next_attempt_at <= $2 AND NOT parked
204                   AND (config_id LIKE $4) = $5
205                 ORDER BY next_attempt_at, task_id, config_id
206                 LIMIT $3",
207                &[
208                    &self.tenant_name(),
209                    &at.cast_signed(),
210                    &i64::try_from(limit).unwrap_or(i64::MAX),
211                    &pattern,
212                    &wants_operator,
213                ],
214            )
215            .await
216            .map_err(|error| be(&error))?;
217        // The whole foreign due backlog, not the slice a scan happened to walk
218        // past: `unserved` documents itself as a lower bound, and the exact
219        // count is the most honest lower bound a backend with a filter can
220        // give — it is what the paging default converges to once it has read
221        // the store to the end.
222        let unserved_row = client
223            .query_one(
224                "SELECT COUNT(*) FROM push_delivery
225                 WHERE tenant = $1 AND next_attempt_at <= $2 AND NOT parked
226                   AND (config_id LIKE $3) <> $4",
227                &[
228                    &self.tenant_name(),
229                    &at.cast_signed(),
230                    &pattern,
231                    &wants_operator,
232                ],
233            )
234            .await
235            .map_err(|error| be(&error))?;
236        let unserved = usize::try_from(unserved_row.get::<_, i64>(0)).unwrap_or(usize::MAX);
237        Ok(DueBatch {
238            rows: rows
239                .iter()
240                .map(registration_from)
241                .collect::<Result<_, _>>()?,
242            unserved,
243        })
244    }
245
246    async fn advance(&self, task: RunId, id: &str, next_seq: Seq) -> Result<(), StoreError> {
247        let client = self
248            .pool_ref()
249            .get()
250            .await
251            .map_err(|error| StoreError::Backend(error.to_string()))?;
252        client
253            .execute(
254                "UPDATE push_delivery
255                 SET next_seq = GREATEST(next_seq, $4), attempts = 0,
256                     next_attempt_at = 0, last_error = NULL, parked = FALSE
257                 WHERE tenant = $1 AND task_id = $2 AND config_id = $3",
258                &[
259                    &self.tenant_name(),
260                    &task.to_string(),
261                    &id,
262                    &next_seq.cast_signed(),
263                ],
264            )
265            .await
266            .map_err(|error| be(&error))?;
267        Ok(())
268    }
269
270    async fn retry(
271        &self,
272        task: RunId,
273        id: &str,
274        next_attempt_at: u64,
275        error: &str,
276    ) -> Result<(), StoreError> {
277        let client = self
278            .pool_ref()
279            .get()
280            .await
281            .map_err(|pool_error| StoreError::Backend(pool_error.to_string()))?;
282        client
283            .execute(
284                "UPDATE push_delivery
285                 SET attempts = LEAST(attempts + 1, 2147483647),
286                     next_attempt_at = $4, last_error = $5, parked = FALSE
287                 WHERE tenant = $1 AND task_id = $2 AND config_id = $3",
288                &[
289                    &self.tenant_name(),
290                    &task.to_string(),
291                    &id,
292                    &next_attempt_at.cast_signed(),
293                    &error,
294                ],
295            )
296            .await
297            .map_err(|db_error| be(&db_error))?;
298        Ok(())
299    }
300
301    async fn park(&self, task: RunId, id: &str, error: &str) -> Result<(), StoreError> {
302        let client = self
303            .pool_ref()
304            .get()
305            .await
306            .map_err(|pool_error| StoreError::Backend(pool_error.to_string()))?;
307        client
308            .execute(
309                "UPDATE push_delivery
310                 SET attempts = LEAST(attempts + 1, 2147483647),
311                     last_error = $4, parked = TRUE
312                 WHERE tenant = $1 AND task_id = $2 AND config_id = $3",
313                &[&self.tenant_name(), &task.to_string(), &id, &error],
314            )
315            .await
316            .map_err(|db_error| be(&db_error))?;
317        Ok(())
318    }
319
320    async fn parked(&self, limit: usize) -> Result<Vec<PushRegistration>, StoreError> {
321        let client = self
322            .pool_ref()
323            .get()
324            .await
325            .map_err(|error| StoreError::Backend(error.to_string()))?;
326        let rows = client
327            .query(
328                "SELECT task_id, config_id, url, token, auth_scheme, auth_credentials,
329                    next_seq, attempts, next_attempt_at, last_error
330                 FROM push_delivery
331                 WHERE tenant = $1 AND parked
332                 ORDER BY task_id, config_id
333                 LIMIT $2",
334                &[
335                    &self.tenant_name(),
336                    &i64::try_from(limit).unwrap_or(i64::MAX),
337                ],
338            )
339            .await
340            .map_err(|error| be(&error))?;
341        rows.iter().map(registration_from).collect()
342    }
343
344    async fn unpark(&self, task: RunId, id: &str, at: u64) -> Result<bool, StoreError> {
345        let client = self
346            .pool_ref()
347            .get()
348            .await
349            .map_err(|error| StoreError::Backend(error.to_string()))?;
350        let updated = client
351            .execute(
352                "UPDATE push_delivery
353                 SET parked = FALSE, attempts = 0, next_attempt_at = $4
354                 WHERE tenant = $1 AND task_id = $2 AND config_id = $3 AND parked",
355                &[
356                    &self.tenant_name(),
357                    &task.to_string(),
358                    &id,
359                    &at.cast_signed(),
360                ],
361            )
362            .await
363            .map_err(|error| be(&error))?;
364        Ok(updated > 0)
365    }
366
367    async fn delete(&self, task: RunId, id: &str) -> Result<(), StoreError> {
368        let client = self
369            .pool_ref()
370            .get()
371            .await
372            .map_err(|error| StoreError::Backend(error.to_string()))?;
373        client
374            .execute(
375                "DELETE FROM push_delivery
376                 WHERE tenant = $1 AND task_id = $2 AND config_id = $3",
377                &[&self.tenant_name(), &task.to_string(), &id],
378            )
379            .await
380            .map_err(|error| be(&error))?;
381        Ok(())
382    }
383}