1use super::*;
2
3pub(crate) const BUDGET_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 10;
5const BUDGET_INVOCATION_CAPTURE_SCHEMA_VERSION: i32 = 2;
6const BUDGET_STORE_SCHEMA_KEY: &str = "budget";
9const BUDGET_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["capability_grant_budgets"];
12
13impl SqliteBudgetStore {
14 pub fn open(path: impl AsRef<Path>) -> Result<Self, BudgetStoreError> {
15 let path = path.as_ref();
16 if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
20 fs::create_dir_all(&parent)?;
21 }
22
23 let mut connection = Connection::open(path)?;
24 Self::initialize_connection(&mut connection, false)?;
25 Ok(Self {
26 connection: Arc::new(Mutex::new(connection)),
27 serving_owner: None,
28 })
29 }
30
31 pub(crate) fn initialize_connection_offline(
32 connection: &mut Connection,
33 ) -> Result<(), BudgetStoreError> {
34 Self::initialize_connection(connection, true)
35 }
36
37 fn initialize_connection(
38 connection: &mut Connection,
39 allow_provisioned: bool,
40 ) -> Result<(), BudgetStoreError> {
41 if !allow_provisioned {
42 if let Some(epoch) = crate::serving_owner::provisioned_owner_epoch(connection)? {
43 return Err(BudgetStoreError::Fenced {
44 expected_epoch: 0,
45 actual_epoch: Some(epoch),
46 });
47 }
48 }
49 let on_disk_schema_version = crate::check_schema_version(
50 connection,
51 BUDGET_STORE_SCHEMA_KEY,
52 BUDGET_STORE_SUPPORTED_SCHEMA_VERSION,
53 BUDGET_STORE_LEGACY_ANCHOR_TABLES,
54 )
55 .map_err(|error| BudgetStoreError::Invariant(error.to_string()))?;
56 connection.execute_batch(
57 r#"
58 PRAGMA journal_mode = WAL;
59 PRAGMA synchronous = FULL;
60 PRAGMA busy_timeout = 5000;
61 PRAGMA foreign_keys = ON;
62
63 CREATE TABLE IF NOT EXISTS capability_grant_budgets (
64 capability_id TEXT NOT NULL,
65 grant_index INTEGER NOT NULL,
66 invocation_count INTEGER NOT NULL,
67 updated_at INTEGER NOT NULL,
68 seq INTEGER NOT NULL DEFAULT 0,
69 total_cost_exposed INTEGER NOT NULL DEFAULT 0,
70 total_cost_realized_spend INTEGER NOT NULL DEFAULT 0,
71 PRIMARY KEY (capability_id, grant_index)
72 );
73
74 CREATE INDEX IF NOT EXISTS idx_capability_grant_budgets_updated_at
75 ON capability_grant_budgets(updated_at);
76
77 CREATE TABLE IF NOT EXISTS budget_replication_meta (
78 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
79 next_seq INTEGER NOT NULL
80 );
81
82 CREATE TABLE IF NOT EXISTS budget_authorization_holds (
83 hold_id TEXT PRIMARY KEY,
84 capability_id TEXT NOT NULL,
85 grant_index INTEGER NOT NULL,
86 authorized_exposure_units INTEGER NOT NULL,
87 remaining_exposure_units INTEGER NOT NULL,
88 invocation_count_debited INTEGER NOT NULL,
89 invocation_captured INTEGER NOT NULL DEFAULT 0,
90 disposition TEXT NOT NULL,
91 authority_id TEXT,
92 lease_id TEXT,
93 lease_epoch INTEGER,
94 created_at INTEGER NOT NULL,
95 updated_at INTEGER NOT NULL
96 );
97
98 CREATE INDEX IF NOT EXISTS idx_budget_authorization_holds_capability
99 ON budget_authorization_holds(capability_id, grant_index);
100
101 CREATE TABLE IF NOT EXISTS budget_mutation_events (
102 event_id TEXT PRIMARY KEY,
103 hold_id TEXT,
104 capability_id TEXT NOT NULL,
105 grant_index INTEGER NOT NULL,
106 kind TEXT NOT NULL,
107 allowed INTEGER,
108 recorded_at INTEGER NOT NULL,
109 event_seq INTEGER,
110 usage_seq INTEGER,
111 exposure_units INTEGER NOT NULL DEFAULT 0,
112 realized_spend_units INTEGER NOT NULL DEFAULT 0,
113 max_invocations INTEGER,
114 max_exposure_per_invocation INTEGER,
115 max_total_exposure_units INTEGER,
116 invocation_count_after INTEGER NOT NULL,
117 total_cost_exposed_after INTEGER NOT NULL,
118 total_cost_realized_spend_after INTEGER NOT NULL,
119 authority_id TEXT,
120 lease_id TEXT,
121 lease_epoch INTEGER
122 );
123
124 CREATE INDEX IF NOT EXISTS idx_budget_mutation_events_capability
125 ON budget_mutation_events(capability_id, grant_index, recorded_at);
126
127 CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_mutation_events_event_seq
128 ON budget_mutation_events(event_seq);
129
130 CREATE TABLE IF NOT EXISTS budget_import_floors (
131 authority_id TEXT PRIMARY KEY,
132 floor_seq INTEGER NOT NULL DEFAULT 0,
133 CHECK (floor_seq >= 0)
134 );
135
136 CREATE TABLE IF NOT EXISTS budget_ack_head_watermark (
137 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
138 head_seq INTEGER NOT NULL DEFAULT 0,
139 CHECK (head_seq >= 0)
140 );
141
142 -- Incrementally-maintained per-origin ack head, so the status path does
143 -- not GROUP BY the full mutation history every call. Each entry is the
144 -- highest event_seq that origin has WITHIN the verified global contiguous
145 -- prefix (<= the watermark); a DELETE clears it (see the trigger) so a
146 -- hole forces a rebuild rather than leaving a stale-high per-origin head.
147 CREATE TABLE IF NOT EXISTS budget_origin_ack_heads (
148 authority_id TEXT PRIMARY KEY,
149 head_seq INTEGER NOT NULL,
150 CHECK (head_seq >= 0)
151 );
152
153 -- Metadata tombstones for event_seqs that were CONSUMED but have no
154 -- surviving mutation event: the leader-local rollback-retry path
155 -- (existing_event_allowed) and the delta-import replace path delete a
156 -- rolled-back authorize and re-append it under a fresh higher seq,
157 -- permanently abandoning the original seq. Recording the abandoned seq
158 -- lets the global contiguous ack head treat it as FILLED so it does not
159 -- stall cluster-wide at the hole. This never over-counts: an abandoned
160 -- seq is never a live write (its write was rolled back / superseded), so
161 -- no witness targets it; a genuinely MISSING event (never received, never
162 -- deleted here) is not recorded and still caps the head.
163 CREATE TABLE IF NOT EXISTS budget_abandoned_event_seqs (
164 seq INTEGER PRIMARY KEY,
165 CHECK (seq > 0)
166 );
167 "#,
168 )?;
169 ensure_budget_ack_head_reset_trigger(connection)?;
170 connection.execute(
171 r#"
172 INSERT INTO budget_replication_meta (singleton, next_seq)
173 VALUES (1, 0)
174 ON CONFLICT(singleton) DO NOTHING
175 "#,
176 [],
177 )?;
178 connection.execute(
179 r#"
180 INSERT INTO budget_ack_head_watermark (singleton, head_seq)
181 VALUES (1, 0)
182 ON CONFLICT(singleton) DO NOTHING
183 "#,
184 [],
185 )?;
186 ensure_budget_seq_column(connection)?;
187 ensure_split_budget_cost_columns(connection)?;
188 ensure_budget_hold_authority_columns(connection)?;
189 ensure_budget_hold_reserved_until_column(connection)?;
190 ensure_budget_hold_reserved_currency_column(connection)?;
191 ensure_budget_hold_reserved_payment_reference_column(connection)?;
192 ensure_budget_hold_reserved_envelope_columns(connection)?;
193 ensure_budget_mutation_event_authority_columns(connection)?;
194 ensure_budget_mutation_event_seq_column(connection)?;
195 initialize_budget_replication_seq(connection)?;
196 let migration = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
197 let capture_column_added = ensure_budget_hold_invocation_captured_column(&migration)?;
198 if on_disk_schema_version >= BUDGET_STORE_SUPPORTED_SCHEMA_VERSION && capture_column_added {
199 migration.rollback()?;
200 return Err(BudgetStoreError::Invariant(
201 "budget schema version declares invocation capture support but the column is missing"
202 .to_string(),
203 ));
204 }
205 if on_disk_schema_version < BUDGET_INVOCATION_CAPTURE_SCHEMA_VERSION {
206 migration.execute(
207 r#"
208 UPDATE budget_authorization_holds
209 SET invocation_captured = 1
210 WHERE disposition = 'open'
211 "#,
212 [],
213 )?;
214 }
215 ensure_composite_budget_schema(&migration, on_disk_schema_version)?;
216 crate::stamp_schema_version(
217 &migration,
218 BUDGET_STORE_SCHEMA_KEY,
219 BUDGET_STORE_SUPPORTED_SCHEMA_VERSION,
220 )
221 .map_err(|error| BudgetStoreError::Invariant(error.to_string()))?;
222 migration.commit()?;
223 verify_budget_foreign_keys(connection)?;
224 verify_budget_projection_invariants(connection)?;
225 Self::rebuild_snapshot_proof_caches(connection)?;
226
227 Ok(())
228 }
229
230 pub(crate) fn open_alongside(
231 connection: Arc<Mutex<Connection>>,
232 serving_owner: Arc<crate::serving_owner::SqliteServingOwner>,
233 ) -> Self {
234 Self {
235 connection,
236 serving_owner: Some(serving_owner),
237 }
238 }
239
240 pub(super) fn connection(&self) -> Result<MutexGuard<'_, Connection>, BudgetStoreError> {
241 self.connection.lock().map_err(|_| {
242 BudgetStoreError::Invariant("sqlite budget store lock poisoned".to_string())
243 })
244 }
245
246 pub(super) fn begin_write<'a>(
247 &self,
248 connection: &'a mut Connection,
249 ) -> Result<rusqlite::Transaction<'a>, BudgetStoreError> {
250 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
251 crate::serving_owner::verify_budget_fence(&transaction, self.serving_owner.as_deref())?;
252 if let Some(owner) = self.serving_owner.as_ref() {
253 owner
254 .verify_authority_anchor(&transaction)
255 .map_err(map_serving_owner_error)?;
256 }
257 Ok(transaction)
258 }
259
260 pub(super) fn begin_read<'a>(
261 &self,
262 connection: &'a mut Connection,
263 ) -> Result<rusqlite::Transaction<'a>, BudgetStoreError> {
264 let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?;
265 crate::serving_owner::verify_budget_fence(&transaction, self.serving_owner.as_deref())?;
266 if let Some(owner) = self.serving_owner.as_ref() {
267 owner
268 .verify_authority_anchor(&transaction)
269 .map_err(map_serving_owner_error)?;
270 }
271 Ok(transaction)
272 }
273
274 pub(super) fn append_joint_commit(
275 &self,
276 transaction: &rusqlite::Transaction<'_>,
277 kind: BudgetMutationKind,
278 event_id: &str,
279 event_seq: u64,
280 ) -> Result<(), BudgetStoreError> {
281 let owner = self.serving_owner.as_ref().ok_or_else(|| {
282 BudgetStoreError::Invariant(
283 "structured sqlite mutation requires a joint serving owner".to_string(),
284 )
285 })?;
286 owner
287 .append_global_commit(transaction, kind.as_str(), "budget", event_id, event_seq)
288 .map_err(map_serving_owner_error)
289 }
290
291 pub(super) fn sync_joint_anchor(
292 &self,
293 connection: &Connection,
294 ) -> Result<(), BudgetStoreError> {
295 let owner = self.serving_owner.as_ref().ok_or_else(|| {
296 BudgetStoreError::Invariant(
297 "structured sqlite mutation requires a joint serving owner".to_string(),
298 )
299 })?;
300 owner
301 .sync_authority_anchor(connection)
302 .map_err(map_serving_owner_error)
303 }
304
305 pub(super) fn commit_joint_transaction(
306 &self,
307 transaction: rusqlite::Transaction<'_>,
308 ) -> Result<(), BudgetStoreError> {
309 transaction.commit().map_err(|error| {
310 let detail = format!("sqlite budget commit outcome is unknown: {error}");
311 match self.serving_owner.as_ref() {
312 Some(owner) => map_serving_owner_error(owner.outcome_unknown(detail)),
313 None => BudgetStoreError::OutcomeUnknown(detail),
314 }
315 })
316 }
317
318 pub fn budget_ack_heads(&self) -> Result<Vec<(String, u64)>, BudgetStoreError> {
366 let mut connection = self.connection()?;
367 let transaction = self.begin_write(&mut connection)?;
368 transaction.execute(
369 r#"
370 UPDATE budget_ack_head_watermark
371 SET head_seq = MAX(
372 head_seq,
373 (SELECT covered_head FROM budget_snapshot_coverage WHERE singleton = 1)
374 )
375 WHERE singleton = 1
376 "#,
377 [],
378 )?;
379 let watermark: i64 = transaction.query_row(
380 "SELECT head_seq FROM budget_ack_head_watermark WHERE singleton = 1",
381 [],
382 |row| row.get(0),
383 )?;
384 let watermark = watermark.max(0) as u64;
385 let watermark_sqlite = budget_u64_to_sqlite(watermark, "head_seq")?;
386 let next_slot = watermark_sqlite.checked_add(1).ok_or_else(|| {
409 BudgetStoreError::Overflow("budget acknowledgement head overflowed i64".to_string())
410 })?;
411 let next_slot_filled: bool = transaction.query_row(
412 r#"
413 SELECT EXISTS(
414 SELECT 1 FROM budget_mutation_events WHERE event_seq = ?1
415 UNION ALL
416 SELECT 1 FROM budget_abandoned_event_seqs WHERE seq = ?1
417 )
418 "#,
419 rusqlite::params![next_slot],
420 |row| row.get::<_, i64>(0).map(|value| value != 0),
421 )?;
422 let head: i64 = if next_slot_filled {
423 transaction.query_row(
424 r#"
425 WITH filled AS (
426 SELECT event_seq AS seq
427 FROM budget_mutation_events
428 WHERE event_seq IS NOT NULL AND event_seq > ?1
429 UNION
430 SELECT seq
431 FROM budget_abandoned_event_seqs
432 WHERE seq > ?1
433 ),
434 run AS (
435 SELECT
436 seq,
437 seq - ROW_NUMBER() OVER (ORDER BY seq) AS island
438 FROM filled
439 )
440 SELECT COALESCE(MAX(seq), ?1) AS head_seq
441 FROM run
442 WHERE island = ?1
443 "#,
444 rusqlite::params![watermark_sqlite],
445 |row| row.get(0),
446 )?
447 } else {
448 watermark_sqlite
449 };
450 let head = head.max(0) as u64;
451 if head > watermark {
452 let head_sqlite = budget_u64_to_sqlite(head, "head_seq")?;
453 transaction.execute(
454 "UPDATE budget_ack_head_watermark SET head_seq = ?1 WHERE singleton = 1",
455 rusqlite::params![head_sqlite],
456 )?;
457 transaction.execute(
463 r#"
464 INSERT INTO budget_origin_ack_heads (authority_id, head_seq)
465 SELECT authority_id, MAX(event_seq)
466 FROM budget_mutation_events
467 WHERE authority_id IS NOT NULL
468 AND event_seq IS NOT NULL
469 AND event_seq > ?1
470 AND event_seq <= ?2
471 GROUP BY authority_id
472 ON CONFLICT(authority_id)
473 DO UPDATE SET head_seq = MAX(head_seq, excluded.head_seq)
474 "#,
475 rusqlite::params![watermark_sqlite, head_sqlite],
476 )?;
477 }
478 let mut statement =
479 transaction.prepare("SELECT authority_id, head_seq FROM budget_origin_ack_heads")?;
480 let rows = statement
481 .query_map([], |row| {
482 let origin: String = row.get(0)?;
483 let ack_head = budget_u64_from_row(row, 1, "ack_head")?;
484 Ok((origin, ack_head))
485 })?
486 .collect::<Result<Vec<_>, _>>()
487 .map_err(BudgetStoreError::from)?;
488 drop(statement);
489 transaction.commit()?;
490 Ok(rows)
491 }
492
493 #[cfg(test)]
505 pub(crate) fn reset_budget_ack_head_watermark(
506 transaction: &rusqlite::Transaction<'_>,
507 ) -> Result<(), BudgetStoreError> {
508 transaction.execute(
509 "UPDATE budget_ack_head_watermark SET head_seq = 0 WHERE singleton = 1",
510 [],
511 )?;
512 transaction.execute("DELETE FROM budget_origin_ack_heads", [])?;
515 Ok(())
516 }
517
518 pub fn list_abandoned_event_seqs(&self) -> Result<Vec<u64>, BudgetStoreError> {
525 let mut connection = self.connection()?;
526 let transaction = self.begin_read(&mut connection)?;
527 let mut statement =
528 transaction.prepare("SELECT seq FROM budget_abandoned_event_seqs ORDER BY seq ASC")?;
529 let rows = statement.query_map([], |row| {
530 let seq: i64 = row.get(0)?;
531 Ok(seq.max(0) as u64)
532 })?;
533 let rows = rows.collect::<Result<Vec<_>, _>>()?;
534 drop(statement);
535 transaction.rollback()?;
536 Ok(rows)
537 }
538
539 pub fn list_abandoned_event_seq_ranges(&self) -> Result<Vec<(u64, u64)>, BudgetStoreError> {
557 let mut connection = self.connection()?;
558 let transaction = self.begin_read(&mut connection)?;
559 let mut statement = transaction.prepare(
560 r#"
561 SELECT MIN(seq) AS start_seq, MAX(seq) AS end_seq
562 FROM (
563 SELECT seq, seq - ROW_NUMBER() OVER (ORDER BY seq) AS island
564 FROM budget_abandoned_event_seqs
565 )
566 GROUP BY island
567 ORDER BY start_seq ASC
568 "#,
569 )?;
570 let rows = statement.query_map([], |row| {
571 let start: i64 = row.get(0)?;
572 let end: i64 = row.get(1)?;
573 Ok((start.max(0) as u64, end.max(0) as u64))
574 })?;
575 let rows = rows.collect::<Result<Vec<_>, _>>()?;
576 drop(statement);
577 transaction.rollback()?;
578 Ok(rows)
579 }
580
581 pub fn list_abandoned_event_seqs_after(
586 &self,
587 after_seq: u64,
588 ) -> Result<Vec<u64>, BudgetStoreError> {
589 let after_seq = budget_u64_to_sqlite(after_seq, "after_seq")?;
590 let mut connection = self.connection()?;
591 let transaction = self.begin_read(&mut connection)?;
592 let mut statement = transaction.prepare(
593 "SELECT seq FROM budget_abandoned_event_seqs WHERE seq > ?1 ORDER BY seq ASC",
594 )?;
595 let rows = statement.query_map(rusqlite::params![after_seq], |row| {
596 let seq: i64 = row.get(0)?;
597 Ok(seq.max(0) as u64)
598 })?;
599 let rows = rows.collect::<Result<Vec<_>, _>>()?;
600 drop(statement);
601 transaction.rollback()?;
602 Ok(rows)
603 }
604
605 pub fn list_abandoned_event_seqs_in_range(
618 &self,
619 after_seq: u64,
620 up_to_seq: u64,
621 limit: usize,
622 ) -> Result<Vec<u64>, BudgetStoreError> {
623 let after_seq = budget_u64_to_sqlite(after_seq, "after_seq")?;
624 let up_to_seq = budget_u64_to_sqlite(up_to_seq, "up_to_seq")?;
625 let mut connection = self.connection()?;
626 let transaction = self.begin_read(&mut connection)?;
627 let mut statement = transaction.prepare(
628 "SELECT seq FROM budget_abandoned_event_seqs \
629 WHERE seq > ?1 AND seq <= ?2 ORDER BY seq ASC LIMIT ?3",
630 )?;
631 let rows = statement.query_map(
632 rusqlite::params![after_seq, up_to_seq, limit as i64],
633 |row| {
634 let seq: i64 = row.get(0)?;
635 Ok(seq.max(0) as u64)
636 },
637 )?;
638 let rows = rows.collect::<Result<Vec<_>, _>>()?;
639 drop(statement);
640 transaction.rollback()?;
641 Ok(rows)
642 }
643
644 pub(super) fn upsert_usage_in_transaction(
645 transaction: &rusqlite::Transaction<'_>,
646 record: &BudgetUsageRecord,
647 ) -> Result<(), BudgetStoreError> {
648 let existing = transaction
649 .query_row(
650 r#"
651 SELECT capability_id, grant_index, invocation_count, updated_at, seq,
652 total_cost_exposed, total_cost_realized_spend
653 FROM capability_grant_budgets
654 WHERE capability_id = ?1 AND grant_index = ?2
655 "#,
656 params![&record.capability_id, i64::from(record.grant_index)],
657 record_from_row,
658 )
659 .optional()?;
660 if let Some(existing) = existing
661 .as_ref()
662 .filter(|existing| existing.seq == record.seq)
663 {
664 if existing != record {
665 return Err(BudgetStoreError::Invariant(format!(
666 "budget usage `{}` grant {} reused sequence {} with different counters",
667 record.capability_id, record.grant_index, record.seq
668 )));
669 }
670 return Ok(());
671 }
672 let seq = budget_u64_to_sqlite(record.seq, "seq")?;
673 let total_cost_exposed =
674 budget_u64_to_sqlite(record.total_cost_exposed, "total_cost_exposed")?;
675 let total_cost_realized_spend = budget_u64_to_sqlite(
676 record.total_cost_realized_spend,
677 "total_cost_realized_spend",
678 )?;
679 raise_budget_replication_seq_floor(transaction, record.seq)?;
680 transaction.execute(
681 r#"
682 INSERT INTO capability_grant_budgets (
683 capability_id,
684 grant_index,
685 invocation_count,
686 updated_at,
687 seq,
688 total_cost_exposed,
689 total_cost_realized_spend
690 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
691 ON CONFLICT(capability_id, grant_index) DO UPDATE SET
692 invocation_count = CASE
693 WHEN excluded.seq > capability_grant_budgets.seq
694 THEN excluded.invocation_count
695 ELSE capability_grant_budgets.invocation_count
696 END,
697 updated_at = CASE
698 WHEN excluded.seq > capability_grant_budgets.seq
699 THEN excluded.updated_at
700 ELSE capability_grant_budgets.updated_at
701 END,
702 total_cost_exposed = CASE
703 WHEN excluded.seq > capability_grant_budgets.seq
704 THEN excluded.total_cost_exposed
705 ELSE capability_grant_budgets.total_cost_exposed
706 END,
707 total_cost_realized_spend = CASE
708 WHEN excluded.seq > capability_grant_budgets.seq
709 THEN excluded.total_cost_realized_spend
710 ELSE capability_grant_budgets.total_cost_realized_spend
711 END,
712 seq = MAX(capability_grant_budgets.seq, excluded.seq)
713 "#,
714 params![
715 &record.capability_id,
716 i64::from(record.grant_index),
717 i64::from(record.invocation_count),
718 record.updated_at,
719 seq,
720 total_cost_exposed,
721 total_cost_realized_spend,
722 ],
723 )?;
724 Ok(())
725 }
726
727 #[cfg(test)]
728 pub(crate) fn delete_mutation_event(&self, event_id: &str) -> Result<(), BudgetStoreError> {
729 self.require_standalone_mutation("budget mutation event deletion")?;
730 let mut connection = self.connection()?;
731 let transaction = self.begin_write(&mut connection)?;
732 transaction.execute(
733 "DELETE FROM budget_mutation_events WHERE event_id = ?1",
734 params![event_id],
735 )?;
736 Self::reset_budget_ack_head_watermark(&transaction)?;
737 transaction.commit()?;
738 Ok(())
739 }
740
741 pub fn hold_authority(
742 &self,
743 hold_id: &str,
744 ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
745 let mut connection = self.connection()?;
746 let transaction = self.begin_read(&mut connection)?;
747 let authority = Self::load_hold(&transaction, hold_id)?.and_then(|hold| hold.authority);
748 transaction.rollback()?;
749 Ok(authority)
750 }
751
752 pub fn import_mutation_record(
753 &self,
754 record: &BudgetMutationRecord,
755 ) -> Result<(), BudgetStoreError> {
756 self.require_standalone_mutation("budget mutation record import")?;
757 let mut connection = self.connection()?;
758 let transaction = self.begin_write(&mut connection)?;
759 Self::import_mutation_record_in_transaction(&transaction, record)?;
760 Self::reconcile_imported_usages(&transaction, &[], std::slice::from_ref(record))?;
761 transaction.commit()?;
762 Ok(())
763 }
764
765 pub(super) fn import_mutation_record_in_transaction(
766 transaction: &rusqlite::Transaction<'_>,
767 record: &BudgetMutationRecord,
768 ) -> Result<(), BudgetStoreError> {
769 Self::validate_import_record_sqlite_range(record)?;
770 let mut normalized = record.clone();
771 if legacy_event_lifecycle_is_unset(&normalized) {
772 let lifecycle = imported_event_lifecycle(transaction, &normalized)?;
773 normalized.authorization_outcome = lifecycle.0;
774 normalized.invocation_state_before = lifecycle.1;
775 normalized.invocation_state_after = lifecycle.2;
776 normalized.monetary_state_before = lifecycle.3;
777 normalized.monetary_state_after = lifecycle.4;
778 }
779 Self::validate_supported_import_record(&normalized)?;
780 let record = &normalized;
781 if let Some(hold_id) = record.hold_id.as_deref() {
782 Self::reject_structured_hold_from_legacy_writer(
783 transaction,
784 Some(hold_id),
785 "budget mutation import",
786 )?;
787 }
788
789 let duplicate_event =
790 if let Some(existing) = Self::load_mutation_event(transaction, &record.event_id)? {
791 if Self::same_imported_mutation(&existing, record) {
792 true
793 } else {
794 return Err(BudgetStoreError::Invariant(format!(
795 "budget event_id `{}` was reused for a different mutation",
796 record.event_id
797 )));
798 }
799 } else {
800 Self::validate_import_hold_frontier(transaction, record)?;
801 Self::validate_import_event_usage_chain(transaction, record)?;
802 Self::insert_imported_mutation_event(transaction, record)?;
803 false
804 };
805 if duplicate_event {
806 return Ok(());
807 }
808
809 raise_budget_replication_seq_floor(transaction, record.event_seq)?;
810 if let Some(usage_seq) = record.usage_seq {
811 raise_budget_replication_seq_floor(transaction, usage_seq)?;
812 }
813 Self::apply_imported_hold_state(transaction, record)?;
814 Ok(())
815 }
816
817 fn validate_supported_import_record(
818 record: &BudgetMutationRecord,
819 ) -> Result<(), BudgetStoreError> {
820 let unsupported_kind = matches!(
821 record.kind,
822 BudgetMutationKind::ReserveInvocation
823 | BudgetMutationKind::AuthorizeCumulativeApproval
824 | BudgetMutationKind::ReverseInvocation
825 | BudgetMutationKind::CaptureSpend
826 );
827 let composite_projection = record.admission_binding.is_some()
828 || !record.invocation_quota_usages.is_empty()
829 || !record.invocation_quota_mutations.is_empty()
830 || record.cumulative_approval.is_some()
831 || record.cumulative_approval_mutation.is_some()
832 || record.cumulative_approval_set_digest.is_some();
833 if unsupported_kind || composite_projection {
834 return Err(BudgetStoreError::Invariant(format!(
835 "budget mutation `{}` uses state unsupported by the sqlite budget store",
836 record.kind.as_str()
837 )));
838 }
839 validate_legacy_event_lifecycle(record)?;
840 Self::validate_import_event_shape(record)?;
841 Ok(())
842 }
843
844 fn validate_import_record_sqlite_range(
845 record: &BudgetMutationRecord,
846 ) -> Result<(), BudgetStoreError> {
847 if record.event_seq == 0 || record.usage_seq == Some(0) {
848 return Err(BudgetStoreError::Invariant(
849 "imported budget sequences must be positive".to_string(),
850 ));
851 }
852 if record
853 .usage_seq
854 .is_some_and(|usage_seq| usage_seq > record.event_seq)
855 {
856 return Err(BudgetStoreError::Invariant(
857 "imported budget usage sequence exceeds its event sequence".to_string(),
858 ));
859 }
860 budget_u64_to_sqlite(record.event_seq, "event_seq")?;
861 optional_budget_u64_to_sqlite(record.usage_seq, "usage_seq")?;
862 budget_u64_to_sqlite(record.exposure_units, "exposure_units")?;
863 budget_u64_to_sqlite(record.realized_spend_units, "realized_spend_units")?;
864 optional_budget_u64_to_sqlite(
865 record.max_cost_per_invocation,
866 "max_exposure_per_invocation",
867 )?;
868 optional_budget_u64_to_sqlite(record.max_total_cost_units, "max_total_exposure_units")?;
869 budget_u64_to_sqlite(record.total_cost_exposed_after, "total_cost_exposed_after")?;
870 budget_u64_to_sqlite(
871 record.total_cost_realized_spend_after,
872 "total_cost_realized_spend_after",
873 )?;
874 if let Some(authority) = record.authority.as_ref() {
875 budget_u64_to_sqlite(authority.lease_epoch, "lease_epoch")?;
876 }
877 Ok(())
878 }
879
880 fn insert_imported_mutation_event(
888 transaction: &rusqlite::Transaction<'_>,
889 record: &BudgetMutationRecord,
890 ) -> Result<(), BudgetStoreError> {
891 transaction.execute(
892 r#"
893 INSERT INTO budget_mutation_events (
894 event_id,
895 hold_id,
896 capability_id,
897 grant_index,
898 kind,
899 allowed,
900 recorded_at,
901 event_seq,
902 usage_seq,
903 exposure_units,
904 realized_spend_units,
905 max_invocations,
906 max_exposure_per_invocation,
907 max_total_exposure_units,
908 invocation_count_after,
909 total_cost_exposed_after,
910 total_cost_realized_spend_after,
911 authority_id,
912 lease_id,
913 lease_epoch,
914 authorization_outcome,
915 invocation_state_before,
916 invocation_state_after,
917 monetary_state_before,
918 monetary_state_after
919 ) VALUES (
920 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
921 ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20,
922 ?21, ?22, ?23, ?24, ?25
923 )
924 "#,
925 params![
926 record.event_id,
927 record.hold_id,
928 record.capability_id,
929 i64::from(record.grant_index),
930 record.kind.as_str(),
931 record
932 .allowed
933 .map(|value| if value { 1_i64 } else { 0_i64 }),
934 record.recorded_at,
935 budget_u64_to_sqlite(record.event_seq, "event_seq")?,
936 optional_budget_u64_to_sqlite(record.usage_seq, "usage_seq")?,
937 budget_u64_to_sqlite(record.exposure_units, "exposure_units")?,
938 budget_u64_to_sqlite(record.realized_spend_units, "realized_spend_units")?,
939 record.max_invocations.map(i64::from),
940 optional_budget_u64_to_sqlite(
941 record.max_cost_per_invocation,
942 "max_exposure_per_invocation",
943 )?,
944 optional_budget_u64_to_sqlite(
945 record.max_total_cost_units,
946 "max_total_exposure_units",
947 )?,
948 i64::from(record.invocation_count_after),
949 budget_u64_to_sqlite(record.total_cost_exposed_after, "total_cost_exposed_after",)?,
950 budget_u64_to_sqlite(
951 record.total_cost_realized_spend_after,
952 "total_cost_realized_spend_after",
953 )?,
954 record
955 .authority
956 .as_ref()
957 .map(|value| value.authority_id.as_str()),
958 record
959 .authority
960 .as_ref()
961 .map(|value| value.lease_id.as_str()),
962 record
963 .authority
964 .as_ref()
965 .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
966 .transpose()?,
967 record
968 .authorization_outcome
969 .map(budget_authorization_outcome_text),
970 budget_invocation_state_text(record.invocation_state_before),
971 budget_invocation_state_text(record.invocation_state_after),
972 budget_monetary_state_text(record.monetary_state_before),
973 budget_monetary_state_text(record.monetary_state_after),
974 ],
975 )?;
976 Ok(())
977 }
978
979 fn same_imported_mutation(
980 existing: &BudgetMutationRecord,
981 imported: &BudgetMutationRecord,
982 ) -> bool {
983 existing.event_id == imported.event_id
984 && existing.event_seq == imported.event_seq
985 && existing.usage_seq == imported.usage_seq
986 && existing.recorded_at == imported.recorded_at
987 && existing.hold_id == imported.hold_id
988 && existing.capability_id == imported.capability_id
989 && existing.grant_index == imported.grant_index
990 && existing.kind == imported.kind
991 && existing.allowed == imported.allowed
992 && existing.authorization_outcome == imported.authorization_outcome
993 && existing.invocation_state_before == imported.invocation_state_before
994 && existing.invocation_state_after == imported.invocation_state_after
995 && existing.monetary_state_before == imported.monetary_state_before
996 && existing.monetary_state_after == imported.monetary_state_after
997 && existing.exposure_units == imported.exposure_units
998 && existing.realized_spend_units == imported.realized_spend_units
999 && existing.max_invocations == imported.max_invocations
1000 && existing.max_cost_per_invocation == imported.max_cost_per_invocation
1001 && existing.max_total_cost_units == imported.max_total_cost_units
1002 && existing.invocation_count_after == imported.invocation_count_after
1003 && existing.total_cost_exposed_after == imported.total_cost_exposed_after
1004 && existing.total_cost_realized_spend_after == imported.total_cost_realized_spend_after
1005 && existing.authority == imported.authority
1006 }
1007
1008 pub fn list_usages_after(
1009 &self,
1010 limit: usize,
1011 after_seq: Option<u64>,
1012 ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
1013 let after_seq = optional_budget_u64_to_sqlite(after_seq, "after_seq")?;
1014 let mut connection = self.connection()?;
1015 let transaction = self.begin_read(&mut connection)?;
1016 let mut statement = transaction.prepare(
1017 r#"
1018 SELECT
1019 capability_id,
1020 grant_index,
1021 invocation_count,
1022 updated_at,
1023 seq,
1024 total_cost_exposed,
1025 total_cost_realized_spend
1026 FROM capability_grant_budgets
1027 WHERE (?1 IS NULL OR seq > ?1)
1028 ORDER BY seq ASC
1029 LIMIT ?2
1030 "#,
1031 )?;
1032 let rows = statement.query_map(params![after_seq, limit as i64], record_from_row)?;
1033 let rows = rows.collect::<Result<Vec<_>, _>>()?;
1034 drop(statement);
1035 transaction.rollback()?;
1036 Ok(rows)
1037 }
1038
1039 pub fn list_all_usages(&self) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
1040 let mut connection = self.connection()?;
1041 let transaction = self.begin_read(&mut connection)?;
1042 let mut statement = transaction.prepare(
1043 r#"
1044 SELECT
1045 capability_id,
1046 grant_index,
1047 invocation_count,
1048 updated_at,
1049 seq,
1050 total_cost_exposed,
1051 total_cost_realized_spend
1052 FROM capability_grant_budgets
1053 ORDER BY updated_at DESC, capability_id ASC, grant_index ASC
1054 "#,
1055 )?;
1056 let rows = statement.query_map([], record_from_row)?;
1057 let rows = rows.collect::<Result<Vec<_>, _>>()?;
1058 drop(statement);
1059 transaction.rollback()?;
1060 Ok(rows)
1061 }
1062
1063 pub fn list_mutation_events_after_seq(
1064 &self,
1065 limit: usize,
1066 after_event_seq: u64,
1067 ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
1068 let after_event_seq = budget_u64_to_sqlite(after_event_seq, "after_event_seq")?;
1069 let mut connection = self.connection()?;
1070 let transaction = self.begin_read(&mut connection)?;
1071 let mut statement = transaction.prepare(
1072 r#"
1073 SELECT event_id
1074 FROM budget_mutation_events
1075 WHERE event_seq > ?1
1076 ORDER BY event_seq ASC
1077 LIMIT ?2
1078 "#,
1079 )?;
1080 let event_ids = statement
1081 .query_map(params![after_event_seq, limit as i64], |row| {
1082 row.get::<_, String>(0)
1083 })?
1084 .collect::<Result<Vec<_>, _>>()?;
1085 drop(statement);
1086 let events = event_ids
1087 .iter()
1088 .map(|event_id| {
1089 Self::load_projected_mutation_event(&transaction, event_id)?.ok_or_else(|| {
1090 BudgetStoreError::Invariant(format!(
1091 "budget mutation event `{event_id}` disappeared while listing"
1092 ))
1093 })
1094 })
1095 .collect::<Result<Vec<_>, _>>()?;
1096 transaction.rollback()?;
1097 Ok(events)
1098 }
1099
1100 fn generated_event_id(
1101 transaction: &rusqlite::Transaction<'_>,
1102 ) -> Result<String, BudgetStoreError> {
1103 let count =
1104 transaction.query_row("SELECT COUNT(*) FROM budget_mutation_events", [], |row| {
1105 row.get::<_, i64>(0)
1106 })?;
1107 Ok(format!(
1108 "sqlite-budget-event-{}-{}",
1109 unix_now(),
1110 count.max(0) + 1
1111 ))
1112 }
1113
1114 pub(super) fn load_hold(
1115 transaction: &rusqlite::Transaction<'_>,
1116 hold_id: &str,
1117 ) -> Result<Option<SqliteBudgetHold>, BudgetStoreError> {
1118 transaction
1119 .query_row(
1120 r#"
1121 SELECT
1122 hold_id,
1123 capability_id,
1124 grant_index,
1125 authorized_exposure_units,
1126 remaining_exposure_units,
1127 invocation_count_debited,
1128 invocation_captured,
1129 disposition,
1130 authority_id,
1131 lease_id,
1132 lease_epoch
1133 FROM budget_authorization_holds
1134 WHERE hold_id = ?1
1135 "#,
1136 params![hold_id],
1137 |row| {
1138 let disposition = row.get::<_, String>(7)?;
1139 let authority =
1140 sqlite_budget_event_authority(row.get(8)?, row.get(9)?, row.get(10)?)?;
1141 Ok(SqliteBudgetHold {
1142 hold_id: row.get(0)?,
1143 capability_id: row.get(1)?,
1144 grant_index: budget_usize_from_row(row, 2, "grant_index")?,
1145 authorized_exposure_units: budget_u64_from_row(
1146 row,
1147 3,
1148 "authorized_exposure_units",
1149 )?,
1150 remaining_exposure_units: budget_u64_from_row(
1151 row,
1152 4,
1153 "remaining_exposure_units",
1154 )?,
1155 invocation_count_debited: row.get::<_, i64>(5)? > 0,
1156 invocation_captured: row.get::<_, i64>(6)? > 0,
1157 disposition: HoldDisposition::parse(&disposition).ok_or_else(|| {
1158 rusqlite::Error::FromSqlConversionFailure(
1159 7,
1160 rusqlite::types::Type::Text,
1161 Box::new(std::io::Error::new(
1162 std::io::ErrorKind::InvalidData,
1163 format!("unknown hold disposition `{disposition}`"),
1164 )),
1165 )
1166 })?,
1167 authority,
1168 })
1169 },
1170 )
1171 .optional()
1172 .map_err(Into::into)
1173 }
1174
1175 pub(super) fn has_captured_hold(
1176 transaction: &rusqlite::Transaction<'_>,
1177 capability_id: &str,
1178 grant_index: usize,
1179 ) -> Result<bool, BudgetStoreError> {
1180 transaction
1181 .query_row(
1182 r#"
1183 SELECT EXISTS(
1184 SELECT 1
1185 FROM budget_authorization_holds
1186 WHERE capability_id = ?1
1187 AND grant_index = ?2
1188 AND invocation_captured = 1
1189 )
1190 "#,
1191 params![capability_id, grant_index as i64],
1192 |row| row.get(0),
1193 )
1194 .map_err(Into::into)
1195 }
1196
1197 pub(super) fn has_live_hold(
1198 transaction: &rusqlite::Transaction<'_>,
1199 capability_id: &str,
1200 grant_index: usize,
1201 ) -> Result<bool, BudgetStoreError> {
1202 transaction
1203 .query_row(
1204 r#"
1205 SELECT EXISTS(
1206 SELECT 1
1207 FROM budget_authorization_holds
1208 WHERE capability_id = ?1
1209 AND grant_index = ?2
1210 AND invocation_count_debited = 1
1211 AND disposition != 'reversed'
1212 )
1213 "#,
1214 params![capability_id, grant_index as i64],
1215 |row| row.get(0),
1216 )
1217 .map_err(Into::into)
1218 }
1219
1220 pub(super) fn load_mutation_event(
1221 connection: &Connection,
1222 event_id: &str,
1223 ) -> Result<Option<BudgetMutationRecord>, BudgetStoreError> {
1224 connection
1225 .query_row(
1226 r#"
1227 SELECT
1228 event_id,
1229 hold_id,
1230 capability_id,
1231 grant_index,
1232 kind,
1233 allowed,
1234 recorded_at,
1235 event_seq,
1236 usage_seq,
1237 exposure_units,
1238 realized_spend_units,
1239 max_invocations,
1240 max_exposure_per_invocation,
1241 max_total_exposure_units,
1242 invocation_count_after,
1243 total_cost_exposed_after,
1244 total_cost_realized_spend_after,
1245 authority_id,
1246 lease_id,
1247 lease_epoch,
1248 authorization_outcome,
1249 invocation_state_before,
1250 invocation_state_after,
1251 monetary_state_before,
1252 monetary_state_after
1253 FROM budget_mutation_events
1254 WHERE event_id = ?1
1255 "#,
1256 params![event_id],
1257 mutation_record_from_row,
1258 )
1259 .optional()
1260 .map_err(Into::into)
1261 }
1262
1263 pub(super) fn create_hold(
1264 transaction: &rusqlite::Transaction<'_>,
1265 hold_id: &str,
1266 capability_id: &str,
1267 grant_index: usize,
1268 authorized_exposure_units: u64,
1269 authority: Option<&BudgetEventAuthority>,
1270 ) -> Result<(), BudgetStoreError> {
1271 let now = unix_now();
1272 transaction.execute(
1273 r#"
1274 INSERT INTO budget_authorization_holds (
1275 hold_id,
1276 capability_id,
1277 grant_index,
1278 authorized_exposure_units,
1279 remaining_exposure_units,
1280 invocation_count_debited,
1281 invocation_captured,
1282 disposition,
1283 authority_id,
1284 lease_id,
1285 lease_epoch,
1286 created_at,
1287 updated_at
1288 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 0, ?6, ?7, ?8, ?9, ?10, ?10)
1289 "#,
1290 params![
1291 hold_id,
1292 capability_id,
1293 grant_index as i64,
1294 budget_u64_to_sqlite(authorized_exposure_units, "authorized_exposure_units",)?,
1295 budget_u64_to_sqlite(authorized_exposure_units, "remaining_exposure_units",)?,
1296 HoldDisposition::Open.as_str(),
1297 authority.map(|value| value.authority_id.as_str()),
1298 authority.map(|value| value.lease_id.as_str()),
1299 authority
1300 .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1301 .transpose()?,
1302 now,
1303 ],
1304 )?;
1305 Ok(())
1306 }
1307
1308 pub(super) fn update_hold(
1309 transaction: &rusqlite::Transaction<'_>,
1310 hold_id: &str,
1311 remaining_exposure_units: u64,
1312 disposition: HoldDisposition,
1313 authority: Option<&BudgetEventAuthority>,
1314 ) -> Result<(), BudgetStoreError> {
1315 transaction.execute(
1316 r#"
1317 UPDATE budget_authorization_holds
1318 SET remaining_exposure_units = ?2,
1319 disposition = ?3,
1320 authority_id = ?4,
1321 lease_id = ?5,
1322 lease_epoch = ?6,
1323 updated_at = ?7
1324 WHERE hold_id = ?1
1325 "#,
1326 params![
1327 hold_id,
1328 budget_u64_to_sqlite(remaining_exposure_units, "remaining_exposure_units",)?,
1329 disposition.as_str(),
1330 authority.map(|value| value.authority_id.as_str()),
1331 authority.map(|value| value.lease_id.as_str()),
1332 authority
1333 .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1334 .transpose()?,
1335 unix_now(),
1336 ],
1337 )?;
1338 Ok(())
1339 }
1340
1341 #[allow(clippy::too_many_arguments)]
1342 pub(super) fn upsert_hold(
1343 transaction: &rusqlite::Transaction<'_>,
1344 hold_id: &str,
1345 capability_id: &str,
1346 grant_index: usize,
1347 authorized_exposure_units: u64,
1348 remaining_exposure_units: u64,
1349 invocation_captured: bool,
1350 disposition: HoldDisposition,
1351 authority: Option<&BudgetEventAuthority>,
1352 ) -> Result<(), BudgetStoreError> {
1353 let now = unix_now();
1354 transaction.execute(
1355 r#"
1356 INSERT INTO budget_authorization_holds (
1357 hold_id,
1358 capability_id,
1359 grant_index,
1360 authorized_exposure_units,
1361 remaining_exposure_units,
1362 invocation_count_debited,
1363 invocation_captured,
1364 disposition,
1365 authority_id,
1366 lease_id,
1367 lease_epoch,
1368 created_at,
1369 updated_at
1370 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
1371 ON CONFLICT(hold_id) DO UPDATE SET
1372 capability_id = excluded.capability_id,
1373 grant_index = excluded.grant_index,
1374 authorized_exposure_units = excluded.authorized_exposure_units,
1375 remaining_exposure_units = excluded.remaining_exposure_units,
1376 invocation_count_debited = excluded.invocation_count_debited,
1377 invocation_captured = excluded.invocation_captured,
1378 disposition = excluded.disposition,
1379 authority_id = excluded.authority_id,
1380 lease_id = excluded.lease_id,
1381 lease_epoch = excluded.lease_epoch,
1382 updated_at = excluded.updated_at
1383 "#,
1384 params![
1385 hold_id,
1386 capability_id,
1387 grant_index as i64,
1388 budget_u64_to_sqlite(authorized_exposure_units, "authorized_exposure_units",)?,
1389 budget_u64_to_sqlite(remaining_exposure_units, "remaining_exposure_units",)?,
1390 if invocation_captured { 1_i64 } else { 0_i64 },
1391 disposition.as_str(),
1392 authority.map(|value| value.authority_id.as_str()),
1393 authority.map(|value| value.lease_id.as_str()),
1394 authority
1395 .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1396 .transpose()?,
1397 now,
1398 ],
1399 )?;
1400 Ok(())
1401 }
1402
1403 pub(super) fn delete_hold_if_exists(
1404 transaction: &rusqlite::Transaction<'_>,
1405 hold_id: &str,
1406 ) -> Result<(), BudgetStoreError> {
1407 transaction.execute(
1408 "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
1409 params![hold_id],
1410 )?;
1411 Ok(())
1412 }
1413
1414 pub(super) fn ensure_open_hold(
1415 transaction: &rusqlite::Transaction<'_>,
1416 hold_id: &str,
1417 capability_id: &str,
1418 grant_index: usize,
1419 ) -> Result<SqliteBudgetHold, BudgetStoreError> {
1420 let hold = Self::load_hold(transaction, hold_id)?.ok_or_else(|| {
1421 BudgetStoreError::Invariant(format!("missing budget hold `{hold_id}`"))
1422 })?;
1423 if hold.capability_id != capability_id || hold.grant_index != grant_index {
1424 return Err(BudgetStoreError::Invariant(format!(
1425 "budget hold `{hold_id}` does not match capability/grant"
1426 )));
1427 }
1428 if hold.disposition != HoldDisposition::Open {
1429 return Err(BudgetStoreError::Invariant(format!(
1430 "budget hold `{hold_id}` is no longer open"
1431 )));
1432 }
1433 Ok(hold)
1434 }
1435
1436 pub(super) fn validate_hold_authority(
1437 hold_id: &str,
1438 current: Option<&BudgetEventAuthority>,
1439 requested: Option<&BudgetEventAuthority>,
1440 ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
1441 match (current, requested) {
1442 (None, None) => Ok(None),
1443 (None, Some(_)) => Err(BudgetStoreError::Invariant(format!(
1444 "budget hold `{hold_id}` was created without authority lease metadata"
1445 ))),
1446 (Some(_), None) => Err(BudgetStoreError::Invariant(format!(
1447 "budget hold `{hold_id}` requires authority lease metadata"
1448 ))),
1449 (Some(current), Some(requested)) => {
1450 if current.authority_id != requested.authority_id {
1451 return Err(BudgetStoreError::Invariant(format!(
1452 "budget hold `{hold_id}` authority_id does not match the open lease"
1453 )));
1454 }
1455 if requested.lease_id != current.lease_id {
1456 return Err(BudgetStoreError::Invariant(format!(
1457 "budget hold `{hold_id}` lease_id does not match the open lease epoch"
1458 )));
1459 }
1460 if requested.lease_epoch < current.lease_epoch {
1461 return Err(BudgetStoreError::Invariant(format!(
1462 "budget hold `{hold_id}` authority lease epoch regressed"
1463 )));
1464 }
1465 if requested.lease_epoch > current.lease_epoch {
1466 return Err(BudgetStoreError::Invariant(format!(
1467 "budget hold `{hold_id}` authority lease epoch advanced beyond the open lease"
1468 )));
1469 }
1470 Ok(Some(requested.clone()))
1471 }
1472 }
1473 }
1474
1475 pub(super) fn validate_replay_authority(
1476 event_id: &str,
1477 persisted: Option<&BudgetEventAuthority>,
1478 requested: Option<&BudgetEventAuthority>,
1479 ) -> Result<(), BudgetStoreError> {
1480 if persisted == requested {
1481 return Ok(());
1482 }
1483 Err(BudgetStoreError::Invariant(format!(
1484 "budget event_id `{event_id}` authority metadata does not match the original mutation"
1485 )))
1486 }
1487
1488 fn existing_increment_allowed(
1489 transaction: &rusqlite::Transaction<'_>,
1490 event_id: Option<&str>,
1491 capability_id: &str,
1492 grant_index: usize,
1493 max_invocations: Option<u32>,
1494 ) -> Result<Option<bool>, BudgetStoreError> {
1495 let Some(event_id) = event_id else {
1496 return Ok(None);
1497 };
1498 let existing = transaction
1499 .query_row(
1500 r#"
1501 SELECT capability_id, grant_index, kind, allowed, max_invocations
1502 FROM budget_mutation_events
1503 WHERE event_id = ?1
1504 "#,
1505 params![event_id],
1506 |row| {
1507 Ok((
1508 row.get::<_, String>(0)?,
1509 budget_usize_from_row(row, 1, "grant_index")?,
1510 row.get::<_, String>(2)?,
1511 row.get::<_, Option<i64>>(3)?,
1512 optional_budget_u32_from_row(row, 4, "max_invocations")?,
1513 ))
1514 },
1515 )
1516 .optional()?;
1517 let Some((
1518 existing_capability_id,
1519 existing_grant_index,
1520 existing_kind,
1521 existing_allowed,
1522 existing_max_invocations,
1523 )) = existing
1524 else {
1525 return Ok(None);
1526 };
1527 let mutation_matches = existing_capability_id == capability_id
1528 && existing_grant_index == grant_index
1529 && existing_kind == BudgetMutationKind::IncrementInvocation.as_str()
1530 && existing_max_invocations == max_invocations;
1531 if !mutation_matches {
1532 return Err(BudgetStoreError::Invariant(format!(
1533 "budget event_id `{event_id}` was reused for a different mutation"
1534 )));
1535 }
1536 Ok(Some(existing_allowed.unwrap_or(0) > 0))
1537 }
1538
1539 #[allow(clippy::too_many_arguments)]
1540 pub(super) fn existing_event_allowed(
1541 transaction: &rusqlite::Transaction<'_>,
1542 event_id: Option<&str>,
1543 kind: BudgetMutationKind,
1544 capability_id: &str,
1545 grant_index: usize,
1546 hold_id: Option<&str>,
1547 authority: Option<&BudgetEventAuthority>,
1548 exposure_units: u64,
1549 realized_spend_units: u64,
1550 max_invocations: Option<u32>,
1551 max_cost_per_invocation: Option<u64>,
1552 max_total_cost_units: Option<u64>,
1553 ) -> Result<Option<Option<bool>>, BudgetStoreError> {
1554 let Some(event_id) = event_id else {
1555 return Ok(None);
1556 };
1557 let existing = transaction
1558 .query_row(
1559 r#"
1560 SELECT
1561 hold_id,
1562 capability_id,
1563 grant_index,
1564 kind,
1565 allowed,
1566 exposure_units,
1567 realized_spend_units,
1568 max_invocations,
1569 max_exposure_per_invocation,
1570 max_total_exposure_units
1571 FROM budget_mutation_events
1572 WHERE event_id = ?1
1573 "#,
1574 params![event_id],
1575 |row| {
1576 Ok((
1577 row.get::<_, Option<String>>(0)?,
1578 row.get::<_, String>(1)?,
1579 budget_usize_from_row(row, 2, "grant_index")?,
1580 row.get::<_, String>(3)?,
1581 row.get::<_, Option<i64>>(4)?,
1582 budget_u64_from_row(row, 5, "exposure_units")?,
1583 budget_u64_from_row(row, 6, "realized_spend_units")?,
1584 optional_budget_u32_from_row(row, 7, "max_invocations")?,
1585 optional_budget_u64_from_row(row, 8, "max_exposure_per_invocation")?,
1586 optional_budget_u64_from_row(row, 9, "max_total_exposure_units")?,
1587 ))
1588 },
1589 )
1590 .optional()?;
1591 let Some((
1592 existing_hold_id,
1593 existing_capability_id,
1594 existing_grant_index,
1595 existing_kind,
1596 existing_allowed,
1597 existing_exposure_units,
1598 existing_realized_spend_units,
1599 existing_max_invocations,
1600 existing_max_exposure_per_invocation,
1601 existing_max_total_exposure_units,
1602 )) = existing
1603 else {
1604 return Ok(None);
1605 };
1606 let max_invocations_matches = existing_max_invocations == max_invocations;
1607 let max_per_matches = existing_max_exposure_per_invocation == max_cost_per_invocation;
1608 let max_total_matches = existing_max_total_exposure_units == max_total_cost_units;
1609 let mutation_matches = existing_capability_id == capability_id
1610 && existing_grant_index == grant_index
1611 && existing_kind == kind.as_str()
1612 && existing_hold_id.as_deref() == hold_id
1613 && existing_exposure_units == exposure_units
1614 && existing_realized_spend_units == realized_spend_units
1615 && max_invocations_matches
1616 && max_per_matches
1617 && max_total_matches;
1618 let existing_allowed = existing_allowed.map(|value| value > 0);
1619 let existing_record =
1620 Self::load_mutation_event(transaction, event_id)?.ok_or_else(|| {
1621 BudgetStoreError::Invariant(
1622 "budget mutation event disappeared during idempotency check".to_string(),
1623 )
1624 })?;
1625 if !mutation_matches {
1626 return Err(BudgetStoreError::Invariant(format!(
1627 "budget event_id `{event_id}` was reused for a different mutation"
1628 )));
1629 }
1630 Self::validate_replay_authority(event_id, existing_record.authority.as_ref(), authority)?;
1631 Ok(Some(existing_allowed))
1632 }
1633
1634 #[allow(clippy::too_many_arguments)]
1635 pub(super) fn append_mutation_event(
1636 transaction: &rusqlite::Transaction<'_>,
1637 event_id: Option<&str>,
1638 hold_id: Option<&str>,
1639 authority: Option<&BudgetEventAuthority>,
1640 capability_id: &str,
1641 grant_index: usize,
1642 kind: BudgetMutationKind,
1643 allowed: Option<bool>,
1644 event_seq: u64,
1645 usage_seq: Option<u64>,
1646 exposure_units: u64,
1647 realized_spend_units: u64,
1648 max_invocations: Option<u32>,
1649 max_cost_per_invocation: Option<u64>,
1650 max_total_cost_units: Option<u64>,
1651 invocation_count_after: u32,
1652 total_cost_exposed_after: u64,
1653 total_cost_realized_spend_after: u64,
1654 ) -> Result<BudgetMutationRecord, BudgetStoreError> {
1655 validate_budget_grant_index(grant_index)?;
1656 let event_id = match event_id {
1657 Some(event_id) => event_id.to_string(),
1658 None => Self::generated_event_id(transaction)?,
1659 };
1660 let recorded_at = unix_now();
1661 let (
1662 authorization_outcome,
1663 invocation_state_before,
1664 invocation_state_after,
1665 monetary_state_before,
1666 monetary_state_after,
1667 ) = appended_event_lifecycle(transaction, hold_id, kind, allowed, exposure_units)?;
1668 transaction.execute(
1669 r#"
1670 INSERT INTO budget_mutation_events (
1671 event_id,
1672 hold_id,
1673 capability_id,
1674 grant_index,
1675 kind,
1676 allowed,
1677 recorded_at,
1678 event_seq,
1679 usage_seq,
1680 exposure_units,
1681 realized_spend_units,
1682 max_invocations,
1683 max_exposure_per_invocation,
1684 max_total_exposure_units,
1685 invocation_count_after,
1686 total_cost_exposed_after,
1687 total_cost_realized_spend_after,
1688 authority_id,
1689 lease_id,
1690 lease_epoch,
1691 authorization_outcome,
1692 invocation_state_before,
1693 invocation_state_after,
1694 monetary_state_before,
1695 monetary_state_after
1696 ) VALUES (
1697 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
1698 ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20,
1699 ?21, ?22, ?23, ?24, ?25
1700 )
1701 "#,
1702 params![
1703 event_id,
1704 hold_id,
1705 capability_id,
1706 grant_index as i64,
1707 kind.as_str(),
1708 allowed.map(|value| if value { 1_i64 } else { 0_i64 }),
1709 recorded_at,
1710 budget_u64_to_sqlite(event_seq, "event_seq")?,
1711 optional_budget_u64_to_sqlite(usage_seq, "usage_seq")?,
1712 budget_u64_to_sqlite(exposure_units, "exposure_units")?,
1713 budget_u64_to_sqlite(realized_spend_units, "realized_spend_units")?,
1714 max_invocations.map(i64::from),
1715 optional_budget_u64_to_sqlite(
1716 max_cost_per_invocation,
1717 "max_exposure_per_invocation",
1718 )?,
1719 optional_budget_u64_to_sqlite(max_total_cost_units, "max_total_exposure_units",)?,
1720 i64::from(invocation_count_after),
1721 budget_u64_to_sqlite(total_cost_exposed_after, "total_cost_exposed_after",)?,
1722 budget_u64_to_sqlite(
1723 total_cost_realized_spend_after,
1724 "total_cost_realized_spend_after",
1725 )?,
1726 authority.map(|value| value.authority_id.as_str()),
1727 authority.map(|value| value.lease_id.as_str()),
1728 authority
1729 .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1730 .transpose()?,
1731 authorization_outcome.map(budget_authorization_outcome_text),
1732 budget_invocation_state_text(invocation_state_before),
1733 budget_invocation_state_text(invocation_state_after),
1734 budget_monetary_state_text(monetary_state_before),
1735 budget_monetary_state_text(monetary_state_after),
1736 ],
1737 )?;
1738 if usage_seq == Some(event_seq) {
1739 let changed = transaction.execute(
1740 r#"
1741 UPDATE capability_grant_budgets SET updated_at = ?3
1742 WHERE capability_id = ?1 AND grant_index = ?2 AND seq = ?4
1743 "#,
1744 params![
1745 capability_id,
1746 grant_index as i64,
1747 recorded_at,
1748 budget_u64_to_sqlite(event_seq, "event_seq")?,
1749 ],
1750 )?;
1751 if changed != 1 {
1752 return Err(BudgetStoreError::Invariant(format!(
1753 "budget event `{event_id}` has no matching usage projection"
1754 )));
1755 }
1756 }
1757 Ok(BudgetMutationRecord {
1758 event_id,
1759 hold_id: hold_id.map(ToOwned::to_owned),
1760 admission_binding: None,
1761 capability_id: capability_id.to_string(),
1762 grant_index: grant_index as u32,
1763 kind,
1764 allowed,
1765 authorization_outcome,
1766 invocation_state_before,
1767 invocation_state_after,
1768 monetary_state_before,
1769 monetary_state_after,
1770 recorded_at,
1771 event_seq,
1772 usage_seq,
1773 exposure_units,
1774 realized_spend_units,
1775 max_invocations,
1776 max_cost_per_invocation,
1777 max_total_cost_units,
1778 invocation_count_after,
1779 invocation_quota_usages: Vec::new(),
1780 invocation_quota_mutations: Vec::new(),
1781 cumulative_approval: None,
1782 cumulative_approval_mutation: None,
1783 cumulative_approval_set_digest: None,
1784 total_cost_exposed_after,
1785 total_cost_realized_spend_after,
1786 authority: authority.cloned(),
1787 })
1788 }
1789
1790 pub fn try_increment_with_event_id(
1791 &self,
1792 capability_id: &str,
1793 grant_index: usize,
1794 max_invocations: Option<u32>,
1795 event_id: Option<&str>,
1796 ) -> Result<bool, BudgetStoreError> {
1797 self.require_standalone_mutation("unbound invocation increment")?;
1798 validate_budget_grant_index(grant_index)?;
1799 let mut connection = self.connection()?;
1800 let transaction = self.begin_write(&mut connection)?;
1801 Self::reject_legacy_admission_after_composite_history(
1802 &transaction,
1803 capability_id,
1804 grant_index,
1805 )?;
1806
1807 if let Some(allowed) = SqliteBudgetStore::existing_increment_allowed(
1808 &transaction,
1809 event_id,
1810 capability_id,
1811 grant_index,
1812 max_invocations,
1813 )? {
1814 transaction.rollback()?;
1815 return Ok(allowed);
1816 }
1817
1818 let current: Option<(u32, u64, u64)> = transaction
1819 .query_row(
1820 r#"
1821 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1822 FROM capability_grant_budgets
1823 WHERE capability_id = ?1 AND grant_index = ?2
1824 "#,
1825 params![capability_id, grant_index as i64],
1826 |row| {
1827 Ok((
1828 budget_u32_from_row(row, 0, "invocation_count")?,
1829 budget_u64_from_row(row, 1, "total_cost_exposed")?,
1830 budget_u64_from_row(row, 2, "total_cost_realized_spend")?,
1831 ))
1832 },
1833 )
1834 .optional()?;
1835 let (current, total_cost_exposed, total_cost_realized_spend) = current.unwrap_or((0, 0, 0));
1836 let updated_at = unix_now();
1837
1838 if let Some(max) = max_invocations {
1839 if current >= max {
1840 let event_seq = allocate_budget_replication_seq(&transaction)?;
1841 SqliteBudgetStore::append_mutation_event(
1842 &transaction,
1843 event_id,
1844 None,
1845 None,
1846 capability_id,
1847 grant_index,
1848 BudgetMutationKind::IncrementInvocation,
1849 Some(false),
1850 event_seq,
1851 None,
1852 0,
1853 0,
1854 max_invocations,
1855 None,
1856 None,
1857 current,
1858 total_cost_exposed,
1859 total_cost_realized_spend,
1860 )?;
1861 transaction.commit()?;
1862 return Ok(false);
1863 }
1864 }
1865
1866 let invocation_count_after = current.checked_add(1).ok_or_else(|| {
1867 BudgetStoreError::Overflow("invocation count overflowed u32".to_string())
1868 })?;
1869 let seq = allocate_budget_replication_seq(&transaction)?;
1870 transaction.execute(
1871 r#"
1872 INSERT INTO capability_grant_budgets (
1873 capability_id,
1874 grant_index,
1875 invocation_count,
1876 updated_at,
1877 seq,
1878 total_cost_exposed,
1879 total_cost_realized_spend
1880 ) VALUES (?1, ?2, ?3, ?4, ?5, 0, 0)
1881 ON CONFLICT(capability_id, grant_index) DO UPDATE SET
1882 invocation_count = excluded.invocation_count,
1883 updated_at = excluded.updated_at,
1884 seq = excluded.seq
1885 "#,
1886 params![
1887 capability_id,
1888 grant_index as i64,
1889 i64::from(invocation_count_after),
1890 updated_at,
1891 budget_u64_to_sqlite(seq, "seq")?,
1892 ],
1893 )?;
1894 SqliteBudgetStore::append_mutation_event(
1895 &transaction,
1896 event_id,
1897 None,
1898 None,
1899 capability_id,
1900 grant_index,
1901 BudgetMutationKind::IncrementInvocation,
1902 Some(true),
1903 seq,
1904 Some(seq),
1905 0,
1906 0,
1907 max_invocations,
1908 None,
1909 None,
1910 invocation_count_after,
1911 total_cost_exposed,
1912 total_cost_realized_spend,
1913 )?;
1914 transaction.commit()?;
1915 Ok(true)
1916 }
1917}
1918
1919pub(super) fn map_serving_owner_error(
1920 error: crate::serving_owner::SqliteServingOwnerError,
1921) -> BudgetStoreError {
1922 match error {
1923 crate::serving_owner::SqliteServingOwnerError::OutcomeUnknown(detail) => {
1924 BudgetStoreError::OutcomeUnknown(detail)
1925 }
1926 error => BudgetStoreError::Invariant(error.to_string()),
1927 }
1928}