1pub(crate) mod instant;
31
32#[cfg(feature = "test-support")]
33pub mod test_support;
34
35use instant::StoredInstant;
36use std::num::{NonZeroI64, NonZeroUsize};
37use std::sync::Arc;
38
39use async_trait::async_trait;
40use jiff::{SignedDuration, Timestamp};
41use serde::{Deserialize, Serialize};
42use sqlx::postgres::PgPoolOptions;
43use sqlx::{PgPool, Postgres, Row, Transaction};
44use tokio::sync::broadcast;
45
46use tollgate_core::{
47 AccountId, AccountSnapshot, AccountStatus, BudgetSchedule, BudgetView, CapacityClass,
48 CostTable, CostUnits, EnforcementMode, FencingToken, Generation, KeyId, LeaseGrant, LeaseId,
49 Period, PermissionBits, PolicyRevision, Principal, PublishableSnapshot, ResolvedLimits,
50 Rollover, UsageEvent,
51};
52use tollgate_store::{
53 AccountConfig, AccountView, AdminReceipt, AdminState, AdminStore, AllocateError, Allocation,
54 BudgetError, Conservation, CreateAccountError, GrantPolicy, IngestError, IngestReport,
55 KeyDirectory, KeyError, KeyRecord, KeySnapshotError, KeySummary, LeaseAllocator,
56 PUSH_CHANNEL_CAPACITY, PublishSnapshotError, ReclaimBatch, ReclaimedLease, Revocation,
57 RolledAccount, RolloverBatch, SetStatusError, SnapshotPush, SnapshotResolution, SnapshotSource,
58 StatusChange, StoreError, StoreHealth, UsageSink, pushes_exceed_capacity,
59 validate_key_page_limit,
60};
61
62const STATE_ACTIVE: i16 = 0;
63const STATE_RELEASED: i16 = 1;
64const STATE_EXPIRED: i16 = 2;
65
66#[derive(Debug, Clone, Copy)]
70struct StoredId(u128);
71
72impl Serialize for StoredId {
73 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74 where
75 S: serde::Serializer,
76 {
77 match u64::try_from(self.0) {
78 Ok(value) => serializer.serialize_u64(value),
79 Err(_) => serializer.collect_str(&format_args!("{:032x}", self.0)),
80 }
81 }
82}
83
84impl<'de> Deserialize<'de> for StoredId {
85 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
86 where
87 D: serde::Deserializer<'de>,
88 {
89 struct StoredIdVisitor;
90
91 impl serde::de::Visitor<'_> for StoredIdVisitor {
92 type Value = StoredId;
93
94 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 formatter.write_str("a legacy u64 number or a canonical 128-bit identifier string")
96 }
97
98 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
99 Ok(StoredId(u128::from(value)))
100 }
101
102 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
103 where
104 E: serde::de::Error,
105 {
106 value
107 .parse::<AccountId>()
108 .map(|id| StoredId(id.0))
109 .map_err(E::custom)
110 }
111 }
112
113 deserializer.deserialize_any(StoredIdVisitor)
114 }
115}
116
117fn digest_from(bytes: &[u8], key_id: KeyId) -> Result<[u8; 32], StoreError> {
122 <[u8; 32]>::try_from(bytes).map_err(|_| {
123 StoreError(format!(
124 "credential {key_id} has a {}-byte digest in storage, expected 32",
125 bytes.len()
126 ))
127 })
128}
129
130#[async_trait]
131impl KeyDirectory for PostgresStore {
132 async fn credential_activity(
133 &self,
134 keys: &[KeyId],
135 ) -> Result<Vec<tollgate_store::CredentialActivity>, StoreError> {
136 use tollgate_store::{CredentialActivity, CredentialActivityState};
137 let mut result = Vec::with_capacity(keys.len());
138 for chunk in keys.chunks(tollgate_store::MAX_INGEST_BATCH) {
141 let ids: Vec<_> = chunk.iter().map(|id| id_bytes(id.0)).collect();
142 let rows = sqlx::query(
143 "SELECT evidence.key_id IS NOT NULL, evidence.last_committed_at_us
147 FROM UNNEST($1::bytea[]) WITH ORDINALITY AS requested(key_id, ordinal)
148 LEFT JOIN LATERAL (
149 SELECT k.key_id, a.last_committed_at_us
150 FROM tollgate_credential_keys k
151 LEFT JOIN tollgate_credential_activity a ON a.key_id = k.key_id
152 WHERE k.key_id = requested.key_id LIMIT 1
153 ) AS evidence ON true
154 ORDER BY requested.ordinal",
155 )
156 .bind(&ids)
157 .fetch_all(&self.pool)
158 .await
159 .map_err(storage)?;
160 if rows.len() != chunk.len() {
161 return Err(StoreError("incomplete credential activity read".into()));
162 }
163 for (&key_id, row) in chunk.iter().zip(rows) {
164 let state = if !row.get::<bool, _>(0) {
165 CredentialActivityState::Unknown
166 } else if let Some(at) = row.get::<Option<i64>, _>(1) {
167 CredentialActivityState::Committed {
168 last_committed_at: micros_ts(at, "credential activity")?,
169 }
170 } else {
171 CredentialActivityState::Unobserved
172 };
173 result.push(CredentialActivity { key_id, state });
174 }
175 }
176 Ok(result)
177 }
178
179 async fn insert_key(&self, record: KeyRecord) -> Result<(), KeyError> {
180 self.insert_credential(record, None)
181 .await
182 .map(|receipt| receipt.outcome)
183 }
184
185 async fn revoke_key(&self, key_id: KeyId, now: Timestamp) -> Result<Revocation, KeyError> {
186 self.revoke_key_audited(key_id, now)
187 .await
188 .map(|receipt| receipt.outcome)
189 }
190
191 async fn revoke_key_audited(
192 &self,
193 key_id: KeyId,
194 now: Timestamp,
195 ) -> Result<AdminReceipt<Revocation>, KeyError> {
196 let mut tx = self.pool.begin().await.map_err(storage)?;
197 let result = async {
198 let row = sqlx::query(
202 "SELECT account_id, revoked_at_us IS NOT NULL
203 FROM tollgate_credential_keys WHERE key_id = $1 FOR UPDATE",
204 )
205 .bind(id_bytes(key_id.0))
206 .fetch_optional(&mut *tx)
207 .await
208 .map_err(storage)?
209 .ok_or(KeyError::UnknownKey)?;
210 let account_bytes: Vec<u8> = row.get(0);
211 let account_bytes: [u8; 16] = account_bytes
212 .try_into()
213 .map_err(|_| StoreError("credential account identifier is not 16 bytes".into()))?;
214 let account_id = AccountId(u128::from_be_bytes(account_bytes));
215 let revoked: bool = row.get(1);
216 let before = AdminState::Credential {
217 account_id,
218 key_id,
219 revoked,
220 };
221 let outcome = if revoked {
222 Revocation::AlreadyRetired
223 } else {
224 sqlx::query(
225 "UPDATE tollgate_credential_keys SET revoked_at_us = $2 WHERE key_id = $1",
226 )
227 .bind(id_bytes(key_id.0))
228 .bind(ts_micros(now))
229 .execute(&mut *tx)
230 .await
231 .map_err(storage)?;
232 Revocation::Retired
233 };
234 Ok(AdminReceipt::new(
235 outcome,
236 before,
237 AdminState::Credential {
238 account_id,
239 key_id,
240 revoked: true,
241 },
242 ))
243 }
244 .await;
245 finish_transaction(tx, result).await
246 }
247
248 async fn publish_key_snapshot(
249 &self,
250 account: AccountId,
251 key: KeyId,
252 snapshot: PublishableSnapshot,
253 ) -> Result<AdminReceipt<()>, KeySnapshotError> {
254 let generation = i64::try_from(snapshot.generation.0).map_err(|_| {
255 StoreError("snapshot generation exceeds PostgreSQL BIGINT range".into())
256 })?;
257 let mut tx = self.pool.begin().await.map_err(storage)?;
258 let result = async {
259 let (principal, revoked) = lock_account_key(&mut tx, account, key).await?;
260 if revoked {
261 return Err(KeySnapshotError::Retired { key_id: key });
262 }
263 if snapshot.key_id != Some(key) {
264 return Err(PublishSnapshotError::CredentialMismatch { key_id: key }.into());
265 }
266 let published = publish_in_tx(&mut tx, principal, generation, snapshot).await?;
267 Ok((principal, published))
268 }
269 .await;
270 let (principal, (written, published, before, after)) =
271 finish_transaction(tx, result).await?;
272 if written {
273 self.push_to_subscribers(SnapshotPush {
274 principal,
275 resolution: SnapshotResolution::Present(published),
276 });
277 }
278 Ok(AdminReceipt::new((), before, after))
279 }
280
281 async fn remove_key_snapshot(
282 &self,
283 account: AccountId,
284 key: KeyId,
285 ) -> Result<AdminReceipt<()>, KeySnapshotError> {
286 let mut tx = self.pool.begin().await.map_err(storage)?;
287 let result = async {
288 let (principal, _revoked) = lock_account_key(&mut tx, account, key).await?;
289 Ok::<_, KeySnapshotError>((principal, remove_in_tx(&mut tx, principal).await?))
290 }
291 .await;
292 let (principal, receipt) = finish_transaction(tx, result).await?;
293 self.announce_removal(principal, &receipt);
294 Ok(receipt)
295 }
296
297 async fn active_keys(&self, now: Timestamp) -> Result<Vec<KeyRecord>, StoreError> {
298 let cutoff = StoredInstant::from(now);
299 let rows = sqlx::query(
305 "SELECT key_id, account_id, principal, digest, not_after_floor_us, not_after_submicro_ns, not_after_is_lower_bound
306 FROM tollgate_credential_keys
307 WHERE revoked_at_us IS NULL
308 AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
309 OR (not_after_floor_us, not_after_submicro_ns) > ($1, $2))
310 ORDER BY key_id",
311 )
312 .bind(cutoff.micros)
313 .bind(cutoff.submicro_nanos)
314 .fetch_all(&self.pool)
315 .await
316 .map_err(storage)?;
317
318 rows.into_iter().map(credential_from_row).collect()
319 }
320
321 async fn account_keys(
322 &self,
323 account: AccountId,
324 after: Option<KeyId>,
325 limit: NonZeroUsize,
326 ) -> Result<Vec<KeySummary>, StoreError> {
327 validate_key_page_limit(limit)?;
328 let sql = if after.is_some() {
331 "SELECT key_id, not_after_floor_us, not_after_submicro_ns,
332 not_after_is_lower_bound, revoked_at_us
333 FROM tollgate_credential_keys
334 WHERE account_id = $1 AND key_id > $3
335 ORDER BY key_id LIMIT $2"
336 } else {
337 "SELECT key_id, not_after_floor_us, not_after_submicro_ns,
338 not_after_is_lower_bound, revoked_at_us
339 FROM tollgate_credential_keys
340 WHERE account_id = $1
341 ORDER BY key_id LIMIT $2"
342 };
343 let mut query = sqlx::query(sql)
344 .bind(id_bytes(account.0))
345 .bind(i64::try_from(limit.get()).unwrap_or(i64::MAX));
346 if let Some(cursor) = after {
347 query = query.bind(id_bytes(cursor.0));
348 }
349 let rows = query.fetch_all(&self.pool).await.map_err(storage)?;
350 rows.into_iter().map(summary_from_row).collect()
351 }
352
353 async fn insert_key_within(
354 &self,
355 record: KeyRecord,
356 max_active: NonZeroUsize,
357 now: Timestamp,
358 ) -> Result<(), KeyError> {
359 self.insert_key_within_audited(record, max_active, now)
360 .await
361 .map(|receipt| receipt.outcome)
362 }
363
364 async fn insert_key_within_audited(
365 &self,
366 record: KeyRecord,
367 max_active: NonZeroUsize,
368 now: Timestamp,
369 ) -> Result<AdminReceipt<()>, KeyError> {
370 self.insert_credential(record, Some((max_active, now)))
371 .await
372 }
373}
374
375impl PostgresStore {
376 async fn insert_credential(
381 &self,
382 record: KeyRecord,
383 bound: Option<(NonZeroUsize, Timestamp)>,
384 ) -> Result<AdminReceipt<()>, KeyError> {
385 let mut tx = self
386 .pool
387 .begin()
388 .await
389 .map_err(|e| KeyError::Storage(storage(e)))?;
390 let account = sqlx::query(ACCOUNT_LOCK_SQL)
398 .bind(id_bytes(record.account_id.0))
399 .fetch_optional(&mut *tx)
400 .await
401 .map_err(|e| KeyError::Storage(storage(e)))?;
402 if account.is_none() {
403 return Err(KeyError::UnknownAccount);
404 }
405
406 if let Some((max_active, now)) = bound {
407 let existing: Option<i32> = sqlx::query_scalar(
414 "SELECT 1 FROM tollgate_credential_keys WHERE key_id = $1 OR principal = $2",
415 )
416 .bind(id_bytes(record.key_id.0))
417 .bind(id_bytes(record.principal.0))
418 .fetch_optional(&mut *tx)
419 .await
420 .map_err(|e| KeyError::Storage(storage(e)))?;
421 if existing.is_some() {
422 return Err(KeyError::AlreadyExists);
423 }
424
425 let cutoff = StoredInstant::from(now);
426 let live: i64 = sqlx::query_scalar(LIVE_KEY_COUNT_SQL)
427 .bind(id_bytes(record.account_id.0))
428 .bind(cutoff.micros)
429 .bind(cutoff.submicro_nanos)
430 .fetch_one(&mut *tx)
431 .await
432 .map_err(|e| KeyError::Storage(storage(e)))?;
433 if u128::from(live.max(0).unsigned_abs())
434 >= u128::try_from(max_active.get()).unwrap_or(u128::MAX)
435 {
436 return Err(KeyError::ActiveKeyLimit { limit: max_active });
437 }
438 }
439
440 let expiry = record.not_after.map(StoredInstant::from);
441 let result = sqlx::query(
442 "INSERT INTO tollgate_credential_keys
443 (key_id, account_id, principal, digest, not_after_floor_us,
444 not_after_submicro_ns, not_after_is_lower_bound, revoked_at_us)
445 VALUES ($1, $2, $3, $4, $5, $6, FALSE, NULL)
446 ON CONFLICT (key_id) DO NOTHING",
447 )
448 .bind(id_bytes(record.key_id.0))
449 .bind(id_bytes(record.account_id.0))
450 .bind(id_bytes(record.principal.0))
451 .bind(record.digest.to_vec())
452 .bind(expiry.map(|expiry| expiry.micros))
453 .bind(expiry.map(|expiry| expiry.submicro_nanos))
454 .execute(&mut *tx)
455 .await;
456 let outcome = match result {
457 Ok(done) if done.rows_affected() == 0 => Err(KeyError::AlreadyExists),
458 Ok(_) => Ok(()),
459 Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
460 Err(KeyError::AlreadyExists)
461 }
462 Err(e) => Err(KeyError::Storage(storage(e))),
472 };
473 outcome?;
474 tx.commit()
475 .await
476 .map_err(|e| KeyError::Storage(storage(e)))?;
477 Ok(AdminReceipt::new(
478 (),
479 AdminState::Absent,
480 AdminState::Credential {
481 account_id: record.account_id,
482 key_id: record.key_id,
483 revoked: false,
484 },
485 ))
486 }
487}
488
489fn summary_from_row(row: sqlx::postgres::PgRow) -> Result<KeySummary, StoreError> {
490 let bytes: Vec<u8> = row.get(0);
491 let fixed: [u8; 16] = bytes
492 .as_slice()
493 .try_into()
494 .map_err(|_| StoreError("credential identifier is not 16 bytes".into()))?;
495 let not_after = match (row.get::<Option<i64>, _>(1), row.get::<Option<i16>, _>(2)) {
496 (None, None) if !row.get::<bool, _>(3) => None,
497 (Some(micros), Some(submicro_nanos)) => Some(
498 StoredInstant {
499 micros,
500 submicro_nanos,
501 }
502 .timestamp()?,
503 ),
504 _ => return Err(StoreError("incomplete stored credential expiry".into())),
505 };
506 Ok(KeySummary {
507 key_id: KeyId(u128::from_be_bytes(fixed)),
508 not_after,
509 revoked_at: row
510 .get::<Option<i64>, _>(4)
511 .map(|micros| {
512 StoredInstant {
513 micros,
514 submicro_nanos: 0,
515 }
516 .timestamp()
517 })
518 .transpose()?,
519 })
520}
521
522fn credential_from_row(row: sqlx::postgres::PgRow) -> Result<KeyRecord, StoreError> {
523 let id = |index| -> Result<u128, StoreError> {
524 let bytes: Vec<u8> = row.get(index);
525 let fixed: [u8; 16] = bytes
526 .as_slice()
527 .try_into()
528 .map_err(|_| StoreError("credential identifier is not 16 bytes".into()))?;
529 Ok(u128::from_be_bytes(fixed))
530 };
531 let key_id = KeyId(id(0)?);
532 let not_after = match (row.get::<Option<i64>, _>(4), row.get::<Option<i16>, _>(5)) {
533 (None, None) if !row.get::<bool, _>(6) => None,
534 (Some(micros), Some(submicro_nanos)) => Some(
535 StoredInstant {
536 micros,
537 submicro_nanos,
538 }
539 .timestamp()?,
540 ),
541 _ => return Err(StoreError("incomplete stored credential expiry".into())),
542 };
543 Ok(KeyRecord {
544 key_id,
545 account_id: AccountId(id(1)?),
546 principal: Principal(id(2)?),
547 digest: digest_from(row.get::<Vec<u8>, _>(3).as_slice(), key_id)?,
548 not_after,
549 })
550}
551
552#[async_trait]
553impl tollgate_store::KeySource for PostgresStore {
554 async fn active_keys_page(
555 &self,
556 now: Timestamp,
557 after: Option<KeyId>,
558 limit: NonZeroUsize,
559 ) -> Result<tollgate_store::KeyPage, StoreError> {
560 validate_key_page_limit(limit)?;
561 let cutoff = StoredInstant::from(now);
562 let mut tx = self.pool.begin().await.map_err(storage)?;
565 sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
566 .execute(&mut *tx)
567 .await
568 .map_err(storage)?;
569 let revision: i64 =
570 sqlx::query_scalar("SELECT revision FROM tollgate_credential_revision WHERE singleton")
571 .fetch_one(&mut *tx)
572 .await
573 .map_err(storage)?;
574 let revision = u64::try_from(revision)
575 .map_err(|_| StoreError("credential revision is negative".into()))?;
576 let sql = if after.is_some() {
578 "SELECT key_id, account_id, principal, digest, not_after_floor_us, not_after_submicro_ns, not_after_is_lower_bound
579 FROM tollgate_credential_keys WHERE revoked_at_us IS NULL
580 AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
581 OR (not_after_floor_us, not_after_submicro_ns) > ($1, $2)) AND key_id > $3
582 ORDER BY key_id LIMIT $4"
583 } else {
584 "SELECT key_id, account_id, principal, digest, not_after_floor_us, not_after_submicro_ns, not_after_is_lower_bound
585 FROM tollgate_credential_keys WHERE revoked_at_us IS NULL
586 AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
587 OR (not_after_floor_us, not_after_submicro_ns) > ($1, $2)) AND key_id >= $3
588 ORDER BY key_id LIMIT $4"
589 };
590 let rows = sqlx::query(sql)
591 .bind(cutoff.micros)
592 .bind(cutoff.submicro_nanos)
593 .bind(id_bytes(after.unwrap_or(KeyId(0)).0))
594 .bind((limit.get() + 1) as i64)
595 .fetch_all(&mut *tx)
596 .await
597 .map_err(storage)?;
598 tx.commit().await.map_err(storage)?;
599 let mut records = rows
602 .into_iter()
603 .map(credential_from_row)
604 .map(|row| row.map(tollgate_store::CredentialRecord::from))
605 .collect::<Result<Vec<_>, _>>()?;
606 let next_after = if records.len() > limit.get() {
607 records.pop();
608 records.last().map(|record| record.key_id)
609 } else {
610 None
611 };
612 tollgate_store::KeyPage::try_new(revision, now, after, limit, records, next_after)
613 }
614}
615
616#[cfg(test)]
617mod stored_id_tests {
618 use super::StoredId;
619
620 #[test]
621 fn malformed_storage_id_explains_both_accepted_representations() {
622 let error = serde_json::from_value::<StoredId>(serde_json::Value::Bool(true)).unwrap_err();
623 assert!(
624 error
625 .to_string()
626 .contains("a legacy u64 number or a canonical 128-bit identifier string"),
627 "unexpected diagnostic: {error}"
628 );
629 }
630}
631
632#[derive(Serialize)]
648struct StoredSnapshotRef<'a> {
649 account_id: StoredId,
650 key_id: Option<StoredId>,
651 status: &'a AccountStatus,
652 capacity_class: &'a CapacityClass,
655 enforcement_mode: &'a EnforcementMode,
656 valid_until: &'a Timestamp,
657 permissions: &'a PermissionBits,
658 limits: &'a ResolvedLimits,
659 cost_table: &'a Arc<CostTable>,
660 budget: Option<&'a BudgetView>,
664 policy_revision: &'a PolicyRevision,
669}
670
671impl<'a> From<&'a AccountSnapshot> for StoredSnapshotRef<'a> {
672 fn from(snapshot: &'a AccountSnapshot) -> Self {
673 StoredSnapshotRef {
674 account_id: StoredId(snapshot.account_id.0),
675 key_id: snapshot.key_id.map(|id| StoredId(id.0)),
676 status: &snapshot.status,
677 capacity_class: &snapshot.capacity_class,
678 enforcement_mode: &snapshot.enforcement_mode,
679 valid_until: &snapshot.valid_until,
680 permissions: &snapshot.permissions,
681 limits: &snapshot.limits,
682 cost_table: &snapshot.cost_table,
683 budget: snapshot.budget.as_ref(),
684 policy_revision: &snapshot.policy_revision,
685 }
686 }
687}
688
689#[derive(Deserialize)]
693struct StoredSnapshot {
694 account_id: StoredId,
695 key_id: Option<StoredId>,
696 status: AccountStatus,
697 #[serde(default)]
703 capacity_class: CapacityClass,
704 #[serde(default)]
715 enforcement_mode: EnforcementMode,
716 valid_until: Timestamp,
717 permissions: PermissionBits,
718 limits: ResolvedLimits,
719 cost_table: Arc<CostTable>,
720 #[serde(default)]
726 budget: Option<BudgetView>,
727 #[serde(default)]
733 policy_revision: PolicyRevision,
734}
735
736impl StoredSnapshot {
737 fn into_snapshot(self, generation: Generation) -> AccountSnapshot {
743 let builder = AccountSnapshot::builder(
744 AccountId(self.account_id.0),
745 generation,
746 self.status,
747 self.valid_until,
748 self.permissions,
749 self.limits,
750 self.cost_table,
751 )
752 .enforcement_mode(self.enforcement_mode)
753 .capacity_class(self.capacity_class)
754 .policy_revision(self.policy_revision);
755 match self.key_id {
756 Some(key_id) => builder.key_id(tollgate_core::KeyId(key_id.0)).build(),
757 None => builder.build(),
758 }
759 }
760}
761
762const ACTIVE_LEASE_SUM_SQL: &str =
780 "SELECT COALESCE(SUM(granted), 0)::BIGINT, COALESCE(SUM(used), 0)::BIGINT
781 FROM tollgate_leases WHERE account_id = $1 AND state = 0";
782
783pub(crate) const ACCOUNT_LOCK_SQL: &str =
802 "SELECT 1 FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE";
803
804pub(crate) const LIVE_KEY_COUNT_SQL: &str = "SELECT count(*) FROM tollgate_credential_keys
807 WHERE account_id = $1
808 AND revoked_at_us IS NULL
809 AND (not_after_floor_us IS NULL OR not_after_submicro_ns IS NULL
810 OR (not_after_floor_us, not_after_submicro_ns) > ($2, $3))";
811
812const RECLAIM_DUE_LEASES_SQL: &str = "SELECT lease_id, account_id, granted, used
813 FROM tollgate_leases
814 WHERE state = 0 AND (expires_at_floor_us, expires_at_submicro_ns) <= ($1, $2)
815 ORDER BY expires_at_floor_us, expires_at_submicro_ns
816 LIMIT $3 FOR UPDATE SKIP LOCKED";
817
818const DUE_PERIODS_SQL: &str = "WITH due AS (
847 SELECT account_id, allowance_balance AS prior, budget_allowance AS allowance,
848 period_start_us AS crossed_from
849 FROM tollgate_accounts
850 WHERE budget_allowance IS NOT NULL
851 AND budget_period = $1 AND period_start_us < $2
852 ORDER BY period_start_us LIMIT $3 FOR UPDATE SKIP LOCKED
853 )
854 UPDATE tollgate_accounts AS account SET
855 deposited = account.deposited + due.allowance,
856 expired = account.expired + due.prior,
857 balance = account.balance - due.prior + due.allowance,
858 allowance_balance = due.allowance,
859 period_start_us = $2
860 FROM due
861 WHERE account.account_id = due.account_id
862 RETURNING due.account_id, due.allowance, due.prior, due.crossed_from";
863
864fn id_bytes(id: u128) -> Vec<u8> {
865 id.to_be_bytes().to_vec()
866}
867
868fn id_from(bytes: &[u8]) -> u128 {
869 let mut buf = [0u8; 16];
870 buf.copy_from_slice(bytes);
871 u128::from_be_bytes(buf)
872}
873
874fn to_i64(units: CostUnits, what: &str) -> Result<i64, StoreError> {
875 i64::try_from(units.get()).map_err(|_| StoreError(format!("{what} exceeds i64 range")))
876}
877
878fn stored_fence(value: i64) -> Result<FencingToken, StoreError> {
881 u64::try_from(value)
882 .ok()
883 .filter(|value| *value > 0)
884 .map(FencingToken)
885 .ok_or_else(|| StoreError(format!("stored fencing token is not positive: {value}")))
886}
887
888fn to_units(value: i64, what: &str) -> Result<CostUnits, StoreError> {
892 u64::try_from(value)
893 .map(CostUnits)
894 .map_err(|_| StoreError(format!("{what} is negative in storage: {value}")))
895}
896
897fn ts_micros(ts: Timestamp) -> i64 {
898 ts.as_microsecond()
899}
900
901fn micros_ts(value: i64, what: &str) -> Result<Timestamp, StoreError> {
904 tollgate_store::clock::timestamp_from_micros(value).map_err(|e| {
905 StoreError(format!(
906 "{what} is not a representable instant: {value} ({e})"
907 ))
908 })
909}
910
911fn storage(e: sqlx::Error) -> StoreError {
912 StoreError(format!("postgres: {e}"))
913}
914
915fn alloc_storage(e: sqlx::Error) -> AllocateError {
916 AllocateError::Storage(storage(e))
917}
918
919async fn finish_transaction<T, E>(
932 tx: Transaction<'_, Postgres>,
933 result: Result<T, E>,
934) -> Result<T, E>
935where
936 E: From<StoreError> + std::fmt::Display,
937{
938 match result {
939 Ok(value) => {
940 tx.commit().await.map_err(|e| E::from(storage(e)))?;
941 Ok(value)
942 }
943 Err(error) => {
944 if let Err(rollback_error) = tx.rollback().await {
945 return Err(E::from(StoreError(format!(
946 "operation failed ({error}); transaction rollback failed ({})",
947 storage(rollback_error)
948 ))));
949 }
950 Err(error)
951 }
952 }
953}
954
955pub struct PostgresStore {
992 pool: PgPool,
993 policy: GrantPolicy,
994 push: broadcast::Sender<SnapshotPush>,
995}
996
997#[derive(Debug, Clone, Copy)]
1002pub struct PoolConfig {
1003 pub max_connections: u32,
1004 pub acquire_timeout: std::time::Duration,
1007}
1008
1009impl Default for PoolConfig {
1010 fn default() -> Self {
1011 PoolConfig {
1012 max_connections: 16,
1013 acquire_timeout: std::time::Duration::from_secs(5),
1014 }
1015 }
1016}
1017
1018impl PoolConfig {
1019 pub fn validate(&self) -> Result<(), StoreError> {
1020 if self.max_connections == 0 {
1021 return Err(StoreError("max_connections must be positive".into()));
1022 }
1023 if self.acquire_timeout.is_zero() {
1024 return Err(StoreError("acquire_timeout must be positive".into()));
1025 }
1026 Ok(())
1027 }
1028}
1029
1030impl PostgresStore {
1031 pub async fn connect(url: &str, policy: GrantPolicy) -> Result<Arc<Self>, StoreError> {
1033 Self::connect_with(url, policy, PoolConfig::default()).await
1034 }
1035
1036 pub async fn connect_with(
1045 url: &str,
1046 policy: GrantPolicy,
1047 pool_config: PoolConfig,
1048 ) -> Result<Arc<Self>, StoreError> {
1049 policy
1050 .validate()
1051 .map_err(|e| StoreError(format!("invalid grant policy: {e}")))?;
1052 pool_config.validate()?;
1053 let pool = PgPoolOptions::new()
1054 .max_connections(pool_config.max_connections)
1055 .acquire_timeout(pool_config.acquire_timeout)
1056 .connect(url)
1057 .await
1058 .map_err(storage)?;
1059 sqlx::migrate!("./migrations")
1060 .run(&pool)
1061 .await
1062 .map_err(|e| StoreError(format!("migrate: {e}")))?;
1063 let (push, _) = broadcast::channel(PUSH_CHANNEL_CAPACITY);
1064 Ok(Arc::new(PostgresStore { pool, policy, push }))
1065 }
1066
1067 fn push_to_subscribers(&self, push: SnapshotPush) {
1073 let principal = push.principal;
1074 let subscribers = self.push.send(push).unwrap_or(0);
1075 tracing::debug!(
1076 %principal,
1077 subscribers,
1078 "snapshot pushed to subscribers"
1079 );
1080 }
1081
1082 fn announce_removal(&self, principal: Principal, receipt: &AdminReceipt<()>) {
1084 if receipt.before != receipt.after
1085 && let AdminState::Snapshot { generation, .. } = receipt.after
1086 {
1087 self.push_to_subscribers(SnapshotPush {
1088 principal,
1089 resolution: SnapshotResolution::Revoked { generation },
1090 });
1091 }
1092 }
1093
1094 pub async fn balance(&self, account: AccountId) -> Result<CostUnits, StoreError> {
1097 let row = sqlx::query("SELECT balance FROM tollgate_accounts WHERE account_id = $1")
1098 .bind(id_bytes(account.0))
1099 .fetch_optional(&self.pool)
1100 .await
1101 .map_err(storage)?;
1102 row.map(|r| to_units(r.get::<i64, _>(0), "account balance"))
1103 .transpose()
1104 .map(|units| units.unwrap_or(CostUnits::ZERO))
1105 }
1106
1107 pub async fn usage_recorded(&self, account: AccountId) -> Result<CostUnits, StoreError> {
1108 let row = sqlx::query("SELECT usage_recorded FROM tollgate_accounts WHERE account_id = $1")
1109 .bind(id_bytes(account.0))
1110 .fetch_optional(&self.pool)
1111 .await
1112 .map_err(storage)?;
1113 row.map(|r| to_units(r.get::<i64, _>(0), "account usage_recorded"))
1114 .transpose()
1115 .map(|units| units.unwrap_or(CostUnits::ZERO))
1116 }
1117
1118 pub async fn conservation(
1119 &self,
1120 account: AccountId,
1121 ) -> Result<Option<Conservation>, StoreError> {
1122 let mut tx = self.pool.begin().await.map_err(storage)?;
1136 let result = async {
1137 sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
1138 .execute(&mut *tx)
1139 .await
1140 .map_err(storage)?;
1141 let account_row = sqlx::query(
1142 "SELECT deposited, balance, usage_recorded, settlement_loss, overage_recorded,
1143 expired
1144 FROM tollgate_accounts WHERE account_id = $1",
1145 )
1146 .bind(id_bytes(account.0))
1147 .fetch_optional(&mut *tx)
1148 .await
1149 .map_err(storage)?;
1150 let lease_row = sqlx::query(ACTIVE_LEASE_SUM_SQL)
1151 .bind(id_bytes(account.0))
1152 .fetch_one(&mut *tx)
1153 .await
1154 .map_err(storage)?;
1155 Ok::<_, StoreError>((account_row, lease_row))
1156 }
1157 .await;
1158 let (account_row, lease_row) = finish_transaction(tx, result).await?;
1159
1160 let Some(row) = account_row else {
1161 return Ok(None);
1162 };
1163 let active_grants = to_units(lease_row.get::<i64, _>(0), "active lease grants")?;
1164 let active_used = to_units(lease_row.get::<i64, _>(1), "active lease usage")?;
1165 let recorded = to_units(row.get::<i64, _>(2), "usage_recorded")?;
1166 Ok(Some(Conservation {
1167 deposited: to_units(row.get::<i64, _>(0), "deposited")?,
1168 overage_recorded: to_units(row.get::<i64, _>(4), "overage_recorded")?,
1169 balance: to_units(row.get::<i64, _>(1), "balance")?,
1170 active_lease_grants: active_grants,
1171 settled_usage: recorded.checked_sub(active_used).ok_or_else(|| {
1179 StoreError(format!(
1180 "active lease usage {} exceeds recorded usage {} for account {account}",
1181 active_used.get(),
1182 recorded.get()
1183 ))
1184 })?,
1185 settlement_loss: to_units(row.get::<i64, _>(3), "settlement_loss")?,
1186 expired: to_units(row.get::<i64, _>(5), "expired")?,
1187 }))
1188 }
1189}
1190
1191struct LockedLeaseRow {
1195 account_id: Vec<u8>,
1196 fencing_token: i64,
1197 granted: i64,
1198 used: i64,
1199 credited: i64,
1200 expires_at: StoredInstant,
1201 state: i16,
1202 from_allowance: i64,
1207 period_start_us: i64,
1208}
1209
1210struct ReleasedCredit {
1213 account: AccountId,
1214 restored: CostUnits,
1215 preserves_funding: bool,
1216}
1217
1218async fn lock_lease(
1220 tx: &mut Transaction<'_, Postgres>,
1221 lease_id: LeaseId,
1222) -> Result<Option<LockedLeaseRow>, sqlx::Error> {
1223 let row = sqlx::query(
1224 "SELECT account_id, fencing_token, granted, used, credited, expires_at_floor_us, state,
1225 from_allowance, period_start_us, expires_at_submicro_ns
1226 FROM tollgate_leases WHERE lease_id = $1 FOR UPDATE",
1227 )
1228 .bind(id_bytes(lease_id.0))
1229 .fetch_optional(&mut **tx)
1230 .await?;
1231 Ok(row.map(|row| LockedLeaseRow {
1232 account_id: row.get(0),
1233 fencing_token: row.get(1),
1234 granted: row.get(2),
1235 used: row.get(3),
1236 credited: row.get(4),
1237 expires_at: StoredInstant {
1238 micros: row.get(5),
1239 submicro_nanos: row.get(9),
1240 },
1241 state: row.get(6),
1242 from_allowance: row.get(7),
1243 period_start_us: row.get(8),
1244 }))
1245}
1246
1247struct Exchange {
1250 floor: CostUnits,
1254 needed: CostUnits,
1257 preserves_funding: bool,
1260}
1261
1262impl Exchange {
1263 const ACQUIRE: Exchange = Exchange {
1264 floor: CostUnits::ZERO,
1265 needed: CostUnits::ZERO,
1266 preserves_funding: true,
1267 };
1268}
1269
1270impl PostgresStore {
1271 async fn acquire_in_tx(
1273 &self,
1274 tx: &mut Transaction<'_, Postgres>,
1275 account: AccountId,
1276 requested: CostUnits,
1277 expires_at: Timestamp,
1278 exchange: Exchange,
1279 ) -> Result<Allocation, AllocateError> {
1280 let Exchange {
1281 floor,
1282 needed,
1283 preserves_funding: settlement_preserves_funding,
1284 } = exchange;
1285 let row = sqlx::query(
1286 "SELECT balance, status, next_fence, allowance_balance, period_start_us
1287 FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
1288 )
1289 .bind(id_bytes(account.0))
1290 .fetch_optional(&mut **tx)
1291 .await
1292 .map_err(alloc_storage)?
1293 .ok_or(AllocateError::UnknownAccount)?;
1294
1295 if decode_status(row.get::<String, _>(1)).map_err(AllocateError::Storage)?
1299 != AccountStatus::Active
1300 {
1301 return Err(AllocateError::AccountInactive);
1302 }
1303 let balance =
1304 to_units(row.get::<i64, _>(0), "account balance").map_err(AllocateError::Storage)?;
1305 let granted = match self
1311 .policy
1312 .consolidation_grant(requested, balance, floor, needed)
1313 {
1314 Some(granted) => granted,
1315 None => {
1316 if settlement_preserves_funding && !requested.is_zero() {
1319 let budget = sqlx::query(BUDGET_VIEW_SQL)
1320 .bind(id_bytes(account.0))
1321 .fetch_one(&mut **tx)
1322 .await
1323 .map_err(alloc_storage)?;
1324 let evidence = budget_view(&budget)
1325 .map_err(AllocateError::Storage)?
1326 .shortfall();
1327 return Err(match evidence.exhaustion() {
1328 Some(exhausted) => AllocateError::BalanceExhausted(exhausted),
1329 None => AllocateError::BalanceInsufficient(evidence),
1330 });
1331 }
1332 return Err(AllocateError::InsufficientBalance);
1333 }
1334 };
1335 let granted_i = to_i64(granted, "grant").map_err(AllocateError::Storage)?;
1340 let allowance_balance = row.get::<i64, _>(3);
1341 let from_allowance = granted_i.min(allowance_balance);
1342 let period_start_us = row.get::<i64, _>(4);
1343 let fence = row.get::<i64, _>(2);
1344 let fence_token = stored_fence(fence).map_err(AllocateError::Storage)?;
1345
1346 let lease_id = LeaseId(uuid::Uuid::new_v4().as_u128());
1347
1348 let budget = sqlx::query(GRANT_DEBIT_SQL)
1352 .bind(id_bytes(account.0))
1353 .bind(granted_i)
1354 .bind(from_allowance)
1355 .fetch_one(&mut **tx)
1356 .await
1357 .map_err(alloc_storage)?;
1358 let funding = budget_view(&budget)
1359 .map_err(AllocateError::Storage)?
1360 .shortfall();
1361 sqlx::query(
1362 "INSERT INTO tollgate_leases
1363 (lease_id, account_id, fencing_token, granted, used, credited, expires_at_floor_us,
1364 state, from_allowance, period_start_us, expires_at_submicro_ns, expiry_is_upper_bound)
1365 VALUES ($1, $2, $3, $4, 0, 0, $5, 0, $6, $7, $8, FALSE)",
1366 )
1367 .bind(id_bytes(lease_id.0))
1368 .bind(id_bytes(account.0))
1369 .bind(fence)
1370 .bind(granted_i)
1371 .bind(StoredInstant::from(expires_at).micros)
1372 .bind(from_allowance)
1373 .bind(period_start_us)
1374 .bind(StoredInstant::from(expires_at).submicro_nanos)
1375 .execute(&mut **tx)
1376 .await
1377 .map_err(alloc_storage)?;
1378
1379 Ok(Allocation {
1380 grant: LeaseGrant {
1381 lease_id,
1382 account_id: account,
1383 fencing_token: fence_token,
1384 units: granted,
1385 expires_at,
1386 },
1387 funding: Some(funding),
1388 })
1389 }
1390
1391 async fn release_in_tx(
1394 &self,
1395 tx: &mut Transaction<'_, Postgres>,
1396 lease_id: LeaseId,
1397 fencing_token: FencingToken,
1398 unspent: CostUnits,
1399 now: Timestamp,
1400 ) -> Result<ReleasedCredit, AllocateError> {
1401 let LockedLeaseRow {
1402 account_id,
1403 fencing_token: fence,
1404 granted,
1405 used,
1406 credited: _credited,
1407 expires_at,
1408 state,
1409 from_allowance,
1410 period_start_us,
1411 } = lock_lease(tx, lease_id)
1412 .await
1413 .map_err(alloc_storage)?
1414 .ok_or(AllocateError::UnknownLease)?;
1415
1416 if stored_fence(fence).map_err(AllocateError::Storage)? != fencing_token {
1417 return Err(AllocateError::Fenced);
1418 }
1419 let expires_at = expires_at.timestamp().map_err(AllocateError::Storage)?;
1422 if state != STATE_ACTIVE
1423 || self
1424 .policy
1425 .reclaim_cutoff(now)
1426 .is_some_and(|cutoff| expires_at <= cutoff)
1427 {
1428 return Err(AllocateError::LeaseNotActive);
1429 }
1430 let account = AccountId(id_from(&account_id));
1434 let unspent_i = to_i64(unspent, "unspent").map_err(AllocateError::Storage)?;
1435 let loss = granted
1436 .checked_sub(
1437 used.checked_add(unspent_i)
1438 .ok_or(AllocateError::InvalidRelease)?,
1439 )
1440 .filter(|l| *l >= 0)
1441 .ok_or(AllocateError::InvalidRelease)?;
1442
1443 sqlx::query("UPDATE tollgate_leases SET state = $2, credited = $3 WHERE lease_id = $1")
1444 .bind(id_bytes(lease_id.0))
1445 .bind(STATE_RELEASED)
1446 .bind(unspent_i)
1447 .execute(&mut **tx)
1448 .await
1449 .map_err(alloc_storage)?;
1450 let from_topup = granted
1456 .checked_sub(from_allowance)
1457 .filter(|t| *t >= 0)
1458 .ok_or_else(|| {
1459 AllocateError::Storage(StoreError(format!(
1460 "lease allowance funding {from_allowance} exceeds its grant {granted}"
1461 )))
1462 })?;
1463 let to_topup = from_topup.min(unspent_i);
1464 let to_allowance = unspent_i - to_topup;
1465
1466 let restored: i64 = sqlx::query_scalar(
1473 "UPDATE tollgate_accounts SET
1474 balance = balance + $2
1475 + CASE WHEN period_start_us > $5 THEN 0 ELSE $3 END,
1476 allowance_balance = allowance_balance
1477 + CASE WHEN period_start_us > $5 THEN 0 ELSE $3 END,
1478 expired = expired + CASE WHEN period_start_us > $5 THEN $3 ELSE 0 END,
1479 settlement_loss = settlement_loss + $4
1480 WHERE account_id = $1
1481 RETURNING $2 + CASE WHEN period_start_us > $5 THEN 0 ELSE $3 END",
1482 )
1483 .bind(account_id)
1484 .bind(to_topup)
1485 .bind(to_allowance)
1486 .bind(loss)
1487 .bind(period_start_us)
1488 .fetch_one(&mut **tx)
1489 .await
1490 .map_err(alloc_storage)?;
1491 Ok(ReleasedCredit {
1492 account,
1493 preserves_funding: loss == 0 && restored == unspent_i,
1494 restored: to_units(restored, "restored release credit")
1495 .map_err(AllocateError::Storage)?,
1496 })
1497 }
1498
1499 fn grant_expiry(
1503 &self,
1504 ttl: SignedDuration,
1505 now: Timestamp,
1506 ) -> Result<Timestamp, AllocateError> {
1507 if ttl <= SignedDuration::ZERO {
1508 return Err(AllocateError::InvalidTtl);
1509 }
1510 now.checked_add(ttl.min(self.policy.max_ttl))
1511 .map_err(|e| AllocateError::Storage(StoreError(format!("ttl overflow: {e}"))))
1512 }
1513}
1514
1515#[async_trait]
1516impl LeaseAllocator for PostgresStore {
1517 async fn acquire(
1518 &self,
1519 account: AccountId,
1520 requested: CostUnits,
1521 ttl: SignedDuration,
1522 now: Timestamp,
1523 ) -> Result<Allocation, AllocateError> {
1524 let expires_at = self.grant_expiry(ttl, now)?;
1525 let mut tx = self.pool.begin().await.map_err(alloc_storage)?;
1526 let result = self
1527 .acquire_in_tx(&mut tx, account, requested, expires_at, Exchange::ACQUIRE)
1528 .await;
1529 finish_transaction(tx, result).await
1530 }
1531
1532 async fn release(
1533 &self,
1534 lease_id: LeaseId,
1535 fencing_token: FencingToken,
1536 unspent: CostUnits,
1537 now: Timestamp,
1538 ) -> Result<(), AllocateError> {
1539 let mut tx = self.pool.begin().await.map_err(alloc_storage)?;
1540 let result = self
1541 .release_in_tx(&mut tx, lease_id, fencing_token, unspent, now)
1542 .await
1543 .map(|_| ());
1544 finish_transaction(tx, result).await
1545 }
1546
1547 async fn consolidate(
1548 &self,
1549 lease_id: LeaseId,
1550 fencing_token: FencingToken,
1551 unspent: CostUnits,
1552 requested: CostUnits,
1553 needed: CostUnits,
1554 ttl: SignedDuration,
1555 now: Timestamp,
1556 ) -> Result<Allocation, AllocateError> {
1557 let expires_at = self.grant_expiry(ttl, now)?;
1558 let mut tx = self.pool.begin().await.map_err(alloc_storage)?;
1559 let result = async {
1568 let released = self
1569 .release_in_tx(&mut tx, lease_id, fencing_token, unspent, now)
1570 .await?;
1571 self.acquire_in_tx(
1572 &mut tx,
1573 released.account,
1574 requested,
1575 expires_at,
1576 Exchange {
1577 floor: released.restored,
1578 needed,
1579 preserves_funding: released.preserves_funding,
1580 },
1581 )
1582 .await
1583 }
1584 .await;
1585 finish_transaction(tx, result).await
1586 }
1587
1588 async fn reclaim_expired_batch(
1589 &self,
1590 now: Timestamp,
1591 limit: NonZeroUsize,
1592 ) -> Result<ReclaimBatch, StoreError> {
1593 let limit_i = i64::try_from(limit.get())
1594 .map_err(|_| StoreError(format!("reclaim batch limit exceeds i64 range: {limit}")))?;
1595 let Some(cutoff) = self.policy.reclaim_cutoff(now) else {
1596 return ReclaimBatch::try_new(Vec::new(), limit);
1597 };
1598 let cutoff = StoredInstant::from(cutoff);
1599 let mut tx = self.pool.begin().await.map_err(storage)?;
1600 let result = async {
1601 let rows = sqlx::query(RECLAIM_DUE_LEASES_SQL)
1607 .bind(cutoff.micros)
1608 .bind(cutoff.submicro_nanos)
1609 .bind(limit_i)
1610 .fetch_all(&mut *tx)
1611 .await
1612 .map_err(storage)?;
1613
1614 let mut reclaimed = Vec::with_capacity(rows.len());
1619 let mut lease_ids = Vec::with_capacity(rows.len());
1620 let mut forfeits: std::collections::BTreeMap<Vec<u8>, i64> =
1621 std::collections::BTreeMap::new();
1622 for row in rows {
1623 let lease_bytes: Vec<u8> = row.get(0);
1624 let account_bytes: Vec<u8> = row.get(1);
1625 let granted = row.get::<i64, _>(2);
1626 let used = row.get::<i64, _>(3);
1627 let forfeited = granted.checked_sub(used).ok_or_else(|| {
1631 StoreError(format!(
1632 "reclaim remainder overflow: granted {granted}, used {used}"
1633 ))
1634 })?;
1635 let forfeited_units = to_units(forfeited, "reclaim remainder")?;
1636 let total = forfeits.entry(account_bytes.clone()).or_default();
1637 *total = total
1638 .checked_add(forfeited)
1639 .ok_or_else(|| StoreError("reclaim loss sum overflow".into()))?;
1640 lease_ids.push(lease_bytes.clone());
1641 reclaimed.push(ReclaimedLease {
1642 lease_id: LeaseId(id_from(&lease_bytes)),
1643 account_id: AccountId(id_from(&account_bytes)),
1644 forfeited: forfeited_units,
1645 });
1646 }
1647
1648 let batch = ReclaimBatch::try_new(reclaimed, limit)?;
1649 if batch.is_empty() {
1650 return Ok(batch);
1651 }
1652
1653 let (account_ids, account_forfeits): (Vec<_>, Vec<_>) = forfeits.into_iter().unzip();
1654 let expected_lease_rows = u64::try_from(lease_ids.len())
1655 .map_err(|_| StoreError("reclaim lease row count exceeds u64 range".into()))?;
1656 let expected_account_rows = u64::try_from(account_ids.len())
1657 .map_err(|_| StoreError("reclaim account row count exceeds u64 range".into()))?;
1658
1659 let locked_accounts = sqlx::query(
1665 "SELECT account_id FROM tollgate_accounts
1666 WHERE account_id = ANY($1) ORDER BY account_id FOR UPDATE",
1667 )
1668 .bind(&account_ids)
1669 .fetch_all(&mut *tx)
1670 .await
1671 .map_err(storage)?;
1672 if locked_accounts.len() != account_ids.len() {
1673 return Err(StoreError(format!(
1674 "reclaim locked {} of {} referenced account rows",
1675 locked_accounts.len(),
1676 account_ids.len()
1677 )));
1678 }
1679
1680 let updated_leases = sqlx::query(
1681 "UPDATE tollgate_leases
1682 SET state = $2, credited = 0
1683 WHERE lease_id = ANY($1) AND state = $3",
1684 )
1685 .bind(&lease_ids)
1686 .bind(STATE_EXPIRED)
1687 .bind(STATE_ACTIVE)
1688 .execute(&mut *tx)
1689 .await
1690 .map_err(storage)?;
1691 if updated_leases.rows_affected() != expected_lease_rows {
1692 return Err(StoreError(format!(
1693 "reclaim updated {} of {} locked lease rows",
1694 updated_leases.rows_affected(),
1695 lease_ids.len()
1696 )));
1697 }
1698
1699 let updated_accounts = sqlx::query(
1700 "UPDATE tollgate_accounts AS account
1701 SET settlement_loss = account.settlement_loss + delta.forfeited
1702 FROM UNNEST($1::bytea[], $2::bigint[]) AS delta(account_id, forfeited)
1703 WHERE account.account_id = delta.account_id",
1704 )
1705 .bind(&account_ids)
1706 .bind(&account_forfeits)
1707 .execute(&mut *tx)
1708 .await
1709 .map_err(storage)?;
1710 if updated_accounts.rows_affected() != expected_account_rows {
1711 return Err(StoreError(format!(
1712 "reclaim updated {} of {} locked account rows",
1713 updated_accounts.rows_affected(),
1714 account_ids.len()
1715 )));
1716 }
1717
1718 Ok(batch)
1719 }
1720 .await;
1721 finish_transaction(tx, result).await
1722 }
1723}
1724
1725#[async_trait]
1726impl UsageSink for PostgresStore {
1727 async fn ingest(
1728 &self,
1729 events: &[UsageEvent],
1730 _now: Timestamp,
1731 ) -> Result<IngestReport, IngestError> {
1732 let mut report = IngestReport {
1740 unattributed: Some(0),
1741 ..IngestReport::default()
1742 };
1743 if events.is_empty() {
1744 return Ok(report);
1745 }
1746
1747 struct PreparedEvent<'a> {
1753 event: &'a UsageEvent,
1754 request_id: Vec<u8>,
1755 account_id: Vec<u8>,
1756 key_id: Option<Vec<u8>>,
1757 lease_id: Option<Vec<u8>>,
1761 occurred_at_us: i64,
1762 }
1763 let prepared: Vec<PreparedEvent<'_>> = events
1764 .iter()
1765 .map(|event| {
1766 Ok(PreparedEvent {
1767 event,
1768 request_id: id_bytes(event.request_id.0),
1769 account_id: id_bytes(event.account_id.0),
1770 key_id: event.key_id.map(|id| id_bytes(id.0)),
1771 lease_id: event.source.lease_id().map(|id| id_bytes(id.0)),
1772 occurred_at_us: ts_micros(event.occurred_at),
1773 })
1774 })
1775 .collect::<Result<_, StoreError>>()?;
1776
1777 let mut tx = self.pool.begin().await.map_err(storage)?;
1778 let result: Result<_, IngestError> = async {
1779 let lease_ids: Vec<Vec<u8>> = prepared
1788 .iter()
1789 .filter_map(|event| event.lease_id.clone())
1790 .collect::<std::collections::BTreeSet<_>>()
1791 .into_iter()
1792 .collect();
1793 struct LeaseRow {
1794 account_id: Vec<u8>,
1795 fence: i64,
1796 granted: i64,
1797 used: i64,
1798 used_delta: Option<NonZeroI64>,
1799 credited: i64,
1800 settled: bool,
1801 }
1802 let rows = sqlx::query(
1803 "SELECT lease_id, account_id, fencing_token, granted, used, credited, state
1804 FROM tollgate_leases
1805 WHERE lease_id = ANY($1)
1806 ORDER BY account_id, lease_id FOR UPDATE",
1807 )
1808 .bind(&lease_ids)
1809 .fetch_all(&mut *tx)
1810 .await
1811 .map_err(storage)?;
1812 let mut leases: std::collections::BTreeMap<Vec<u8>, LeaseRow> =
1813 std::collections::BTreeMap::new();
1814 for row in rows {
1815 let lease_id: Vec<u8> = row.get(0);
1816 let fence: i64 = row.get(2);
1817 let granted: i64 = row.get(3);
1818 let used: i64 = row.get(4);
1819 let credited: i64 = row.get(5);
1820 leases.insert(
1821 lease_id,
1822 LeaseRow {
1823 account_id: row.get(1),
1824 fence,
1825 granted,
1826 used,
1827 used_delta: None,
1828 credited,
1829 settled: row.get::<i16, _>(6) != STATE_ACTIVE,
1830 },
1831 );
1832 }
1833
1834 let request_ids: Vec<Vec<u8>> = prepared
1836 .iter()
1837 .map(|event| event.request_id.clone())
1838 .collect();
1839 let mut seen: std::collections::HashSet<Vec<u8>> = sqlx::query(
1840 "SELECT request_id FROM tollgate_usage_events WHERE request_id = ANY($1)",
1841 )
1842 .bind(&request_ids)
1843 .fetch_all(&mut *tx)
1844 .await
1845 .map_err(storage)?
1846 .into_iter()
1847 .map(|row| row.get::<Vec<u8>, _>(0))
1848 .collect();
1849
1850 let overage_account_ids: Vec<Vec<u8>> = prepared
1866 .iter()
1867 .filter(|event| event.lease_id.is_none())
1868 .map(|event| event.account_id.clone())
1869 .collect::<std::collections::BTreeSet<_>>()
1870 .into_iter()
1871 .collect();
1872 let known_overage_accounts: std::collections::HashSet<Vec<u8>> =
1873 if overage_account_ids.is_empty() {
1874 std::collections::HashSet::new()
1875 } else {
1876 sqlx::query(
1877 "SELECT account_id FROM tollgate_accounts WHERE account_id = ANY($1)",
1878 )
1879 .bind(&overage_account_ids)
1880 .fetch_all(&mut *tx)
1881 .await
1882 .map_err(storage)?
1883 .into_iter()
1884 .map(|row| row.get::<Vec<u8>, _>(0))
1885 .collect()
1886 };
1887
1888 struct Accepted {
1892 event_index: usize,
1893 settled: bool,
1894 fence: Option<i64>,
1899 overage: bool,
1902 units: i64,
1905 }
1906 let mut accepted: Vec<Accepted> = Vec::with_capacity(prepared.len());
1907 for (event_index, event) in prepared.iter().enumerate() {
1908 if seen.contains(event.request_id.as_slice()) {
1909 report.duplicate += 1;
1910 continue;
1911 }
1912 let Some(lease_key) = event.lease_id.as_deref() else {
1913 if !known_overage_accounts.contains(event.account_id.as_slice()) {
1922 report.rejected += 1;
1923 continue;
1924 }
1925 let Ok(units) = i64::try_from(event.event.units.get()) else {
1926 report.rejected += 1;
1929 continue;
1930 };
1931 seen.insert(event.request_id.clone());
1932 accepted.push(Accepted {
1933 event_index,
1934 settled: false,
1938 fence: None,
1939 overage: true,
1940 units,
1941 });
1942 report.accepted += 1;
1943 continue;
1944 };
1945 let Some(lease) = leases.get_mut(lease_key) else {
1946 report.rejected += 1;
1947 continue;
1948 };
1949 if Some(stored_fence(lease.fence)?) != event.event.source.fencing_token()
1950 || lease.account_id.as_slice() != event.account_id.as_slice()
1951 {
1952 report.rejected += 1;
1953 continue;
1954 }
1955 to_units(lease.granted, "lease granted")?;
1956 to_units(lease.used, "lease used")?;
1957 to_units(lease.credited, "lease credited")?;
1958 let Ok(units) = i64::try_from(event.event.units.get()) else {
1959 report.rejected += 1;
1960 continue;
1961 };
1962 let committed = lease
1963 .used
1964 .checked_add(lease.used_delta.map_or(0, NonZeroI64::get))
1965 .and_then(|used| used.checked_add(lease.credited))
1966 .ok_or_else(|| {
1967 StoreError(format!(
1968 "lease accounting overflow for {:#034x}",
1969 id_from(lease_key)
1970 ))
1971 })?;
1972 let remaining = lease.granted.checked_sub(committed).ok_or_else(|| {
1973 StoreError(format!(
1974 "lease accounting exceeds grant for {:#034x}: granted {}, committed {committed}",
1975 id_from(lease_key), lease.granted
1976 ))
1977 })?;
1978 if units > remaining {
1979 report.rejected += 1;
1980 continue;
1981 }
1982 let used_delta = lease
1983 .used_delta
1984 .map_or(0, NonZeroI64::get)
1985 .checked_add(units)
1986 .ok_or_else(|| {
1987 StoreError(format!(
1988 "lease usage delta overflow for {:#034x}",
1989 id_from(lease_key)
1990 ))
1991 })?;
1992 lease.used_delta = NonZeroI64::new(used_delta);
1993 seen.insert(event.request_id.clone());
1994 accepted.push(Accepted {
1995 event_index,
1996 settled: lease.settled,
1997 fence: Some(lease.fence),
1998 overage: false,
1999 units,
2000 });
2001 report.accepted += 1;
2002 }
2003
2004 if accepted.is_empty() {
2005 return Ok(report);
2006 }
2007
2008 #[derive(Default)]
2009 struct AccountDelta {
2010 usage: i64,
2011 loss: i64,
2012 overage: i64,
2016 }
2017
2018 let (mut rid, mut acct, mut lease, mut fence, mut units, mut at, mut revision, mut keys) = (
2020 Vec::with_capacity(accepted.len()),
2021 Vec::with_capacity(accepted.len()),
2022 Vec::with_capacity(accepted.len()),
2023 Vec::with_capacity(accepted.len()),
2024 Vec::with_capacity(accepted.len()),
2025 Vec::with_capacity(accepted.len()),
2026 Vec::with_capacity(accepted.len()),
2027 Vec::with_capacity(accepted.len()),
2028 );
2029 let mut account_deltas: std::collections::BTreeMap<Vec<u8>, AccountDelta> =
2030 std::collections::BTreeMap::new();
2031 for accepted_event in &accepted {
2032 let event = &prepared[accepted_event.event_index];
2033 rid.push(event.request_id.clone());
2034 acct.push(event.account_id.clone());
2035 keys.push(event.key_id.clone());
2036 lease.push(event.lease_id.clone());
2037 fence.push(accepted_event.fence);
2038 units.push(accepted_event.units);
2039 at.push(event.occurred_at_us);
2040 revision.push(event.event.policy_revision.as_bytes().to_vec());
2045 if accepted_event.overage {
2046 debug_assert!(
2047 event.lease_id.is_none() && accepted_event.fence.is_none(),
2048 "an overage row must carry neither half of a capability"
2049 );
2050 }
2051
2052 let entry = account_deltas.entry(event.account_id.clone()).or_default();
2053 entry.usage = entry.usage.checked_add(accepted_event.units).ok_or_else(|| {
2054 IngestError::Refused(StoreError(format!(
2055 "usage delta overflow for account {:#034x}",
2056 event.event.account_id.0
2057 )))
2058 })?;
2059 if accepted_event.settled {
2060 entry.loss = entry.loss.checked_add(accepted_event.units).ok_or_else(|| {
2061 IngestError::Refused(StoreError(format!(
2062 "settlement loss delta overflow for account {:#034x}",
2063 event.event.account_id.0
2064 )))
2065 })?;
2066 }
2067 if accepted_event.overage {
2068 entry.overage =
2069 entry.overage.checked_add(accepted_event.units).ok_or_else(|| {
2070 IngestError::Refused(StoreError(format!(
2071 "overage delta overflow for account {:#034x}",
2072 event.event.account_id.0
2073 )))
2074 })?;
2075 }
2076 }
2077 let inserted = sqlx::query(
2078 "INSERT INTO tollgate_usage_events
2079 (request_id, account_id, lease_id, fencing_token, units, occurred_at_us, policy_revision, key_id)
2080 SELECT * FROM UNNEST($1::bytea[], $2::bytea[], $3::bytea[], $4::bigint[], $5::bigint[], $6::bigint[], $7::bytea[], $8::bytea[])",
2081 )
2082 .bind(&rid)
2083 .bind(&acct)
2084 .bind(&lease)
2085 .bind(&fence)
2086 .bind(&units)
2087 .bind(&at)
2088 .bind(&revision)
2089 .bind(&keys)
2090 .execute(&mut *tx)
2091 .await
2092 .map_err(storage)?;
2093 let expected_event_rows = u64::try_from(accepted.len())
2094 .map_err(|_| StoreError("accepted event count exceeds u64 range".into()))?;
2095 if inserted.rows_affected() != expected_event_rows {
2096 return Err(IngestError::Unavailable(StoreError(format!(
2097 "ingest inserted {} of {} accepted usage rows",
2098 inserted.rows_affected(),
2099 accepted.len()
2100 ))));
2101 }
2102
2103 let (lease_update_ids, lease_used_deltas): (Vec<Vec<u8>>, Vec<i64>) = leases
2107 .iter()
2108 .filter_map(|(lease_id, row)| {
2109 row.used_delta
2110 .map(|used_delta| (lease_id.clone(), used_delta.get()))
2111 })
2112 .unzip();
2113 if !lease_update_ids.is_empty() {
2114 let updated_leases = sqlx::query(
2115 "UPDATE tollgate_leases AS lease
2116 SET used = lease.used + delta.used
2117 FROM UNNEST($1::bytea[], $2::bigint[]) AS delta(lease_id, used)
2118 WHERE lease.lease_id = delta.lease_id",
2119 )
2120 .bind(&lease_update_ids)
2121 .bind(&lease_used_deltas)
2122 .execute(&mut *tx)
2123 .await
2124 .map_err(storage)?;
2125 let expected_lease_rows = u64::try_from(lease_update_ids.len())
2126 .map_err(|_| StoreError("ingest lease row count exceeds u64 range".into()))?;
2127 if updated_leases.rows_affected() != expected_lease_rows {
2128 return Err(IngestError::Unavailable(StoreError(format!(
2129 "ingest updated {} of {} locked lease rows",
2130 updated_leases.rows_affected(),
2131 lease_update_ids.len()
2132 ))));
2133 }
2134 }
2135
2136 let mut account_ids = Vec::with_capacity(account_deltas.len());
2137 let mut account_usage_deltas = Vec::with_capacity(account_deltas.len());
2138 let mut account_loss_deltas = Vec::with_capacity(account_deltas.len());
2139 let mut account_overage_deltas = Vec::with_capacity(account_deltas.len());
2140 for (account_id, delta) in &account_deltas {
2141 account_ids.push(account_id.clone());
2142 account_usage_deltas.push(delta.usage);
2143 account_loss_deltas.push(delta.loss);
2144 account_overage_deltas.push(delta.overage);
2145 }
2146
2147 let locked_accounts = sqlx::query(
2153 "SELECT account_id, usage_recorded, settlement_loss, overage_recorded
2154 FROM tollgate_accounts
2155 WHERE account_id = ANY($1)
2156 ORDER BY account_id FOR UPDATE",
2157 )
2158 .bind(&account_ids)
2159 .fetch_all(&mut *tx)
2160 .await
2161 .map_err(storage)?;
2162 if locked_accounts.len() != account_ids.len() {
2163 return Err(IngestError::Unavailable(StoreError(format!(
2164 "ingest locked {} of {} referenced account rows",
2165 locked_accounts.len(),
2166 account_ids.len()
2167 ))));
2168 }
2169
2170 for row in locked_accounts {
2175 let account_id: Vec<u8> = row.get(0);
2176 let usage_recorded: i64 = row.get(1);
2177 let settlement_loss: i64 = row.get(2);
2178 let overage_recorded: i64 = row.get(3);
2179 to_units(usage_recorded, "account usage_recorded")?;
2180 to_units(settlement_loss, "account settlement_loss")?;
2181 to_units(overage_recorded, "account overage_recorded")?;
2182 let delta = account_deltas.get(&account_id).ok_or_else(|| {
2183 StoreError(format!(
2184 "ingest locked unexpected account {:#034x}",
2185 id_from(&account_id)
2186 ))
2187 })?;
2188 usage_recorded.checked_add(delta.usage).ok_or_else(|| {
2189 IngestError::Refused(StoreError(format!(
2190 "usage_recorded overflow for account {:#034x}",
2191 id_from(&account_id)
2192 )))
2193 })?;
2194 overage_recorded.checked_add(delta.overage).ok_or_else(|| {
2198 IngestError::Refused(StoreError(format!(
2199 "overage_recorded overflow for account {:#034x}",
2200 id_from(&account_id)
2201 )))
2202 })?;
2203 if settlement_loss < delta.loss {
2204 return Err(IngestError::Unavailable(StoreError(format!(
2205 "settlement_loss underflow for account {:#034x}: settled straggler \
2206 usage {} exceeds recorded loss",
2207 id_from(&account_id),
2208 delta.loss
2209 ))));
2210 }
2211 }
2212
2213 let updated_accounts = sqlx::query(
2214 "UPDATE tollgate_accounts AS account
2215 SET usage_recorded = account.usage_recorded + delta.usage,
2216 settlement_loss = account.settlement_loss - delta.loss,
2217 overage_recorded = account.overage_recorded + delta.overage
2218 FROM UNNEST($1::bytea[], $2::bigint[], $3::bigint[], $4::bigint[])
2219 AS delta(account_id, usage, loss, overage)
2220 WHERE account.account_id = delta.account_id
2221 AND account.settlement_loss >= delta.loss",
2222 )
2223 .bind(&account_ids)
2224 .bind(&account_usage_deltas)
2225 .bind(&account_loss_deltas)
2226 .bind(&account_overage_deltas)
2227 .execute(&mut *tx)
2228 .await
2229 .map_err(storage)?;
2230 let expected_account_rows = u64::try_from(account_ids.len())
2231 .map_err(|_| StoreError("ingest account row count exceeds u64 range".into()))?;
2232 if updated_accounts.rows_affected() != expected_account_rows {
2233 return Err(IngestError::Unavailable(StoreError(format!(
2234 "ingest updated {} of {} locked account rows",
2235 updated_accounts.rows_affected(),
2236 account_ids.len()
2237 ))));
2238 }
2239
2240 if keys.iter().all(Option::is_none) {
2244 report.unattributed = Some(expected_event_rows);
2245 return Ok(report);
2246 }
2247
2248 let attributed: i64 = sqlx::query_scalar(
2253 "WITH matched AS MATERIALIZED (
2254 SELECT k.key_id, b.occurred_at_us
2255 FROM UNNEST($1::bytea[], $2::bytea[], $3::bigint[])
2256 AS b(key_id, account_id, occurred_at_us)
2257 JOIN LATERAL (
2258 SELECT key_id FROM tollgate_credential_keys
2259 WHERE key_id = b.key_id AND account_id = b.account_id LIMIT 1
2260 ) k ON true
2261 ), updated AS (
2262 INSERT INTO tollgate_credential_activity AS activity (key_id, last_committed_at_us)
2263 SELECT key_id, MAX(occurred_at_us) FROM matched GROUP BY key_id ORDER BY key_id
2264 ON CONFLICT (key_id) DO UPDATE
2265 SET last_committed_at_us = EXCLUDED.last_committed_at_us
2266 WHERE activity.last_committed_at_us < EXCLUDED.last_committed_at_us
2267 RETURNING key_id
2268 ) SELECT COUNT(*) FROM matched"
2269 ).bind(&keys).bind(&acct).bind(&at).fetch_one(&mut *tx).await.map_err(storage)?;
2270 report.unattributed = Some(expected_event_rows.checked_sub(
2271 u64::try_from(attributed).map_err(|_| StoreError("negative attribution count".into()))?
2272 ).ok_or_else(|| StoreError("attribution count exceeds accepted events".into()))?);
2273
2274 Ok(report)
2275 }
2276 .await;
2277 finish_transaction(tx, result).await
2281 }
2282}
2283
2284fn decode_status(stored: String) -> Result<AccountStatus, StoreError> {
2292 match stored.as_str() {
2293 s if s == AccountStatus::Active.as_str() => Ok(AccountStatus::Active),
2294 s if s == AccountStatus::Suspended.as_str() => Ok(AccountStatus::Suspended),
2295 s if s == AccountStatus::Closed.as_str() => Ok(AccountStatus::Closed),
2296 other => Err(StoreError(format!("unrecognized account status {other:?}"))),
2297 }
2298}
2299
2300fn decode_capacity_class(stored: String) -> Result<CapacityClass, StoreError> {
2309 match stored.as_str() {
2310 s if s == CapacityClass::Assured.as_str() => Ok(CapacityClass::Assured),
2311 s if s == CapacityClass::BestEffort.as_str() => Ok(CapacityClass::BestEffort),
2312 other => Err(StoreError(format!("unrecognized capacity class {other:?}"))),
2313 }
2314}
2315
2316fn decode_schedule(
2325 allowance: Option<i64>,
2326 period: Option<String>,
2327 rollover: Option<String>,
2328) -> Result<Option<BudgetSchedule>, StoreError> {
2329 let populated = [allowance.is_some(), period.is_some(), rollover.is_some()];
2330 let (Some(allowance), Some(period), Some(rollover)) = (allowance, period, rollover) else {
2331 if populated.iter().any(|present| *present) {
2332 return Err(StoreError(
2333 "stored budget schedule is partially populated".into(),
2334 ));
2335 }
2336 return Ok(None);
2337 };
2338 let period = match period.as_str() {
2339 s if s == Period::UtcCalendarMonth.as_str() => Period::UtcCalendarMonth,
2340 other => return Err(StoreError(format!("unrecognized budget period {other:?}"))),
2341 };
2342 let rollover = match rollover.as_str() {
2343 s if s == Rollover::None.as_str() => Rollover::None,
2344 other => {
2345 return Err(StoreError(format!(
2346 "unrecognized budget rollover {other:?}"
2347 )));
2348 }
2349 };
2350 Ok(Some(BudgetSchedule {
2351 allowance: to_units(allowance, "budget allowance")?,
2352 period,
2353 rollover,
2354 }))
2355}
2356
2357const BUDGET_VIEW_SQL: &str = "SELECT account_id, deposited, overage_recorded, usage_recorded,
2360 settlement_loss, expired, budget_allowance, budget_period,
2361 budget_rollover, period_start_us
2362 FROM tollgate_accounts WHERE account_id = $1";
2363
2364const GRANT_DEBIT_SQL: &str = "UPDATE tollgate_accounts
2367 SET balance = balance - $2,
2368 allowance_balance = allowance_balance - $3,
2369 next_fence = next_fence + 1
2370 WHERE account_id = $1
2371 RETURNING account_id, deposited, overage_recorded, usage_recorded,
2372 settlement_loss, expired, budget_allowance, budget_period,
2373 budget_rollover, period_start_us";
2374
2375fn budget_view(row: &sqlx::postgres::PgRow) -> Result<BudgetView, StoreError> {
2390 let deposited = to_units(row.get::<i64, _>(1), "deposited")?;
2391 let overage = to_units(row.get::<i64, _>(2), "overage_recorded")?;
2392 let usage = to_units(row.get::<i64, _>(3), "usage_recorded")?;
2393 let loss = to_units(row.get::<i64, _>(4), "settlement_loss")?;
2394 let expired = to_units(row.get::<i64, _>(5), "expired")?;
2395 let schedule = decode_schedule(row.get(6), row.get(7), row.get(8))?;
2396 let period_start = micros_ts(row.get::<i64, _>(9), "period_start_us")?;
2397
2398 let funded = deposited
2399 .checked_add(overage)
2400 .ok_or_else(|| StoreError("account funding total overflows".into()))?;
2401 let consumed = usage
2402 .checked_add(loss)
2403 .and_then(|spent| spent.checked_add(expired))
2404 .ok_or_else(|| StoreError("account consumption total overflows".into()))?;
2405 Ok(BudgetView {
2406 balance_at_publish: funded.checked_sub(consumed).ok_or_else(|| {
2407 StoreError(format!(
2408 "consumption {} exceeds funding {}",
2409 consumed.get(),
2410 funded.get()
2411 ))
2412 })?,
2413 period_end: schedule.map(|schedule| schedule.period.end_after(period_start)),
2418 })
2419}
2420
2421fn decode_publishable(
2433 principal: Principal,
2434 generation: i64,
2435 value: serde_json::Value,
2436) -> Result<PublishableSnapshot, StoreError> {
2437 let generation = generation_from(generation)?;
2438 let snapshot: StoredSnapshot =
2439 serde_json::from_value(value).map_err(|e| StoreError(format!("snapshot decode: {e}")))?;
2440 let budget = snapshot.budget;
2445 let publishable = PublishableSnapshot::try_new(Arc::new(snapshot.into_snapshot(generation)))
2446 .map_err(|error| {
2447 StoreError(format!(
2448 "invalid stored snapshot for principal {:#034x}: {error}",
2449 principal.0
2450 ))
2451 })?;
2452 Ok(match budget {
2453 Some(budget) => publishable.with_budget(Some(budget)),
2454 None => publishable,
2455 })
2456}
2457
2458fn generation_from(column: i64) -> Result<Generation, StoreError> {
2470 u64::try_from(column)
2471 .map(Generation)
2472 .map_err(|_| StoreError("stored snapshot generation is negative".into()))
2473}
2474
2475#[async_trait]
2476impl SnapshotSource for PostgresStore {
2477 async fn snapshot(&self, principal: Principal) -> Result<SnapshotResolution, StoreError> {
2478 let row = sqlx::query(
2479 "SELECT generation, snapshot, deleted FROM tollgate_snapshots WHERE principal = $1",
2480 )
2481 .bind(id_bytes(principal.0))
2482 .fetch_optional(&self.pool)
2483 .await
2484 .map_err(storage)?;
2485 match row {
2486 Some(row) if row.get::<bool, _>(2) => Ok(SnapshotResolution::Revoked {
2492 generation: generation_from(row.get::<i64, _>(0))?,
2493 }),
2494 Some(row) => Ok(SnapshotResolution::Present(decode_publishable(
2495 principal,
2496 row.get::<i64, _>(0),
2497 row.get(1),
2498 )?)),
2499 None => Ok(SnapshotResolution::Unknown),
2500 }
2501 }
2502
2503 fn subscribe(&self) -> broadcast::Receiver<SnapshotPush> {
2504 self.push.subscribe()
2505 }
2506
2507 async fn principals(&self) -> Result<Option<Vec<Principal>>, StoreError> {
2515 let rows = sqlx::query("SELECT principal FROM tollgate_snapshots ORDER BY principal")
2516 .fetch_all(&self.pool)
2517 .await
2518 .map_err(storage)?;
2519 Ok(Some(
2520 rows.iter()
2521 .map(|row| Principal(id_from(row.get::<Vec<u8>, _>(0).as_slice())))
2522 .collect(),
2523 ))
2524 }
2525}
2526
2527#[async_trait]
2528impl StoreHealth for PostgresStore {
2529 async fn ping(&self) -> Result<(), StoreError> {
2530 sqlx::query("SELECT 1")
2531 .execute(&self.pool)
2532 .await
2533 .map_err(storage)?;
2534 Ok(())
2535 }
2536}
2537
2538async fn republish_patched_snapshots(
2568 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
2569 account: AccountId,
2570 json_path: &'static str,
2571 value: &str,
2572) -> Result<(Vec<(Principal, PublishableSnapshot)>, usize), SetStatusError> {
2573 let rows = sqlx::query(
2574 "UPDATE tollgate_snapshots
2575 SET generation = generation + 1,
2576 snapshot = jsonb_set(snapshot, $3::text[], to_jsonb($2::text))
2577 WHERE account_id = $1
2578 AND deleted = FALSE
2579 AND snapshot #>> $3::text[] IS DISTINCT FROM $2::text
2580 RETURNING principal, generation, snapshot",
2581 )
2582 .bind(id_bytes(account.0))
2583 .bind(value)
2584 .bind(json_path)
2585 .fetch_all(&mut **tx)
2586 .await
2587 .map_err(storage)?;
2588
2589 let mut republished = Vec::with_capacity(rows.len());
2590 let mut unreadable = 0usize;
2591 for row in rows {
2592 let principal = Principal(id_from(row.get::<Vec<u8>, _>(0).as_slice()));
2593 match decode_publishable(
2594 principal,
2595 row.get::<i64, _>(1),
2596 row.get::<serde_json::Value, _>(2),
2597 ) {
2598 Ok(snapshot) => republished.push((principal, snapshot)),
2599 Err(error) => {
2607 unreadable += 1;
2608 tracing::warn!(
2609 %principal,
2610 %error,
2611 "restamped snapshot could not be decoded for push"
2612 );
2613 }
2614 }
2615 }
2616 republished.sort_unstable_by_key(|(principal, _)| *principal);
2619 Ok((republished, unreadable))
2620}
2621#[async_trait]
2622impl AdminStore for PostgresStore {
2623 async fn create_account(
2624 &self,
2625 config: AccountConfig,
2626 ) -> Result<tollgate_store::AdminReceipt<()>, CreateAccountError> {
2627 let result = sqlx::query(
2628 "INSERT INTO tollgate_accounts
2629 (account_id, balance, deposited, status, capacity_class, next_fence,
2630 usage_recorded, settlement_loss, overage_recorded)
2631 VALUES ($1, $2, $2, $3, $4, 1, 0, 0, 0)
2632 ON CONFLICT (account_id) DO NOTHING",
2633 )
2634 .bind(id_bytes(config.account_id.0))
2635 .bind(to_i64(config.initial_balance, "balance").map_err(CreateAccountError::Storage)?)
2636 .bind(config.status.as_str())
2637 .bind(config.capacity_class.as_str())
2638 .execute(&self.pool)
2639 .await
2640 .map_err(|e| CreateAccountError::Storage(storage(e)))?;
2641 if result.rows_affected() == 0 {
2642 return Err(CreateAccountError::AlreadyExists);
2643 }
2644 Ok(AdminReceipt::new(
2645 (),
2646 AdminState::Absent,
2647 AdminState::AccountCreated {
2648 initial_balance: config.initial_balance,
2649 status: config.status,
2650 capacity_class: config.capacity_class,
2651 },
2652 ))
2653 }
2654
2655 async fn deposit(
2656 &self,
2657 account: AccountId,
2658 units: CostUnits,
2659 ) -> Result<AdminReceipt<()>, AllocateError> {
2660 let row = sqlx::query(
2661 "UPDATE tollgate_accounts
2662 SET balance = balance + $2, deposited = deposited + $2
2663 WHERE account_id = $1
2664 RETURNING balance - $2 AS old_topup, deposited - $2 AS old_deposited,
2665 balance AS new_topup, deposited AS new_deposited",
2666 )
2667 .bind(id_bytes(account.0))
2668 .bind(to_i64(units, "deposit").map_err(AllocateError::Storage)?)
2669 .fetch_optional(&self.pool)
2670 .await
2671 .map_err(alloc_storage)?
2672 .ok_or(AllocateError::UnknownAccount)?;
2673 let state = |topup: &str, deposited: &str| -> Result<AdminState, StoreError> {
2674 Ok(AdminState::Funding {
2675 topup: to_units(row.get(topup), "audit topup")?,
2676 deposited: to_units(row.get(deposited), "audit deposited")?,
2677 })
2678 };
2679 Ok(AdminReceipt::new(
2680 (),
2681 state("old_topup", "old_deposited")?,
2682 state("new_topup", "new_deposited")?,
2683 ))
2684 }
2685
2686 async fn set_budget_schedule(
2687 &self,
2688 account: AccountId,
2689 schedule: Option<BudgetSchedule>,
2690 ) -> Result<AdminReceipt<()>, BudgetError> {
2691 let allowance = schedule
2701 .map(|s| to_i64(s.allowance, "budget allowance"))
2702 .transpose()
2703 .map_err(BudgetError::Storage)?;
2704 let mut tx = self.pool.begin().await.map_err(storage)?;
2705 let result = async {
2706 let row = sqlx::query(
2710 "SELECT budget_allowance, budget_period, budget_rollover
2711 FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
2712 )
2713 .bind(id_bytes(account.0))
2714 .fetch_optional(&mut *tx)
2715 .await
2716 .map_err(storage)?
2717 .ok_or(BudgetError::UnknownAccount)?;
2718 let before = decode_schedule(row.get(0), row.get(1), row.get(2))?;
2719 sqlx::query(
2720 "UPDATE tollgate_accounts
2721 SET budget_allowance = $2, budget_period = $3, budget_rollover = $4
2722 WHERE account_id = $1",
2723 )
2724 .bind(id_bytes(account.0))
2725 .bind(allowance)
2726 .bind(schedule.map(|s| s.period.as_str()))
2727 .bind(schedule.map(|s| s.rollover.as_str()))
2728 .execute(&mut *tx)
2729 .await
2730 .map_err(storage)?;
2731 Ok(AdminReceipt::new(
2732 (),
2733 AdminState::Budget { schedule: before },
2734 AdminState::Budget { schedule },
2735 ))
2736 }
2737 .await;
2738 finish_transaction(tx, result).await
2739 }
2740
2741 async fn account_view(&self, account: AccountId) -> Result<Option<AccountView>, StoreError> {
2742 let mut tx = self.pool.begin().await.map_err(storage)?;
2751 let result = async {
2752 sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
2753 .execute(&mut *tx)
2754 .await
2755 .map_err(storage)?;
2756 let account_row = sqlx::query(
2757 "SELECT deposited, balance, usage_recorded, settlement_loss, overage_recorded,
2758 expired, status, capacity_class, budget_allowance, budget_period,
2759 budget_rollover, period_start_us
2760 FROM tollgate_accounts WHERE account_id = $1",
2761 )
2762 .bind(id_bytes(account.0))
2763 .fetch_optional(&mut *tx)
2764 .await
2765 .map_err(storage)?;
2766 let lease_row = sqlx::query(ACTIVE_LEASE_SUM_SQL)
2767 .bind(id_bytes(account.0))
2768 .fetch_one(&mut *tx)
2769 .await
2770 .map_err(storage)?;
2771 Ok::<_, StoreError>((account_row, lease_row))
2772 }
2773 .await;
2774 let (account_row, lease_row) = finish_transaction(tx, result).await?;
2775
2776 let Some(row) = account_row else {
2777 return Ok(None);
2778 };
2779 let active_grants = to_units(lease_row.get::<i64, _>(0), "active lease grants")?;
2780 let active_used = to_units(lease_row.get::<i64, _>(1), "active lease usage")?;
2781 let recorded = to_units(row.get::<i64, _>(2), "usage_recorded")?;
2782 Ok(Some(AccountView {
2783 account_id: account,
2784 status: decode_status(row.get::<String, _>(6))?,
2785 capacity_class: decode_capacity_class(row.get::<String, _>(7))?,
2786 schedule: decode_schedule(
2787 row.get::<Option<i64>, _>(8),
2788 row.get::<Option<String>, _>(9),
2789 row.get::<Option<String>, _>(10),
2790 )?,
2791 period_start: StoredInstant {
2792 micros: row.get::<i64, _>(11),
2793 submicro_nanos: 0,
2794 }
2795 .timestamp()?,
2796 conservation: Conservation {
2797 deposited: to_units(row.get::<i64, _>(0), "deposited")?,
2798 overage_recorded: to_units(row.get::<i64, _>(4), "overage_recorded")?,
2799 balance: to_units(row.get::<i64, _>(1), "balance")?,
2800 active_lease_grants: active_grants,
2801 settled_usage: recorded.checked_sub(active_used).ok_or_else(|| {
2805 StoreError(format!(
2806 "active lease usage {} exceeds recorded usage {} for account {account}",
2807 active_used.get(),
2808 recorded.get()
2809 ))
2810 })?,
2811 settlement_loss: to_units(row.get::<i64, _>(3), "settlement_loss")?,
2812 expired: to_units(row.get::<i64, _>(5), "expired")?,
2813 },
2814 }))
2815 }
2816
2817 async fn roll_due_periods(
2818 &self,
2819 now: Timestamp,
2820 limit: NonZeroUsize,
2821 ) -> Result<RolloverBatch, StoreError> {
2822 let limit_i = i64::try_from(limit.get())
2823 .map_err(|_| StoreError(format!("rollover batch limit exceeds i64 range: {limit}")))?;
2824 let mut rolled = Vec::new();
2825 for period in Period::ALL {
2832 let boundary_us = ts_micros(period.start_of(now));
2833 let remaining = limit_i
2834 - i64::try_from(rolled.len())
2835 .map_err(|_| StoreError("rollover batch row count exceeds i64 range".into()))?;
2836 if remaining <= 0 {
2837 break;
2838 }
2839 let rows = sqlx::query(DUE_PERIODS_SQL)
2850 .bind(period.as_str())
2851 .bind(boundary_us)
2852 .bind(remaining)
2853 .fetch_all(&self.pool)
2854 .await
2855 .map_err(storage)?;
2856
2857 for row in rows {
2858 rolled.push((
2859 row.get::<i64, _>(3),
2860 RolledAccount {
2861 account_id: AccountId(id_from(&row.get::<Vec<u8>, _>(0))),
2862 deposited: to_units(row.get::<i64, _>(1), "budget allowance")?,
2863 expired: to_units(row.get::<i64, _>(2), "expiring allowance")?,
2864 },
2865 ));
2866 }
2867 }
2868 rolled
2875 .sort_unstable_by_key(|(crossed_from, account)| (*crossed_from, account.account_id.0));
2876 RolloverBatch::try_new(
2877 rolled.into_iter().map(|(_, account)| account).collect(),
2878 limit,
2879 )
2880 }
2881
2882 async fn set_account_status(
2883 &self,
2884 account: AccountId,
2885 status: AccountStatus,
2886 ) -> Result<tollgate_store::AdminReceipt<StatusChange>, SetStatusError> {
2887 let mut tx = self.pool.begin().await.map_err(storage)?;
2888 let result = async {
2889 let row = sqlx::query(
2894 "SELECT status FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
2895 )
2896 .bind(id_bytes(account.0))
2897 .fetch_optional(&mut *tx)
2898 .await
2899 .map_err(storage)?
2900 .ok_or(SetStatusError::UnknownAccount)?;
2901
2902 let before = decode_status(row.get::<String, _>(0))?;
2903 if before == AccountStatus::Closed && status != AccountStatus::Closed {
2904 return Err(SetStatusError::AccountClosed);
2907 }
2908
2909 sqlx::query("UPDATE tollgate_accounts SET status = $2 WHERE account_id = $1")
2910 .bind(id_bytes(account.0))
2911 .bind(status.as_str())
2912 .execute(&mut *tx)
2913 .await
2914 .map_err(storage)?;
2915
2916 let (republished, unreadable) =
2917 republish_patched_snapshots(&mut tx, account, "{status}", status.as_str()).await?;
2918 Ok((before, republished, unreadable))
2919 }
2920 .await;
2921
2922 let (before, republished, unreadable) = finish_transaction(tx, result).await?;
2923 if pushes_exceed_capacity(republished.len()) {
2925 tracing::warn!(
2926 %account,
2927 principals = republished.len(),
2928 capacity = PUSH_CHANNEL_CAPACITY,
2929 "status change emitted more pushes than the channel holds; subscribers will resync"
2930 );
2931 }
2932 let count = republished.len();
2933 for (principal, snapshot) in republished {
2934 self.push_to_subscribers(SnapshotPush {
2935 principal,
2936 resolution: SnapshotResolution::Present(snapshot),
2937 });
2938 }
2939 Ok(AdminReceipt::new(
2940 StatusChange {
2941 republished: count + unreadable,
2944 unreadable,
2945 },
2946 AdminState::Status { status: before },
2947 AdminState::Status { status },
2948 ))
2949 }
2950
2951 async fn set_capacity_class(
2952 &self,
2953 account: AccountId,
2954 class: CapacityClass,
2955 ) -> Result<tollgate_store::AdminReceipt<StatusChange>, SetStatusError> {
2956 let mut tx = self.pool.begin().await.map_err(storage)?;
2957 let result = async {
2958 let row = sqlx::query(
2966 "SELECT status, capacity_class FROM tollgate_accounts WHERE account_id = $1 FOR UPDATE",
2967 )
2968 .bind(id_bytes(account.0))
2969 .fetch_optional(&mut *tx)
2970 .await
2971 .map_err(storage)?
2972 .ok_or(SetStatusError::UnknownAccount)?;
2973
2974 let before = decode_capacity_class(row.get::<String, _>(1))?;
2975 if decode_status(row.get::<String, _>(0))? == AccountStatus::Closed {
2980 return Err(SetStatusError::AccountClosed);
2981 }
2982
2983 sqlx::query("UPDATE tollgate_accounts SET capacity_class = $2 WHERE account_id = $1")
2984 .bind(id_bytes(account.0))
2985 .bind(class.as_str())
2986 .execute(&mut *tx)
2987 .await
2988 .map_err(storage)?;
2989
2990 let (republished, unreadable) =
2991 republish_patched_snapshots(&mut tx, account, "{capacity_class}", class.as_str()).await?;
2992 Ok((before, republished, unreadable))
2993 }
2994 .await;
2995
2996 let (before, republished, unreadable) = finish_transaction(tx, result).await?;
2997 if pushes_exceed_capacity(republished.len()) {
2998 tracing::warn!(
2999 %account,
3000 principals = republished.len(),
3001 capacity = PUSH_CHANNEL_CAPACITY,
3002 "capacity class change emitted more pushes than the channel holds; \
3003 subscribers will resync"
3004 );
3005 }
3006 let count = republished.len();
3007 for (principal, snapshot) in republished {
3008 self.push_to_subscribers(SnapshotPush {
3009 principal,
3010 resolution: SnapshotResolution::Present(snapshot),
3011 });
3012 }
3013 Ok(AdminReceipt::new(
3014 StatusChange {
3015 republished: count + unreadable,
3018 unreadable,
3019 },
3020 AdminState::CapacityClass {
3021 capacity_class: before,
3022 },
3023 AdminState::CapacityClass {
3024 capacity_class: class,
3025 },
3026 ))
3027 }
3028
3029 async fn publish_snapshot(
3030 &self,
3031 principal: Principal,
3032 snapshot: PublishableSnapshot,
3033 ) -> Result<tollgate_store::AdminReceipt<()>, PublishSnapshotError> {
3034 let generation = i64::try_from(snapshot.generation.0).map_err(|_| {
3035 StoreError("snapshot generation exceeds PostgreSQL BIGINT range".into())
3036 })?;
3037
3038 let mut tx = self.pool.begin().await.map_err(storage)?;
3039 let result = publish_in_tx(&mut tx, principal, generation, snapshot).await;
3040
3041 let (written, published, before, after) = finish_transaction(tx, result).await?;
3042 if written {
3043 self.push_to_subscribers(SnapshotPush {
3047 principal,
3048 resolution: SnapshotResolution::Present(published),
3049 });
3050 }
3051 Ok(AdminReceipt::new((), before, after))
3052 }
3053
3054 async fn remove_snapshot(&self, principal: Principal) -> Result<AdminReceipt<()>, StoreError> {
3055 let mut tx = self.pool.begin().await.map_err(storage)?;
3056 let result = remove_in_tx(&mut tx, principal).await;
3057 let receipt = finish_transaction(tx, result).await?;
3058 self.announce_removal(principal, &receipt);
3059 Ok(receipt)
3060 }
3061}
3062
3063async fn lock_account_key(
3087 tx: &mut Transaction<'_, Postgres>,
3088 account: AccountId,
3089 key: KeyId,
3090) -> Result<(Principal, bool), KeySnapshotError> {
3091 let row = sqlx::query(
3092 "SELECT principal, revoked_at_us IS NOT NULL
3093 FROM tollgate_credential_keys WHERE key_id = $1 AND account_id = $2 FOR SHARE",
3094 )
3095 .bind(id_bytes(key.0))
3096 .bind(id_bytes(account.0))
3097 .fetch_optional(&mut **tx)
3098 .await
3099 .map_err(storage)?
3100 .ok_or(KeySnapshotError::UnknownCredential)?;
3101 let principal: Vec<u8> = row.get(0);
3102 let principal: [u8; 16] = principal
3103 .try_into()
3104 .map_err(|_| StoreError("credential principal is not 16 bytes".into()))?;
3105 Ok((Principal(u128::from_be_bytes(principal)), row.get(1)))
3106}
3107
3108async fn publish_in_tx(
3114 tx: &mut Transaction<'_, Postgres>,
3115 principal: Principal,
3116 generation: i64,
3117 snapshot: PublishableSnapshot,
3118) -> Result<(bool, PublishableSnapshot, AdminState, AdminState), PublishSnapshotError> {
3119 if let Some(key_id) = snapshot.key_id {
3120 let matches: bool = sqlx::query_scalar(
3121 "SELECT EXISTS(SELECT 1 FROM tollgate_credential_keys
3122 WHERE key_id = $1 AND principal = $2 AND account_id = $3)",
3123 )
3124 .bind(id_bytes(key_id.0))
3125 .bind(id_bytes(principal.0))
3126 .bind(id_bytes(snapshot.account_id.0))
3127 .fetch_one(&mut **tx)
3128 .await
3129 .map_err(storage)?;
3130 if !matches {
3131 return Err(PublishSnapshotError::CredentialMismatch { key_id });
3132 }
3133 }
3134 let ledger = sqlx::query(
3146 "SELECT status, deposited, overage_recorded, usage_recorded, settlement_loss,
3147 expired, budget_allowance, budget_period, budget_rollover, period_start_us,
3148 capacity_class
3149 FROM tollgate_accounts WHERE account_id = $1 FOR SHARE",
3150 )
3151 .bind(id_bytes(snapshot.account_id.0))
3152 .fetch_optional(&mut **tx)
3153 .await
3154 .map_err(storage)?;
3155
3156 let view = match &ledger {
3164 Some(row) => {
3165 let ledger = decode_status(row.get::<String, _>(0))?;
3166 if ledger != snapshot.status {
3167 return Err(PublishSnapshotError::StatusMismatch {
3168 ledger,
3169 submitted: snapshot.status,
3170 });
3171 }
3172 let ledger_class = decode_capacity_class(row.get::<String, _>(10))?;
3176 if ledger_class != snapshot.capacity_class {
3177 return Err(PublishSnapshotError::CapacityClassMismatch {
3178 ledger: ledger_class,
3179 submitted: snapshot.capacity_class,
3180 });
3181 }
3182 Some(budget_view(row)?)
3183 }
3184 None => None,
3185 };
3186 let published = snapshot.with_budget(view);
3187 let value = serde_json::to_value(StoredSnapshotRef::from(published.as_snapshot()))
3188 .map_err(|e| StoreError(format!("snapshot encode: {e}")))?;
3189
3190 let (written, before, after) = write_snapshot_audited(tx, principal, generation, value).await?;
3191 Ok((written, published, before, after))
3192}
3193
3194async fn remove_in_tx(
3197 tx: &mut Transaction<'_, Postgres>,
3198 principal: Principal,
3199) -> Result<AdminReceipt<()>, StoreError> {
3200 let before = snapshot_audit_row(tx, principal).await?;
3201 let after = match before {
3202 AdminState::Snapshot {
3203 generation,
3204 revoked: false,
3205 } => {
3206 sqlx::query("UPDATE tollgate_snapshots SET deleted = TRUE WHERE principal = $1")
3207 .bind(id_bytes(principal.0))
3208 .execute(&mut **tx)
3209 .await
3210 .map_err(storage)?;
3211 AdminState::Snapshot {
3212 generation,
3213 revoked: true,
3214 }
3215 }
3216 state => state,
3217 };
3218 Ok(AdminReceipt::new((), before, after))
3219}
3220
3221async fn snapshot_audit_row(
3224 tx: &mut Transaction<'_, Postgres>,
3225 principal: Principal,
3226) -> Result<AdminState, StoreError> {
3227 let row = sqlx::query(
3228 "SELECT generation, deleted FROM tollgate_snapshots WHERE principal = $1 FOR UPDATE",
3229 )
3230 .bind(id_bytes(principal.0))
3231 .fetch_optional(&mut **tx)
3232 .await
3233 .map_err(storage)?;
3234 row.map(|row| {
3235 Ok(AdminState::Snapshot {
3236 generation: generation_from(row.get(0))?,
3237 revoked: row.get(1),
3238 })
3239 })
3240 .unwrap_or(Ok(AdminState::Absent))
3241}
3242
3243async fn write_snapshot_audited(
3244 tx: &mut Transaction<'_, Postgres>,
3245 principal: Principal,
3246 generation: i64,
3247 value: serde_json::Value,
3248) -> Result<(bool, AdminState, AdminState), StoreError> {
3249 let mut before = snapshot_audit_row(tx, principal).await?;
3250 let after = AdminState::Snapshot {
3251 generation: generation_from(generation)?,
3252 revoked: false,
3253 };
3254 if before == AdminState::Absent {
3255 let inserted = sqlx::query(
3256 "INSERT INTO tollgate_snapshots (principal, generation, snapshot, deleted)
3257 VALUES ($1, $2, $3, FALSE) ON CONFLICT (principal) DO NOTHING",
3258 )
3259 .bind(id_bytes(principal.0))
3260 .bind(generation)
3261 .bind(&value)
3262 .execute(&mut **tx)
3263 .await
3264 .map_err(storage)?;
3265 if inserted.rows_affected() == 1 {
3266 return Ok((true, before, after));
3267 }
3268 before = snapshot_audit_row(tx, principal).await?;
3271 }
3272 let AdminState::Snapshot {
3273 generation: previous,
3274 ..
3275 } = before
3276 else {
3277 return Err(StoreError("snapshot disappeared during publication".into()));
3278 };
3279 if previous >= generation_from(generation)? {
3280 return Ok((false, before, before));
3281 }
3282 sqlx::query("UPDATE tollgate_snapshots SET generation = $2, snapshot = $3, deleted = FALSE WHERE principal = $1")
3283 .bind(id_bytes(principal.0)).bind(generation).bind(value)
3284 .execute(&mut **tx).await.map_err(storage)?;
3285 Ok((true, before, after))
3286}
3287
3288#[cfg(test)]
3289mod tests {
3290 use super::*;
3291
3292 #[test]
3293 fn stored_fences_use_the_exact_positive_bigint_domain() {
3294 for invalid in [i64::MIN, -1, 0] {
3295 assert!(stored_fence(invalid).is_err());
3296 }
3297 for valid in [1, 2, i64::MAX] {
3298 assert_eq!(stored_fence(valid).unwrap(), FencingToken(valid as u64));
3299 }
3300 }
3301
3302 #[tokio::test]
3305 async fn ping_surfaces_a_closed_pool() {
3306 let pool = PgPoolOptions::new()
3307 .connect_lazy("postgres://localhost/tollgate")
3308 .unwrap();
3309 pool.close().await;
3310 let (push, _) = broadcast::channel(1);
3311 let store = PostgresStore {
3312 pool,
3313 policy: GrantPolicy::default(),
3314 push,
3315 };
3316
3317 assert!(store.ping().await.is_err());
3318 }
3319}