1use std::fs;
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use chio_kernel::budget_store::{BudgetEventAuthority, BudgetMutationKind, BudgetMutationRecord};
6use chio_kernel::{BudgetStore, BudgetStoreError, BudgetUsageRecord};
7use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
8
9pub struct SqliteBudgetStore {
10 connection: Connection,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14enum HoldDisposition {
15 Open,
16 Released,
17 Reversed,
18 Reconciled,
19}
20
21impl HoldDisposition {
22 fn as_str(&self) -> &'static str {
23 match self {
24 Self::Open => "open",
25 Self::Released => "released",
26 Self::Reversed => "reversed",
27 Self::Reconciled => "reconciled",
28 }
29 }
30
31 fn parse(value: &str) -> Option<Self> {
32 match value {
33 "open" => Some(Self::Open),
34 "released" => Some(Self::Released),
35 "reversed" => Some(Self::Reversed),
36 "reconciled" => Some(Self::Reconciled),
37 _ => None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43struct SqliteBudgetHold {
44 hold_id: String,
45 capability_id: String,
46 grant_index: usize,
47 authorized_exposure_units: u64,
48 remaining_exposure_units: u64,
49 invocation_count_debited: bool,
50 disposition: HoldDisposition,
51 authority: Option<BudgetEventAuthority>,
52}
53
54impl SqliteBudgetStore {
55 pub fn open(path: impl AsRef<Path>) -> Result<Self, BudgetStoreError> {
56 let path = path.as_ref();
57 if let Some(parent) = path.parent() {
58 fs::create_dir_all(parent)?;
59 }
60
61 let mut connection = Connection::open(path)?;
62 connection.execute_batch(
63 r#"
64 PRAGMA journal_mode = WAL;
65 PRAGMA synchronous = FULL;
66 PRAGMA busy_timeout = 5000;
67
68 CREATE TABLE IF NOT EXISTS capability_grant_budgets (
69 capability_id TEXT NOT NULL,
70 grant_index INTEGER NOT NULL,
71 invocation_count INTEGER NOT NULL,
72 updated_at INTEGER NOT NULL,
73 seq INTEGER NOT NULL DEFAULT 0,
74 total_cost_exposed INTEGER NOT NULL DEFAULT 0,
75 total_cost_realized_spend INTEGER NOT NULL DEFAULT 0,
76 PRIMARY KEY (capability_id, grant_index)
77 );
78
79 CREATE INDEX IF NOT EXISTS idx_capability_grant_budgets_updated_at
80 ON capability_grant_budgets(updated_at);
81
82 CREATE TABLE IF NOT EXISTS budget_replication_meta (
83 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
84 next_seq INTEGER NOT NULL
85 );
86
87 CREATE TABLE IF NOT EXISTS budget_authorization_holds (
88 hold_id TEXT PRIMARY KEY,
89 capability_id TEXT NOT NULL,
90 grant_index INTEGER NOT NULL,
91 authorized_exposure_units INTEGER NOT NULL,
92 remaining_exposure_units INTEGER NOT NULL,
93 invocation_count_debited INTEGER NOT NULL,
94 disposition TEXT NOT NULL,
95 authority_id TEXT,
96 lease_id TEXT,
97 lease_epoch INTEGER,
98 created_at INTEGER NOT NULL,
99 updated_at INTEGER NOT NULL
100 );
101
102 CREATE INDEX IF NOT EXISTS idx_budget_authorization_holds_capability
103 ON budget_authorization_holds(capability_id, grant_index);
104
105 CREATE TABLE IF NOT EXISTS budget_mutation_events (
106 event_id TEXT PRIMARY KEY,
107 hold_id TEXT,
108 capability_id TEXT NOT NULL,
109 grant_index INTEGER NOT NULL,
110 kind TEXT NOT NULL,
111 allowed INTEGER,
112 recorded_at INTEGER NOT NULL,
113 event_seq INTEGER,
114 usage_seq INTEGER,
115 exposure_units INTEGER NOT NULL DEFAULT 0,
116 realized_spend_units INTEGER NOT NULL DEFAULT 0,
117 max_invocations INTEGER,
118 max_exposure_per_invocation INTEGER,
119 max_total_exposure_units INTEGER,
120 invocation_count_after INTEGER NOT NULL,
121 total_cost_exposed_after INTEGER NOT NULL,
122 total_cost_realized_spend_after INTEGER NOT NULL,
123 authority_id TEXT,
124 lease_id TEXT,
125 lease_epoch INTEGER
126 );
127
128 CREATE INDEX IF NOT EXISTS idx_budget_mutation_events_capability
129 ON budget_mutation_events(capability_id, grant_index, recorded_at);
130
131 CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_mutation_events_event_seq
132 ON budget_mutation_events(event_seq);
133 "#,
134 )?;
135 connection.execute(
136 r#"
137 INSERT INTO budget_replication_meta (singleton, next_seq)
138 VALUES (1, 0)
139 ON CONFLICT(singleton) DO NOTHING
140 "#,
141 [],
142 )?;
143 ensure_budget_seq_column(&connection)?;
144 ensure_split_budget_cost_columns(&connection)?;
145 ensure_budget_hold_authority_columns(&connection)?;
146 ensure_budget_mutation_event_authority_columns(&connection)?;
147 ensure_budget_mutation_event_seq_column(&connection)?;
148 initialize_budget_replication_seq(&mut connection)?;
149
150 Ok(Self { connection })
151 }
152
153 pub fn upsert_usage(&mut self, record: &BudgetUsageRecord) -> Result<(), BudgetStoreError> {
154 let transaction = self
155 .connection
156 .transaction_with_behavior(TransactionBehavior::Immediate)?;
157 Self::upsert_usage_in_transaction(&transaction, record)?;
158 transaction.commit()?;
159 Ok(())
160 }
161
162 pub fn import_snapshot_records(
163 &mut self,
164 usages: &[BudgetUsageRecord],
165 events: &[BudgetMutationRecord],
166 ) -> Result<(), BudgetStoreError> {
167 let transaction = self
168 .connection
169 .transaction_with_behavior(TransactionBehavior::Immediate)?;
170 for usage in usages {
171 Self::upsert_usage_in_transaction(&transaction, usage)?;
172 }
173 for event in events {
174 Self::import_mutation_record_in_transaction(&transaction, event)?;
175 }
176 transaction.commit()?;
177 Ok(())
178 }
179
180 fn upsert_usage_in_transaction(
181 transaction: &rusqlite::Transaction<'_>,
182 record: &BudgetUsageRecord,
183 ) -> Result<(), BudgetStoreError> {
184 raise_budget_replication_seq_floor(transaction, record.seq)?;
185 transaction.execute(
186 r#"
187 INSERT INTO capability_grant_budgets (
188 capability_id,
189 grant_index,
190 invocation_count,
191 updated_at,
192 seq,
193 total_cost_exposed,
194 total_cost_realized_spend
195 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
196 ON CONFLICT(capability_id, grant_index) DO UPDATE SET
197 invocation_count = CASE
198 WHEN excluded.seq >= capability_grant_budgets.seq
199 THEN excluded.invocation_count
200 ELSE capability_grant_budgets.invocation_count
201 END,
202 updated_at = CASE
203 WHEN excluded.seq >= capability_grant_budgets.seq
204 THEN excluded.updated_at
205 ELSE capability_grant_budgets.updated_at
206 END,
207 total_cost_exposed = CASE
208 WHEN excluded.seq >= capability_grant_budgets.seq
209 THEN excluded.total_cost_exposed
210 ELSE capability_grant_budgets.total_cost_exposed
211 END,
212 total_cost_realized_spend = CASE
213 WHEN excluded.seq >= capability_grant_budgets.seq
214 THEN excluded.total_cost_realized_spend
215 ELSE capability_grant_budgets.total_cost_realized_spend
216 END,
217 seq = MAX(capability_grant_budgets.seq, excluded.seq)
218 "#,
219 params![
220 &record.capability_id,
221 record.grant_index as i64,
222 record.invocation_count as i64,
223 record.updated_at,
224 record.seq as i64,
225 record.total_cost_exposed as i64,
226 record.total_cost_realized_spend as i64,
227 ],
228 )?;
229 Ok(())
230 }
231
232 pub fn delete_mutation_event(&mut self, event_id: &str) -> Result<(), BudgetStoreError> {
233 let transaction = self
234 .connection
235 .transaction_with_behavior(TransactionBehavior::Immediate)?;
236 transaction.execute(
237 "DELETE FROM budget_mutation_events WHERE event_id = ?1",
238 params![event_id],
239 )?;
240 transaction.commit()?;
241 Ok(())
242 }
243
244 pub fn delete_hold(&mut self, hold_id: &str) -> Result<(), BudgetStoreError> {
245 let transaction = self
246 .connection
247 .transaction_with_behavior(TransactionBehavior::Immediate)?;
248 transaction.execute(
249 "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
250 params![hold_id],
251 )?;
252 transaction.commit()?;
253 Ok(())
254 }
255
256 pub fn hold_authority(
257 &mut self,
258 hold_id: &str,
259 ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
260 let transaction = self
261 .connection
262 .transaction_with_behavior(TransactionBehavior::Deferred)?;
263 let authority = Self::load_hold(&transaction, hold_id)?.and_then(|hold| hold.authority);
264 transaction.rollback()?;
265 Ok(authority)
266 }
267
268 pub fn import_mutation_record(
269 &mut self,
270 record: &BudgetMutationRecord,
271 ) -> Result<(), BudgetStoreError> {
272 let transaction = self
273 .connection
274 .transaction_with_behavior(TransactionBehavior::Immediate)?;
275 Self::import_mutation_record_in_transaction(&transaction, record)?;
276 transaction.commit()?;
277 Ok(())
278 }
279
280 fn import_mutation_record_in_transaction(
281 transaction: &rusqlite::Transaction<'_>,
282 record: &BudgetMutationRecord,
283 ) -> Result<(), BudgetStoreError> {
284 raise_budget_replication_seq_floor(transaction, record.event_seq)?;
285 if let Some(usage_seq) = record.usage_seq {
286 raise_budget_replication_seq_floor(transaction, usage_seq)?;
287 }
288
289 let duplicate_event = if let Some(existing) =
290 Self::load_mutation_event(transaction, &record.event_id)?
291 {
292 if existing != *record {
293 if Self::rolled_back_authorize_can_be_replaced(transaction, &existing, record)? {
294 transaction.execute(
295 "DELETE FROM budget_mutation_events WHERE event_id = ?1",
296 params![record.event_id],
297 )?;
298 if let Some(hold_id) = record.hold_id.as_deref() {
299 transaction.execute(
300 "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
301 params![hold_id],
302 )?;
303 }
304 false
305 } else {
306 return Err(BudgetStoreError::Invariant(format!(
307 "budget event_id `{}` was reused for a different mutation",
308 record.event_id
309 )));
310 }
311 } else {
312 true
313 }
314 } else {
315 transaction.execute(
316 r#"
317 INSERT INTO budget_mutation_events (
318 event_id,
319 hold_id,
320 capability_id,
321 grant_index,
322 kind,
323 allowed,
324 recorded_at,
325 event_seq,
326 usage_seq,
327 exposure_units,
328 realized_spend_units,
329 max_invocations,
330 max_exposure_per_invocation,
331 max_total_exposure_units,
332 invocation_count_after,
333 total_cost_exposed_after,
334 total_cost_realized_spend_after,
335 authority_id,
336 lease_id,
337 lease_epoch
338 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
339 "#,
340 params![
341 record.event_id,
342 record.hold_id,
343 record.capability_id,
344 i64::from(record.grant_index),
345 record.kind.as_str(),
346 record.allowed.map(|value| if value { 1_i64 } else { 0_i64 }),
347 record.recorded_at,
348 record.event_seq as i64,
349 record.usage_seq.map(|value| value as i64),
350 record.exposure_units as i64,
351 record.realized_spend_units as i64,
352 record.max_invocations.map(i64::from),
353 record.max_cost_per_invocation.map(|value| value as i64),
354 record.max_total_cost_units.map(|value| value as i64),
355 i64::from(record.invocation_count_after),
356 record.total_cost_exposed_after as i64,
357 record.total_cost_realized_spend_after as i64,
358 record.authority.as_ref().map(|value| value.authority_id.as_str()),
359 record.authority.as_ref().map(|value| value.lease_id.as_str()),
360 record.authority.as_ref().map(|value| value.lease_epoch as i64),
361 ],
362 )?;
363 false
364 };
365
366 if duplicate_event {
367 return Ok(());
368 }
369
370 Self::apply_imported_hold_state(transaction, record)?;
371 Ok(())
372 }
373
374 pub fn list_usages_after(
375 &self,
376 limit: usize,
377 after_seq: Option<u64>,
378 ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
379 let mut statement = self.connection.prepare(
380 r#"
381 SELECT
382 capability_id,
383 grant_index,
384 invocation_count,
385 updated_at,
386 seq,
387 total_cost_exposed,
388 total_cost_realized_spend
389 FROM capability_grant_budgets
390 WHERE (?1 IS NULL OR seq > ?1)
391 ORDER BY seq ASC
392 LIMIT ?2
393 "#,
394 )?;
395 let rows = statement.query_map(
396 params![after_seq.map(|value| value as i64), limit as i64],
397 record_from_row,
398 )?;
399 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
400 }
401
402 pub fn list_all_usages(&self) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
403 let mut statement = self.connection.prepare(
404 r#"
405 SELECT
406 capability_id,
407 grant_index,
408 invocation_count,
409 updated_at,
410 seq,
411 total_cost_exposed,
412 total_cost_realized_spend
413 FROM capability_grant_budgets
414 ORDER BY updated_at DESC, capability_id ASC, grant_index ASC
415 "#,
416 )?;
417 let rows = statement.query_map([], record_from_row)?;
418 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
419 }
420
421 pub fn list_mutation_events_after_seq(
422 &self,
423 limit: usize,
424 after_event_seq: u64,
425 ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
426 let mut statement = self.connection.prepare(
427 r#"
428 SELECT
429 event_id,
430 hold_id,
431 capability_id,
432 grant_index,
433 kind,
434 allowed,
435 recorded_at,
436 event_seq,
437 usage_seq,
438 exposure_units,
439 realized_spend_units,
440 max_invocations,
441 max_exposure_per_invocation,
442 max_total_exposure_units,
443 invocation_count_after,
444 total_cost_exposed_after,
445 total_cost_realized_spend_after,
446 authority_id,
447 lease_id,
448 lease_epoch
449 FROM budget_mutation_events
450 WHERE event_seq > ?1
451 ORDER BY event_seq ASC
452 LIMIT ?2
453 "#,
454 )?;
455 let rows = statement.query_map(params![after_event_seq as i64, limit as i64], |row| {
456 mutation_record_from_row(row)
457 })?;
458 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
459 }
460
461 fn generated_event_id(
462 transaction: &rusqlite::Transaction<'_>,
463 ) -> Result<String, BudgetStoreError> {
464 let count =
465 transaction.query_row("SELECT COUNT(*) FROM budget_mutation_events", [], |row| {
466 row.get::<_, i64>(0)
467 })?;
468 Ok(format!(
469 "sqlite-budget-event-{}-{}",
470 unix_now(),
471 count.max(0) + 1
472 ))
473 }
474
475 fn load_hold(
476 transaction: &rusqlite::Transaction<'_>,
477 hold_id: &str,
478 ) -> Result<Option<SqliteBudgetHold>, BudgetStoreError> {
479 transaction
480 .query_row(
481 r#"
482 SELECT
483 hold_id,
484 capability_id,
485 grant_index,
486 authorized_exposure_units,
487 remaining_exposure_units,
488 invocation_count_debited,
489 disposition,
490 authority_id,
491 lease_id,
492 lease_epoch
493 FROM budget_authorization_holds
494 WHERE hold_id = ?1
495 "#,
496 params![hold_id],
497 |row| {
498 let disposition = row.get::<_, String>(6)?;
499 let authority =
500 sqlite_budget_event_authority(row.get(7)?, row.get(8)?, row.get(9)?)?;
501 Ok(SqliteBudgetHold {
502 hold_id: row.get(0)?,
503 capability_id: row.get(1)?,
504 grant_index: row.get::<_, i64>(2)?.max(0) as usize,
505 authorized_exposure_units: row.get::<_, i64>(3)?.max(0) as u64,
506 remaining_exposure_units: row.get::<_, i64>(4)?.max(0) as u64,
507 invocation_count_debited: row.get::<_, i64>(5)? > 0,
508 disposition: HoldDisposition::parse(&disposition).ok_or_else(|| {
509 rusqlite::Error::FromSqlConversionFailure(
510 6,
511 rusqlite::types::Type::Text,
512 Box::new(std::io::Error::new(
513 std::io::ErrorKind::InvalidData,
514 format!("unknown hold disposition `{disposition}`"),
515 )),
516 )
517 })?,
518 authority,
519 })
520 },
521 )
522 .optional()
523 .map_err(Into::into)
524 }
525
526 fn load_mutation_event(
527 transaction: &rusqlite::Transaction<'_>,
528 event_id: &str,
529 ) -> Result<Option<BudgetMutationRecord>, BudgetStoreError> {
530 transaction
531 .query_row(
532 r#"
533 SELECT
534 event_id,
535 hold_id,
536 capability_id,
537 grant_index,
538 kind,
539 allowed,
540 recorded_at,
541 event_seq,
542 usage_seq,
543 exposure_units,
544 realized_spend_units,
545 max_invocations,
546 max_exposure_per_invocation,
547 max_total_exposure_units,
548 invocation_count_after,
549 total_cost_exposed_after,
550 total_cost_realized_spend_after,
551 authority_id,
552 lease_id,
553 lease_epoch
554 FROM budget_mutation_events
555 WHERE event_id = ?1
556 "#,
557 params![event_id],
558 mutation_record_from_row,
559 )
560 .optional()
561 .map_err(Into::into)
562 }
563
564 fn create_hold(
565 transaction: &rusqlite::Transaction<'_>,
566 hold_id: &str,
567 capability_id: &str,
568 grant_index: usize,
569 authorized_exposure_units: u64,
570 authority: Option<&BudgetEventAuthority>,
571 ) -> Result<(), BudgetStoreError> {
572 let now = unix_now();
573 transaction.execute(
574 r#"
575 INSERT INTO budget_authorization_holds (
576 hold_id,
577 capability_id,
578 grant_index,
579 authorized_exposure_units,
580 remaining_exposure_units,
581 invocation_count_debited,
582 disposition,
583 authority_id,
584 lease_id,
585 lease_epoch,
586 created_at,
587 updated_at
588 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8, ?9, ?10, ?10)
589 "#,
590 params![
591 hold_id,
592 capability_id,
593 grant_index as i64,
594 authorized_exposure_units as i64,
595 authorized_exposure_units as i64,
596 HoldDisposition::Open.as_str(),
597 authority.map(|value| value.authority_id.as_str()),
598 authority.map(|value| value.lease_id.as_str()),
599 authority.map(|value| value.lease_epoch as i64),
600 now,
601 ],
602 )?;
603 Ok(())
604 }
605
606 fn update_hold(
607 transaction: &rusqlite::Transaction<'_>,
608 hold_id: &str,
609 remaining_exposure_units: u64,
610 disposition: HoldDisposition,
611 authority: Option<&BudgetEventAuthority>,
612 ) -> Result<(), BudgetStoreError> {
613 transaction.execute(
614 r#"
615 UPDATE budget_authorization_holds
616 SET remaining_exposure_units = ?2,
617 disposition = ?3,
618 authority_id = ?4,
619 lease_id = ?5,
620 lease_epoch = ?6,
621 updated_at = ?7
622 WHERE hold_id = ?1
623 "#,
624 params![
625 hold_id,
626 remaining_exposure_units as i64,
627 disposition.as_str(),
628 authority.map(|value| value.authority_id.as_str()),
629 authority.map(|value| value.lease_id.as_str()),
630 authority.map(|value| value.lease_epoch as i64),
631 unix_now(),
632 ],
633 )?;
634 Ok(())
635 }
636
637 #[allow(clippy::too_many_arguments)]
638 fn upsert_hold(
639 transaction: &rusqlite::Transaction<'_>,
640 hold_id: &str,
641 capability_id: &str,
642 grant_index: usize,
643 authorized_exposure_units: u64,
644 remaining_exposure_units: u64,
645 disposition: HoldDisposition,
646 authority: Option<&BudgetEventAuthority>,
647 ) -> Result<(), BudgetStoreError> {
648 let now = unix_now();
649 transaction.execute(
650 r#"
651 INSERT INTO budget_authorization_holds (
652 hold_id,
653 capability_id,
654 grant_index,
655 authorized_exposure_units,
656 remaining_exposure_units,
657 invocation_count_debited,
658 disposition,
659 authority_id,
660 lease_id,
661 lease_epoch,
662 created_at,
663 updated_at
664 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8, ?9, ?10, ?10)
665 ON CONFLICT(hold_id) DO UPDATE SET
666 capability_id = excluded.capability_id,
667 grant_index = excluded.grant_index,
668 authorized_exposure_units = excluded.authorized_exposure_units,
669 remaining_exposure_units = excluded.remaining_exposure_units,
670 invocation_count_debited = excluded.invocation_count_debited,
671 disposition = excluded.disposition,
672 authority_id = excluded.authority_id,
673 lease_id = excluded.lease_id,
674 lease_epoch = excluded.lease_epoch,
675 updated_at = excluded.updated_at
676 "#,
677 params![
678 hold_id,
679 capability_id,
680 grant_index as i64,
681 authorized_exposure_units as i64,
682 remaining_exposure_units as i64,
683 disposition.as_str(),
684 authority.map(|value| value.authority_id.as_str()),
685 authority.map(|value| value.lease_id.as_str()),
686 authority.map(|value| value.lease_epoch as i64),
687 now,
688 ],
689 )?;
690 Ok(())
691 }
692
693 fn delete_hold_if_exists(
694 transaction: &rusqlite::Transaction<'_>,
695 hold_id: &str,
696 ) -> Result<(), BudgetStoreError> {
697 transaction.execute(
698 "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
699 params![hold_id],
700 )?;
701 Ok(())
702 }
703
704 fn apply_imported_hold_state(
705 transaction: &rusqlite::Transaction<'_>,
706 record: &BudgetMutationRecord,
707 ) -> Result<(), BudgetStoreError> {
708 let Some(hold_id) = record.hold_id.as_deref() else {
709 return Ok(());
710 };
711
712 match record.kind {
713 BudgetMutationKind::IncrementInvocation => Ok(()),
714 BudgetMutationKind::AuthorizeExposure => {
715 if record.allowed == Some(true) {
716 Self::upsert_hold(
717 transaction,
718 hold_id,
719 &record.capability_id,
720 record.grant_index as usize,
721 record.exposure_units,
722 record.exposure_units,
723 HoldDisposition::Open,
724 record.authority.as_ref(),
725 )
726 } else {
727 Self::delete_hold_if_exists(transaction, hold_id)
728 }
729 }
730 BudgetMutationKind::ReleaseExposure => {
731 let hold = Self::load_hold(transaction, hold_id)?.ok_or_else(|| {
732 BudgetStoreError::Invariant(format!(
733 "missing budget hold `{hold_id}` while importing release event"
734 ))
735 })?;
736 if hold.capability_id != record.capability_id
737 || hold.grant_index != record.grant_index as usize
738 {
739 return Err(BudgetStoreError::Invariant(format!(
740 "budget hold `{hold_id}` does not match capability/grant"
741 )));
742 }
743 let remaining = hold
744 .remaining_exposure_units
745 .checked_sub(record.exposure_units)
746 .ok_or_else(|| {
747 BudgetStoreError::Invariant(format!(
748 "budget hold `{hold_id}` cannot release more than remaining exposure"
749 ))
750 })?;
751 let disposition = if remaining == 0 {
752 HoldDisposition::Released
753 } else {
754 HoldDisposition::Open
755 };
756 Self::upsert_hold(
757 transaction,
758 hold_id,
759 &record.capability_id,
760 record.grant_index as usize,
761 hold.authorized_exposure_units,
762 remaining,
763 disposition,
764 record.authority.as_ref().or(hold.authority.as_ref()),
765 )
766 }
767 BudgetMutationKind::ReverseExposure => {
768 let authorized_exposure_units = Self::load_hold(transaction, hold_id)?
769 .map(|hold| hold.authorized_exposure_units)
770 .unwrap_or(record.exposure_units);
771 Self::upsert_hold(
772 transaction,
773 hold_id,
774 &record.capability_id,
775 record.grant_index as usize,
776 authorized_exposure_units,
777 0,
778 HoldDisposition::Reversed,
779 record.authority.as_ref(),
780 )
781 }
782 BudgetMutationKind::ReconcileSpend => {
783 let authorized_exposure_units = Self::load_hold(transaction, hold_id)?
784 .map(|hold| hold.authorized_exposure_units)
785 .unwrap_or(record.exposure_units);
786 Self::upsert_hold(
787 transaction,
788 hold_id,
789 &record.capability_id,
790 record.grant_index as usize,
791 authorized_exposure_units,
792 0,
793 HoldDisposition::Reconciled,
794 record.authority.as_ref(),
795 )
796 }
797 }
798 }
799
800 fn ensure_open_hold(
801 transaction: &rusqlite::Transaction<'_>,
802 hold_id: &str,
803 capability_id: &str,
804 grant_index: usize,
805 ) -> Result<SqliteBudgetHold, BudgetStoreError> {
806 let hold = Self::load_hold(transaction, hold_id)?.ok_or_else(|| {
807 BudgetStoreError::Invariant(format!("missing budget hold `{hold_id}`"))
808 })?;
809 if hold.capability_id != capability_id || hold.grant_index != grant_index {
810 return Err(BudgetStoreError::Invariant(format!(
811 "budget hold `{hold_id}` does not match capability/grant"
812 )));
813 }
814 if hold.disposition != HoldDisposition::Open {
815 return Err(BudgetStoreError::Invariant(format!(
816 "budget hold `{hold_id}` is no longer open"
817 )));
818 }
819 Ok(hold)
820 }
821
822 fn validate_hold_authority(
823 hold_id: &str,
824 current: Option<&BudgetEventAuthority>,
825 requested: Option<&BudgetEventAuthority>,
826 ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
827 match (current, requested) {
828 (None, None) => Ok(None),
829 (None, Some(_)) => Err(BudgetStoreError::Invariant(format!(
830 "budget hold `{hold_id}` was created without authority lease metadata"
831 ))),
832 (Some(_), None) => Err(BudgetStoreError::Invariant(format!(
833 "budget hold `{hold_id}` requires authority lease metadata"
834 ))),
835 (Some(current), Some(requested)) => {
836 if current.authority_id != requested.authority_id {
837 return Err(BudgetStoreError::Invariant(format!(
838 "budget hold `{hold_id}` authority_id does not match the open lease"
839 )));
840 }
841 if requested.lease_id != current.lease_id {
842 return Err(BudgetStoreError::Invariant(format!(
843 "budget hold `{hold_id}` lease_id does not match the open lease epoch"
844 )));
845 }
846 if requested.lease_epoch < current.lease_epoch {
847 return Err(BudgetStoreError::Invariant(format!(
848 "budget hold `{hold_id}` authority lease epoch regressed"
849 )));
850 }
851 if requested.lease_epoch > current.lease_epoch {
852 return Err(BudgetStoreError::Invariant(format!(
853 "budget hold `{hold_id}` authority lease epoch advanced beyond the open lease"
854 )));
855 }
856 Ok(Some(requested.clone()))
857 }
858 }
859 }
860
861 fn existing_increment_allowed(
862 transaction: &rusqlite::Transaction<'_>,
863 event_id: Option<&str>,
864 capability_id: &str,
865 grant_index: usize,
866 max_invocations: Option<u32>,
867 ) -> Result<Option<bool>, BudgetStoreError> {
868 let Some(event_id) = event_id else {
869 return Ok(None);
870 };
871 let existing = transaction
872 .query_row(
873 r#"
874 SELECT capability_id, grant_index, kind, allowed, max_invocations
875 FROM budget_mutation_events
876 WHERE event_id = ?1
877 "#,
878 params![event_id],
879 |row| {
880 Ok((
881 row.get::<_, String>(0)?,
882 row.get::<_, i64>(1)?.max(0) as usize,
883 row.get::<_, String>(2)?,
884 row.get::<_, Option<i64>>(3)?,
885 row.get::<_, Option<i64>>(4)?,
886 ))
887 },
888 )
889 .optional()?;
890 let Some((
891 existing_capability_id,
892 existing_grant_index,
893 existing_kind,
894 existing_allowed,
895 existing_max_invocations,
896 )) = existing
897 else {
898 return Ok(None);
899 };
900 let mutation_matches = existing_capability_id == capability_id
901 && existing_grant_index == grant_index
902 && existing_kind == BudgetMutationKind::IncrementInvocation.as_str()
903 && existing_max_invocations.map(|value| value.max(0) as u32) == max_invocations;
904 if !mutation_matches {
905 return Err(BudgetStoreError::Invariant(format!(
906 "budget event_id `{event_id}` was reused for a different mutation"
907 )));
908 }
909 Ok(Some(existing_allowed.unwrap_or(0) > 0))
910 }
911
912 fn sqlite_like_prefix_pattern(prefix: &str) -> String {
913 let mut pattern = String::with_capacity(prefix.len() + 1);
914 for ch in prefix.chars() {
915 match ch {
916 '\\' | '%' | '_' => {
917 pattern.push('\\');
918 pattern.push(ch);
919 }
920 _ => pattern.push(ch),
921 }
922 }
923 pattern.push('%');
924 pattern
925 }
926
927 fn rollback_event_exists(
928 transaction: &rusqlite::Transaction<'_>,
929 event_id: &str,
930 ) -> Result<bool, BudgetStoreError> {
931 let legacy_rollback_event_id = format!("{event_id}:rollback");
932 let rollback_prefix = format!("{event_id}:rollback:");
933 let rollback_prefix_pattern = Self::sqlite_like_prefix_pattern(&rollback_prefix);
934 Ok(transaction
935 .query_row(
936 r#"
937 SELECT 1
938 FROM budget_mutation_events
939 WHERE event_id = ?1
940 OR event_id LIKE ?2 ESCAPE '\'
941 LIMIT 1
942 "#,
943 params![legacy_rollback_event_id, rollback_prefix_pattern],
944 |_| Ok(()),
945 )
946 .optional()?
947 .is_some())
948 }
949
950 fn rolled_back_authorize_can_be_replaced(
951 transaction: &rusqlite::Transaction<'_>,
952 existing: &BudgetMutationRecord,
953 replacement: &BudgetMutationRecord,
954 ) -> Result<bool, BudgetStoreError> {
955 if existing.kind != BudgetMutationKind::AuthorizeExposure
956 || replacement.kind != BudgetMutationKind::AuthorizeExposure
957 || existing.allowed != Some(true)
958 || replacement.allowed != Some(true)
959 {
960 return Ok(false);
961 }
962 let same_mutation_scope = existing.hold_id == replacement.hold_id
963 && existing.capability_id == replacement.capability_id
964 && existing.grant_index == replacement.grant_index
965 && existing.exposure_units == replacement.exposure_units
966 && existing.realized_spend_units == replacement.realized_spend_units
967 && existing.max_invocations == replacement.max_invocations
968 && existing.max_cost_per_invocation == replacement.max_cost_per_invocation
969 && existing.max_total_cost_units == replacement.max_total_cost_units;
970 if !same_mutation_scope {
971 return Ok(false);
972 }
973 Self::rollback_event_exists(transaction, &existing.event_id)
974 }
975
976 #[allow(clippy::too_many_arguments)]
977 fn existing_event_allowed(
978 transaction: &rusqlite::Transaction<'_>,
979 event_id: Option<&str>,
980 kind: BudgetMutationKind,
981 capability_id: &str,
982 grant_index: usize,
983 hold_id: Option<&str>,
984 authority: Option<&BudgetEventAuthority>,
985 exposure_units: u64,
986 realized_spend_units: u64,
987 max_invocations: Option<u32>,
988 max_cost_per_invocation: Option<u64>,
989 max_total_cost_units: Option<u64>,
990 ) -> Result<Option<Option<bool>>, BudgetStoreError> {
991 let Some(event_id) = event_id else {
992 return Ok(None);
993 };
994 let existing = transaction
995 .query_row(
996 r#"
997 SELECT
998 hold_id,
999 capability_id,
1000 grant_index,
1001 kind,
1002 allowed,
1003 exposure_units,
1004 realized_spend_units,
1005 max_invocations,
1006 max_exposure_per_invocation,
1007 max_total_exposure_units,
1008 invocation_count_after,
1009 total_cost_exposed_after,
1010 total_cost_realized_spend_after,
1011 authority_id,
1012 lease_id,
1013 lease_epoch
1014 FROM budget_mutation_events
1015 WHERE event_id = ?1
1016 "#,
1017 params![event_id],
1018 |row| {
1019 let authority =
1020 sqlite_budget_event_authority(row.get(13)?, row.get(14)?, row.get(15)?)?;
1021 Ok((
1022 row.get::<_, Option<String>>(0)?,
1023 row.get::<_, String>(1)?,
1024 row.get::<_, i64>(2)?.max(0) as usize,
1025 row.get::<_, String>(3)?,
1026 row.get::<_, Option<i64>>(4)?,
1027 row.get::<_, i64>(5)?.max(0) as u64,
1028 row.get::<_, i64>(6)?.max(0) as u64,
1029 row.get::<_, Option<i64>>(7)?,
1030 row.get::<_, Option<i64>>(8)?,
1031 row.get::<_, Option<i64>>(9)?,
1032 row.get::<_, i64>(10)?.max(0) as u32,
1033 row.get::<_, i64>(11)?.max(0) as u64,
1034 row.get::<_, i64>(12)?.max(0) as u64,
1035 authority,
1036 ))
1037 },
1038 )
1039 .optional()?;
1040 let Some((
1041 existing_hold_id,
1042 existing_capability_id,
1043 existing_grant_index,
1044 existing_kind,
1045 existing_allowed,
1046 existing_exposure_units,
1047 existing_realized_spend_units,
1048 existing_max_invocations,
1049 existing_max_exposure_per_invocation,
1050 existing_max_total_exposure_units,
1051 existing_invocation_count_after,
1052 existing_total_cost_exposed_after,
1053 existing_total_cost_realized_spend_after,
1054 existing_authority,
1055 )) = existing
1056 else {
1057 return Ok(None);
1058 };
1059 let max_invocations_matches =
1060 existing_max_invocations.map(|value| value.max(0) as u32) == max_invocations;
1061 let max_per_matches = existing_max_exposure_per_invocation.map(|value| value.max(0) as u64)
1062 == max_cost_per_invocation;
1063 let max_total_matches = existing_max_total_exposure_units.map(|value| value.max(0) as u64)
1064 == max_total_cost_units;
1065 let mutation_matches = existing_capability_id == capability_id
1066 && existing_grant_index == grant_index
1067 && existing_kind == kind.as_str()
1068 && existing_hold_id.as_deref() == hold_id
1069 && existing_exposure_units == exposure_units
1070 && existing_realized_spend_units == realized_spend_units
1071 && max_invocations_matches
1072 && max_per_matches
1073 && max_total_matches;
1074 let existing_allowed = existing_allowed.map(|value| value > 0);
1075 let rollback_exists = kind == BudgetMutationKind::AuthorizeExposure
1076 && existing_allowed == Some(true)
1077 && Self::rollback_event_exists(transaction, event_id)?;
1078 if !mutation_matches {
1079 return Err(BudgetStoreError::Invariant(format!(
1080 "budget event_id `{event_id}` was reused for a different mutation"
1081 )));
1082 }
1083 if rollback_exists {
1084 let current = transaction
1085 .query_row(
1086 r#"
1087 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1088 FROM capability_grant_budgets
1089 WHERE capability_id = ?1 AND grant_index = ?2
1090 "#,
1091 params![capability_id, grant_index as i64],
1092 |row| {
1093 Ok((
1094 row.get::<_, i64>(0)?.max(0) as u32,
1095 row.get::<_, i64>(1)?.max(0) as u64,
1096 row.get::<_, i64>(2)?.max(0) as u64,
1097 ))
1098 },
1099 )
1100 .optional()?;
1101 let usage_matches = current.is_some_and(
1102 |(invocation_count, total_cost_exposed, total_cost_realized_spend)| {
1103 invocation_count == existing_invocation_count_after
1104 && total_cost_exposed == existing_total_cost_exposed_after
1105 && total_cost_realized_spend == existing_total_cost_realized_spend_after
1106 },
1107 );
1108 let hold_matches = match hold_id {
1109 Some(hold_id) => Self::load_hold(transaction, hold_id)?.is_some_and(|hold| {
1110 hold.capability_id == capability_id
1111 && hold.grant_index == grant_index
1112 && hold.authorized_exposure_units == exposure_units
1113 && hold.remaining_exposure_units == exposure_units
1114 && hold.invocation_count_debited
1115 && hold.disposition == HoldDisposition::Open
1116 }),
1117 None => true,
1118 };
1119 if usage_matches && hold_matches {
1120 return Ok(Some(existing_allowed));
1121 }
1122 transaction.execute(
1123 "DELETE FROM budget_mutation_events WHERE event_id = ?1",
1124 params![event_id],
1125 )?;
1126 if let Some(hold_id) = hold_id {
1127 transaction.execute(
1128 "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
1129 params![hold_id],
1130 )?;
1131 }
1132 return Ok(None);
1133 }
1134 if existing_authority.as_ref() != authority {
1135 return Err(BudgetStoreError::Invariant(format!(
1136 "budget event_id `{event_id}` was reused for a different mutation"
1137 )));
1138 }
1139 Ok(Some(existing_allowed))
1140 }
1141
1142 #[allow(clippy::too_many_arguments)]
1143 fn append_mutation_event(
1144 transaction: &rusqlite::Transaction<'_>,
1145 event_id: Option<&str>,
1146 hold_id: Option<&str>,
1147 authority: Option<&BudgetEventAuthority>,
1148 capability_id: &str,
1149 grant_index: usize,
1150 kind: BudgetMutationKind,
1151 allowed: Option<bool>,
1152 event_seq: u64,
1153 usage_seq: Option<u64>,
1154 exposure_units: u64,
1155 realized_spend_units: u64,
1156 max_invocations: Option<u32>,
1157 max_cost_per_invocation: Option<u64>,
1158 max_total_cost_units: Option<u64>,
1159 invocation_count_after: u32,
1160 total_cost_exposed_after: u64,
1161 total_cost_realized_spend_after: u64,
1162 ) -> Result<(), BudgetStoreError> {
1163 let event_id = match event_id {
1164 Some(event_id) => event_id.to_string(),
1165 None => Self::generated_event_id(transaction)?,
1166 };
1167 transaction.execute(
1168 r#"
1169 INSERT INTO budget_mutation_events (
1170 event_id,
1171 hold_id,
1172 capability_id,
1173 grant_index,
1174 kind,
1175 allowed,
1176 recorded_at,
1177 event_seq,
1178 usage_seq,
1179 exposure_units,
1180 realized_spend_units,
1181 max_invocations,
1182 max_exposure_per_invocation,
1183 max_total_exposure_units,
1184 invocation_count_after,
1185 total_cost_exposed_after,
1186 total_cost_realized_spend_after,
1187 authority_id,
1188 lease_id,
1189 lease_epoch
1190 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
1191 "#,
1192 params![
1193 event_id,
1194 hold_id,
1195 capability_id,
1196 grant_index as i64,
1197 kind.as_str(),
1198 allowed.map(|value| if value { 1_i64 } else { 0_i64 }),
1199 unix_now(),
1200 event_seq as i64,
1201 usage_seq.map(|value| value as i64),
1202 exposure_units as i64,
1203 realized_spend_units as i64,
1204 max_invocations.map(i64::from),
1205 max_cost_per_invocation.map(|value| value as i64),
1206 max_total_cost_units.map(|value| value as i64),
1207 invocation_count_after as i64,
1208 total_cost_exposed_after as i64,
1209 total_cost_realized_spend_after as i64,
1210 authority.map(|value| value.authority_id.as_str()),
1211 authority.map(|value| value.lease_id.as_str()),
1212 authority.map(|value| value.lease_epoch as i64),
1213 ],
1214 )?;
1215 Ok(())
1216 }
1217
1218 pub fn try_increment_with_event_id(
1219 &mut self,
1220 capability_id: &str,
1221 grant_index: usize,
1222 max_invocations: Option<u32>,
1223 event_id: Option<&str>,
1224 ) -> Result<bool, BudgetStoreError> {
1225 let transaction = self
1226 .connection
1227 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1228
1229 if let Some(allowed) = SqliteBudgetStore::existing_increment_allowed(
1230 &transaction,
1231 event_id,
1232 capability_id,
1233 grant_index,
1234 max_invocations,
1235 )? {
1236 transaction.rollback()?;
1237 return Ok(allowed);
1238 }
1239
1240 let current: Option<(u32, u64, u64)> = transaction
1241 .query_row(
1242 r#"
1243 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1244 FROM capability_grant_budgets
1245 WHERE capability_id = ?1 AND grant_index = ?2
1246 "#,
1247 params![capability_id, grant_index as i64],
1248 |row| {
1249 Ok((
1250 row.get::<_, i64>(0)?.max(0) as u32,
1251 row.get::<_, i64>(1)?.max(0) as u64,
1252 row.get::<_, i64>(2)?.max(0) as u64,
1253 ))
1254 },
1255 )
1256 .optional()?;
1257 let (current, total_cost_exposed, total_cost_realized_spend) = current.unwrap_or((0, 0, 0));
1258 let updated_at = unix_now();
1259
1260 if let Some(max) = max_invocations {
1261 if current >= max {
1262 let event_seq = allocate_budget_replication_seq(&transaction)?;
1263 SqliteBudgetStore::append_mutation_event(
1264 &transaction,
1265 event_id,
1266 None,
1267 None,
1268 capability_id,
1269 grant_index,
1270 BudgetMutationKind::IncrementInvocation,
1271 Some(false),
1272 event_seq,
1273 None,
1274 0,
1275 0,
1276 max_invocations,
1277 None,
1278 None,
1279 current,
1280 total_cost_exposed,
1281 total_cost_realized_spend,
1282 )?;
1283 transaction.commit()?;
1284 return Ok(false);
1285 }
1286 }
1287
1288 let seq = allocate_budget_replication_seq(&transaction)?;
1289 transaction.execute(
1290 r#"
1291 INSERT INTO capability_grant_budgets (
1292 capability_id,
1293 grant_index,
1294 invocation_count,
1295 updated_at,
1296 seq,
1297 total_cost_exposed,
1298 total_cost_realized_spend
1299 ) VALUES (?1, ?2, ?3, ?4, ?5, 0, 0)
1300 ON CONFLICT(capability_id, grant_index) DO UPDATE SET
1301 invocation_count = excluded.invocation_count,
1302 updated_at = excluded.updated_at,
1303 seq = excluded.seq
1304 "#,
1305 params![
1306 capability_id,
1307 grant_index as i64,
1308 current.saturating_add(1) as i64,
1309 updated_at,
1310 seq as i64,
1311 ],
1312 )?;
1313 SqliteBudgetStore::append_mutation_event(
1314 &transaction,
1315 event_id,
1316 None,
1317 None,
1318 capability_id,
1319 grant_index,
1320 BudgetMutationKind::IncrementInvocation,
1321 Some(true),
1322 seq,
1323 Some(seq),
1324 0,
1325 0,
1326 max_invocations,
1327 None,
1328 None,
1329 current.saturating_add(1),
1330 total_cost_exposed,
1331 total_cost_realized_spend,
1332 )?;
1333 transaction.commit()?;
1334 Ok(true)
1335 }
1336}
1337
1338impl BudgetStore for SqliteBudgetStore {
1339 fn try_increment(
1340 &mut self,
1341 capability_id: &str,
1342 grant_index: usize,
1343 max_invocations: Option<u32>,
1344 ) -> Result<bool, BudgetStoreError> {
1345 self.try_increment_with_event_id(capability_id, grant_index, max_invocations, None)
1346 }
1347
1348 fn try_charge_cost(
1349 &mut self,
1350 capability_id: &str,
1351 grant_index: usize,
1352 max_invocations: Option<u32>,
1353 cost_units: u64,
1354 max_cost_per_invocation: Option<u64>,
1355 max_total_cost_units: Option<u64>,
1356 ) -> Result<bool, BudgetStoreError> {
1357 self.try_charge_cost_with_ids(
1358 capability_id,
1359 grant_index,
1360 max_invocations,
1361 cost_units,
1362 max_cost_per_invocation,
1363 max_total_cost_units,
1364 None,
1365 None,
1366 )
1367 }
1368
1369 fn try_charge_cost_with_ids(
1370 &mut self,
1371 capability_id: &str,
1372 grant_index: usize,
1373 max_invocations: Option<u32>,
1374 cost_units: u64,
1375 max_cost_per_invocation: Option<u64>,
1376 max_total_cost_units: Option<u64>,
1377 hold_id: Option<&str>,
1378 event_id: Option<&str>,
1379 ) -> Result<bool, BudgetStoreError> {
1380 self.try_charge_cost_with_ids_and_authority(
1381 capability_id,
1382 grant_index,
1383 max_invocations,
1384 cost_units,
1385 max_cost_per_invocation,
1386 max_total_cost_units,
1387 hold_id,
1388 event_id,
1389 None,
1390 )
1391 }
1392
1393 fn try_charge_cost_with_ids_and_authority(
1394 &mut self,
1395 capability_id: &str,
1396 grant_index: usize,
1397 max_invocations: Option<u32>,
1398 cost_units: u64,
1399 max_cost_per_invocation: Option<u64>,
1400 max_total_cost_units: Option<u64>,
1401 hold_id: Option<&str>,
1402 event_id: Option<&str>,
1403 authority: Option<&BudgetEventAuthority>,
1404 ) -> Result<bool, BudgetStoreError> {
1405 let transaction = self
1406 .connection
1407 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1408
1409 if let Some(existing_allowed) = SqliteBudgetStore::existing_event_allowed(
1410 &transaction,
1411 event_id,
1412 BudgetMutationKind::AuthorizeExposure,
1413 capability_id,
1414 grant_index,
1415 hold_id,
1416 authority,
1417 cost_units,
1418 0,
1419 max_invocations,
1420 max_cost_per_invocation,
1421 max_total_cost_units,
1422 )? {
1423 transaction.rollback()?;
1424 return Ok(existing_allowed.unwrap_or(false));
1425 }
1426
1427 let row: Option<(i64, u64, u64)> = transaction
1428 .query_row(
1429 r#"
1430 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1431 FROM capability_grant_budgets
1432 WHERE capability_id = ?1 AND grant_index = ?2
1433 "#,
1434 params![capability_id, grant_index as i64],
1435 |row| {
1436 Ok((
1437 row.get(0)?,
1438 row.get::<_, i64>(1)?.max(0) as u64,
1439 row.get::<_, i64>(2)?.max(0) as u64,
1440 ))
1441 },
1442 )
1443 .optional()?;
1444 let (current_count, current_exposed, current_realized) = row.unwrap_or((0, 0, 0));
1445 let current_count = current_count.max(0) as u32;
1446
1447 if let Some(hold_id) = hold_id {
1448 let retry_follows_rollback = match event_id {
1449 Some(event_id) => Self::rollback_event_exists(&transaction, event_id)?,
1450 None => false,
1451 };
1452 if let Some(hold) = SqliteBudgetStore::load_hold(&transaction, hold_id)? {
1453 if hold.capability_id == capability_id
1454 && hold.grant_index == grant_index
1455 && hold.authorized_exposure_units == cost_units
1456 && hold.remaining_exposure_units == cost_units
1457 && hold.invocation_count_debited
1458 && hold.disposition == HoldDisposition::Open
1459 && current_exposed >= cost_units
1460 {
1461 let current = transaction
1462 .query_row(
1463 r#"
1464 SELECT seq, invocation_count, total_cost_exposed, total_cost_realized_spend
1465 FROM capability_grant_budgets
1466 WHERE capability_id = ?1 AND grant_index = ?2
1467 "#,
1468 params![capability_id, grant_index as i64],
1469 |row| {
1470 Ok((
1471 row.get::<_, i64>(0)?.max(0) as u64,
1472 row.get::<_, i64>(1)?.max(0) as u32,
1473 row.get::<_, i64>(2)?.max(0) as u64,
1474 row.get::<_, i64>(3)?.max(0) as u64,
1475 ))
1476 },
1477 )
1478 .optional()?;
1479 if let Some((
1480 usage_seq,
1481 invocation_count_after,
1482 total_cost_exposed_after,
1483 total_cost_realized_spend_after,
1484 )) = current
1485 {
1486 let event_seq = allocate_budget_replication_seq(&transaction)?;
1487 if retry_follows_rollback {
1488 SqliteBudgetStore::upsert_hold(
1489 &transaction,
1490 hold_id,
1491 capability_id,
1492 grant_index,
1493 cost_units,
1494 cost_units,
1495 HoldDisposition::Open,
1496 authority,
1497 )?;
1498 }
1499 SqliteBudgetStore::append_mutation_event(
1500 &transaction,
1501 event_id,
1502 Some(hold_id),
1503 authority,
1504 capability_id,
1505 grant_index,
1506 BudgetMutationKind::AuthorizeExposure,
1507 Some(true),
1508 event_seq,
1509 Some(usage_seq),
1510 cost_units,
1511 0,
1512 max_invocations,
1513 max_cost_per_invocation,
1514 max_total_cost_units,
1515 invocation_count_after,
1516 total_cost_exposed_after,
1517 total_cost_realized_spend_after,
1518 )?;
1519 transaction.commit()?;
1520 return Ok(true);
1521 }
1522 }
1523 }
1524 if retry_follows_rollback {
1525 Self::delete_hold_if_exists(&transaction, hold_id)?;
1526 }
1527 }
1528
1529 let mut allowed = true;
1530
1531 if let Some(max) = max_invocations {
1532 if current_count >= max {
1533 allowed = false;
1534 }
1535 }
1536 if let Some(max_per) = max_cost_per_invocation {
1537 if cost_units > max_per {
1538 allowed = false;
1539 }
1540 }
1541 if let Some(max_total) = max_total_cost_units {
1542 let current_total = checked_committed_cost_units(current_exposed, current_realized)?;
1543 let new_total = current_total.checked_add(cost_units).ok_or_else(|| {
1544 BudgetStoreError::Overflow(
1545 "authorized exposure + cost_units overflowed u64".to_string(),
1546 )
1547 })?;
1548 if new_total > max_total {
1549 allowed = false;
1550 }
1551 }
1552
1553 let (
1554 invocation_count_after,
1555 total_cost_exposed_after,
1556 total_cost_realized_spend_after,
1557 event_seq,
1558 usage_seq,
1559 );
1560 if allowed {
1561 if let Some(hold_id) = hold_id {
1562 let retry_follows_rollback = match event_id {
1563 Some(event_id) => Self::rollback_event_exists(&transaction, event_id)?,
1564 None => false,
1565 };
1566 if retry_follows_rollback {
1567 if let Some(hold) = SqliteBudgetStore::load_hold(&transaction, hold_id)? {
1568 if hold.capability_id == capability_id
1569 && hold.grant_index == grant_index
1570 && hold.authorized_exposure_units == cost_units
1571 && hold.remaining_exposure_units == cost_units
1572 && hold.invocation_count_debited
1573 && hold.disposition == HoldDisposition::Open
1574 && current_exposed >= cost_units
1575 {
1576 let current = transaction
1577 .query_row(
1578 r#"
1579 SELECT seq, invocation_count, total_cost_exposed, total_cost_realized_spend
1580 FROM capability_grant_budgets
1581 WHERE capability_id = ?1 AND grant_index = ?2
1582 "#,
1583 params![capability_id, grant_index as i64],
1584 |row| {
1585 Ok((
1586 row.get::<_, i64>(0)?.max(0) as u64,
1587 row.get::<_, i64>(1)?.max(0) as u32,
1588 row.get::<_, i64>(2)?.max(0) as u64,
1589 row.get::<_, i64>(3)?.max(0) as u64,
1590 ))
1591 },
1592 )
1593 .optional()?;
1594 if let Some((
1595 usage_seq,
1596 invocation_count_after,
1597 total_cost_exposed_after,
1598 total_cost_realized_spend_after,
1599 )) = current
1600 {
1601 let event_seq = allocate_budget_replication_seq(&transaction)?;
1602 SqliteBudgetStore::upsert_hold(
1603 &transaction,
1604 hold_id,
1605 capability_id,
1606 grant_index,
1607 cost_units,
1608 cost_units,
1609 HoldDisposition::Open,
1610 authority,
1611 )?;
1612 SqliteBudgetStore::append_mutation_event(
1613 &transaction,
1614 event_id,
1615 Some(hold_id),
1616 authority,
1617 capability_id,
1618 grant_index,
1619 BudgetMutationKind::AuthorizeExposure,
1620 Some(true),
1621 event_seq,
1622 Some(usage_seq),
1623 cost_units,
1624 0,
1625 max_invocations,
1626 max_cost_per_invocation,
1627 max_total_cost_units,
1628 invocation_count_after,
1629 total_cost_exposed_after,
1630 total_cost_realized_spend_after,
1631 )?;
1632 transaction.commit()?;
1633 return Ok(true);
1634 }
1635 }
1636 }
1637 Self::delete_hold_if_exists(&transaction, hold_id)?;
1638 } else if let Some(hold) = SqliteBudgetStore::load_hold(&transaction, hold_id)? {
1639 if hold.capability_id == capability_id
1640 && hold.grant_index == grant_index
1641 && hold.authorized_exposure_units == cost_units
1642 && hold.remaining_exposure_units == cost_units
1643 && hold.invocation_count_debited
1644 && hold.disposition == HoldDisposition::Open
1645 {
1646 let current = transaction
1647 .query_row(
1648 r#"
1649 SELECT seq, invocation_count, total_cost_exposed, total_cost_realized_spend
1650 FROM capability_grant_budgets
1651 WHERE capability_id = ?1 AND grant_index = ?2
1652 "#,
1653 params![capability_id, grant_index as i64],
1654 |row| {
1655 Ok((
1656 row.get::<_, i64>(0)?.max(0) as u64,
1657 row.get::<_, i64>(1)?.max(0) as u32,
1658 row.get::<_, i64>(2)?.max(0) as u64,
1659 row.get::<_, i64>(3)?.max(0) as u64,
1660 ))
1661 },
1662 )
1663 .optional()?;
1664 if let Some((
1665 seq,
1666 invocation_count_after,
1667 total_cost_exposed_after,
1668 total_cost_realized_spend_after,
1669 )) = current
1670 {
1671 if total_cost_exposed_after < cost_units {
1672 transaction.rollback()?;
1673 return Err(BudgetStoreError::Invariant(format!(
1674 "budget hold `{hold_id}` is not reflected in usage totals"
1675 )));
1676 }
1677 SqliteBudgetStore::append_mutation_event(
1678 &transaction,
1679 event_id,
1680 Some(hold_id),
1681 authority,
1682 capability_id,
1683 grant_index,
1684 BudgetMutationKind::AuthorizeExposure,
1685 Some(true),
1686 seq,
1687 Some(seq),
1688 cost_units,
1689 0,
1690 max_invocations,
1691 max_cost_per_invocation,
1692 max_total_cost_units,
1693 invocation_count_after,
1694 total_cost_exposed_after,
1695 total_cost_realized_spend_after,
1696 )?;
1697 transaction.commit()?;
1698 return Ok(true);
1699 }
1700 }
1701 transaction.rollback()?;
1702 return Err(BudgetStoreError::Invariant(format!(
1703 "budget hold `{hold_id}` already exists"
1704 )));
1705 }
1706 }
1707 let new_total_cost_exposed =
1708 current_exposed.checked_add(cost_units).ok_or_else(|| {
1709 BudgetStoreError::Overflow(
1710 "total_cost_exposed + cost_units overflowed u64".to_string(),
1711 )
1712 })?;
1713 let updated_at = unix_now();
1714 let seq = allocate_budget_replication_seq(&transaction)?;
1715 transaction.execute(
1716 r#"
1717 INSERT INTO capability_grant_budgets (
1718 capability_id,
1719 grant_index,
1720 invocation_count,
1721 updated_at,
1722 seq,
1723 total_cost_exposed,
1724 total_cost_realized_spend
1725 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1726 ON CONFLICT(capability_id, grant_index) DO UPDATE SET
1727 invocation_count = excluded.invocation_count,
1728 updated_at = excluded.updated_at,
1729 seq = excluded.seq,
1730 total_cost_exposed = excluded.total_cost_exposed,
1731 total_cost_realized_spend = excluded.total_cost_realized_spend
1732 "#,
1733 params![
1734 capability_id,
1735 grant_index as i64,
1736 (current_count.saturating_add(1)) as i64,
1737 updated_at,
1738 seq as i64,
1739 new_total_cost_exposed as i64,
1740 current_realized as i64,
1741 ],
1742 )?;
1743 if let Some(hold_id) = hold_id {
1744 SqliteBudgetStore::create_hold(
1745 &transaction,
1746 hold_id,
1747 capability_id,
1748 grant_index,
1749 cost_units,
1750 authority,
1751 )?;
1752 }
1753 invocation_count_after = current_count.saturating_add(1);
1754 total_cost_exposed_after = new_total_cost_exposed;
1755 total_cost_realized_spend_after = current_realized;
1756 event_seq = seq;
1757 usage_seq = Some(seq);
1758 } else {
1759 event_seq = allocate_budget_replication_seq(&transaction)?;
1760 invocation_count_after = current_count;
1761 total_cost_exposed_after = current_exposed;
1762 total_cost_realized_spend_after = current_realized;
1763 usage_seq = None;
1764 }
1765 SqliteBudgetStore::append_mutation_event(
1766 &transaction,
1767 event_id,
1768 hold_id,
1769 authority,
1770 capability_id,
1771 grant_index,
1772 BudgetMutationKind::AuthorizeExposure,
1773 Some(allowed),
1774 event_seq,
1775 usage_seq,
1776 cost_units,
1777 0,
1778 max_invocations,
1779 max_cost_per_invocation,
1780 max_total_cost_units,
1781 invocation_count_after,
1782 total_cost_exposed_after,
1783 total_cost_realized_spend_after,
1784 )?;
1785 transaction.commit()?;
1786 Ok(allowed)
1787 }
1788
1789 fn reverse_charge_cost(
1790 &mut self,
1791 capability_id: &str,
1792 grant_index: usize,
1793 cost_units: u64,
1794 ) -> Result<(), BudgetStoreError> {
1795 self.reverse_charge_cost_with_ids(capability_id, grant_index, cost_units, None, None)
1796 }
1797
1798 fn reverse_charge_cost_with_ids(
1799 &mut self,
1800 capability_id: &str,
1801 grant_index: usize,
1802 cost_units: u64,
1803 hold_id: Option<&str>,
1804 event_id: Option<&str>,
1805 ) -> Result<(), BudgetStoreError> {
1806 self.reverse_charge_cost_with_ids_and_authority(
1807 capability_id,
1808 grant_index,
1809 cost_units,
1810 hold_id,
1811 event_id,
1812 None,
1813 )
1814 }
1815
1816 fn reverse_charge_cost_with_ids_and_authority(
1817 &mut self,
1818 capability_id: &str,
1819 grant_index: usize,
1820 cost_units: u64,
1821 hold_id: Option<&str>,
1822 event_id: Option<&str>,
1823 authority: Option<&BudgetEventAuthority>,
1824 ) -> Result<(), BudgetStoreError> {
1825 let transaction = self
1826 .connection
1827 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1828
1829 if SqliteBudgetStore::existing_event_allowed(
1830 &transaction,
1831 event_id,
1832 BudgetMutationKind::ReverseExposure,
1833 capability_id,
1834 grant_index,
1835 hold_id,
1836 authority,
1837 cost_units,
1838 0,
1839 None,
1840 None,
1841 None,
1842 )?
1843 .is_some()
1844 {
1845 transaction.rollback()?;
1846 return Ok(());
1847 }
1848 if let Some(hold_id) = hold_id {
1849 let hold = SqliteBudgetStore::ensure_open_hold(
1850 &transaction,
1851 hold_id,
1852 capability_id,
1853 grant_index,
1854 )?;
1855 if hold.remaining_exposure_units != cost_units || !hold.invocation_count_debited {
1856 transaction.rollback()?;
1857 return Err(BudgetStoreError::Invariant(format!(
1858 "budget hold `{hold_id}` does not match reverse amount"
1859 )));
1860 }
1861 SqliteBudgetStore::validate_hold_authority(
1862 hold_id,
1863 hold.authority.as_ref(),
1864 authority,
1865 )?;
1866 }
1867
1868 let current = transaction
1869 .query_row(
1870 r#"
1871 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1872 FROM capability_grant_budgets
1873 WHERE capability_id = ?1 AND grant_index = ?2
1874 "#,
1875 params![capability_id, grant_index as i64],
1876 |row| {
1877 Ok((
1878 row.get::<_, i64>(0)?,
1879 row.get::<_, i64>(1)?.max(0) as u64,
1880 row.get::<_, i64>(2)?.max(0) as u64,
1881 ))
1882 },
1883 )
1884 .optional()?;
1885
1886 let Some((invocation_count, total_cost_exposed, total_cost_realized_spend)) = current
1887 else {
1888 transaction.rollback()?;
1889 return Err(BudgetStoreError::Invariant(
1890 "missing charged budget row".to_string(),
1891 ));
1892 };
1893
1894 if invocation_count <= 0 {
1895 transaction.rollback()?;
1896 return Err(BudgetStoreError::Invariant(
1897 "cannot reverse charge with zero invocation_count".to_string(),
1898 ));
1899 }
1900 if total_cost_exposed < cost_units {
1901 transaction.rollback()?;
1902 return Err(BudgetStoreError::Invariant(
1903 "cannot reverse charge larger than total_cost_exposed".to_string(),
1904 ));
1905 }
1906
1907 let new_total_cost_exposed = total_cost_exposed - cost_units;
1908 let seq = allocate_budget_replication_seq(&transaction)?;
1909 transaction.execute(
1910 r#"
1911 UPDATE capability_grant_budgets
1912 SET invocation_count = ?3,
1913 updated_at = ?4,
1914 seq = ?5,
1915 total_cost_exposed = ?6
1916 WHERE capability_id = ?1 AND grant_index = ?2
1917 "#,
1918 params![
1919 capability_id,
1920 grant_index as i64,
1921 invocation_count - 1,
1922 unix_now(),
1923 seq as i64,
1924 new_total_cost_exposed as i64,
1925 ],
1926 )?;
1927 if let Some(hold_id) = hold_id {
1928 let next_authority = SqliteBudgetStore::validate_hold_authority(
1929 hold_id,
1930 SqliteBudgetStore::ensure_open_hold(
1931 &transaction,
1932 hold_id,
1933 capability_id,
1934 grant_index,
1935 )?
1936 .authority
1937 .as_ref(),
1938 authority,
1939 )?;
1940 SqliteBudgetStore::update_hold(
1941 &transaction,
1942 hold_id,
1943 0,
1944 HoldDisposition::Reversed,
1945 next_authority.as_ref(),
1946 )?;
1947 }
1948 SqliteBudgetStore::append_mutation_event(
1949 &transaction,
1950 event_id,
1951 hold_id,
1952 authority,
1953 capability_id,
1954 grant_index,
1955 BudgetMutationKind::ReverseExposure,
1956 None,
1957 seq,
1958 Some(seq),
1959 cost_units,
1960 0,
1961 None,
1962 None,
1963 None,
1964 (invocation_count - 1).max(0) as u32,
1965 new_total_cost_exposed,
1966 total_cost_realized_spend,
1967 )?;
1968 transaction.commit()?;
1969 Ok(())
1970 }
1971
1972 fn reduce_charge_cost(
1973 &mut self,
1974 capability_id: &str,
1975 grant_index: usize,
1976 cost_units: u64,
1977 ) -> Result<(), BudgetStoreError> {
1978 self.reduce_charge_cost_with_ids(capability_id, grant_index, cost_units, None, None)
1979 }
1980
1981 fn reduce_charge_cost_with_ids(
1982 &mut self,
1983 capability_id: &str,
1984 grant_index: usize,
1985 cost_units: u64,
1986 hold_id: Option<&str>,
1987 event_id: Option<&str>,
1988 ) -> Result<(), BudgetStoreError> {
1989 self.reduce_charge_cost_with_ids_and_authority(
1990 capability_id,
1991 grant_index,
1992 cost_units,
1993 hold_id,
1994 event_id,
1995 None,
1996 )
1997 }
1998
1999 fn reduce_charge_cost_with_ids_and_authority(
2000 &mut self,
2001 capability_id: &str,
2002 grant_index: usize,
2003 cost_units: u64,
2004 hold_id: Option<&str>,
2005 event_id: Option<&str>,
2006 authority: Option<&BudgetEventAuthority>,
2007 ) -> Result<(), BudgetStoreError> {
2008 let transaction = self
2009 .connection
2010 .transaction_with_behavior(TransactionBehavior::Immediate)?;
2011
2012 if SqliteBudgetStore::existing_event_allowed(
2013 &transaction,
2014 event_id,
2015 BudgetMutationKind::ReleaseExposure,
2016 capability_id,
2017 grant_index,
2018 hold_id,
2019 authority,
2020 cost_units,
2021 0,
2022 None,
2023 None,
2024 None,
2025 )?
2026 .is_some()
2027 {
2028 transaction.rollback()?;
2029 return Ok(());
2030 }
2031 if let Some(hold_id) = hold_id {
2032 let hold = SqliteBudgetStore::ensure_open_hold(
2033 &transaction,
2034 hold_id,
2035 capability_id,
2036 grant_index,
2037 )?;
2038 if hold.remaining_exposure_units < cost_units {
2039 transaction.rollback()?;
2040 return Err(BudgetStoreError::Invariant(format!(
2041 "budget hold `{hold_id}` cannot release more than remaining exposure"
2042 )));
2043 }
2044 SqliteBudgetStore::validate_hold_authority(
2045 hold_id,
2046 hold.authority.as_ref(),
2047 authority,
2048 )?;
2049 }
2050
2051 let current = transaction
2052 .query_row(
2053 r#"
2054 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
2055 FROM capability_grant_budgets
2056 WHERE capability_id = ?1 AND grant_index = ?2
2057 "#,
2058 params![capability_id, grant_index as i64],
2059 |row| {
2060 Ok((
2061 row.get::<_, i64>(0)?,
2062 row.get::<_, i64>(1)?.max(0) as u64,
2063 row.get::<_, i64>(2)?.max(0) as u64,
2064 ))
2065 },
2066 )
2067 .optional()?;
2068
2069 let Some((invocation_count, total_cost_exposed, total_cost_realized_spend)) = current
2070 else {
2071 transaction.rollback()?;
2072 return Err(BudgetStoreError::Invariant(
2073 "missing charged budget row".to_string(),
2074 ));
2075 };
2076
2077 if total_cost_exposed < cost_units {
2078 transaction.rollback()?;
2079 return Err(BudgetStoreError::Invariant(
2080 "cannot reduce charge larger than total_cost_exposed".to_string(),
2081 ));
2082 }
2083
2084 let new_total_cost_exposed = total_cost_exposed - cost_units;
2085 let seq = allocate_budget_replication_seq(&transaction)?;
2086 transaction.execute(
2087 r#"
2088 UPDATE capability_grant_budgets
2089 SET updated_at = ?3,
2090 seq = ?4,
2091 total_cost_exposed = ?5
2092 WHERE capability_id = ?1 AND grant_index = ?2
2093 "#,
2094 params![
2095 capability_id,
2096 grant_index as i64,
2097 unix_now(),
2098 seq as i64,
2099 new_total_cost_exposed as i64,
2100 ],
2101 )?;
2102 if let Some(hold_id) = hold_id {
2103 let hold = SqliteBudgetStore::ensure_open_hold(
2104 &transaction,
2105 hold_id,
2106 capability_id,
2107 grant_index,
2108 )?;
2109 let next_authority = SqliteBudgetStore::validate_hold_authority(
2110 hold_id,
2111 hold.authority.as_ref(),
2112 authority,
2113 )?;
2114 let remaining = hold.remaining_exposure_units - cost_units;
2115 let disposition = if remaining == 0 {
2116 HoldDisposition::Released
2117 } else {
2118 HoldDisposition::Open
2119 };
2120 SqliteBudgetStore::update_hold(
2121 &transaction,
2122 hold_id,
2123 remaining,
2124 disposition,
2125 next_authority.as_ref(),
2126 )?;
2127 }
2128 SqliteBudgetStore::append_mutation_event(
2129 &transaction,
2130 event_id,
2131 hold_id,
2132 authority,
2133 capability_id,
2134 grant_index,
2135 BudgetMutationKind::ReleaseExposure,
2136 None,
2137 seq,
2138 Some(seq),
2139 cost_units,
2140 0,
2141 None,
2142 None,
2143 None,
2144 invocation_count.max(0) as u32,
2145 new_total_cost_exposed,
2146 total_cost_realized_spend,
2147 )?;
2148 transaction.commit()?;
2149 Ok(())
2150 }
2151
2152 fn settle_charge_cost(
2153 &mut self,
2154 capability_id: &str,
2155 grant_index: usize,
2156 exposed_cost_units: u64,
2157 realized_cost_units: u64,
2158 ) -> Result<(), BudgetStoreError> {
2159 self.settle_charge_cost_with_ids(
2160 capability_id,
2161 grant_index,
2162 exposed_cost_units,
2163 realized_cost_units,
2164 None,
2165 None,
2166 )
2167 }
2168
2169 fn settle_charge_cost_with_ids(
2170 &mut self,
2171 capability_id: &str,
2172 grant_index: usize,
2173 exposed_cost_units: u64,
2174 realized_cost_units: u64,
2175 hold_id: Option<&str>,
2176 event_id: Option<&str>,
2177 ) -> Result<(), BudgetStoreError> {
2178 self.settle_charge_cost_with_ids_and_authority(
2179 capability_id,
2180 grant_index,
2181 exposed_cost_units,
2182 realized_cost_units,
2183 hold_id,
2184 event_id,
2185 None,
2186 )
2187 }
2188
2189 fn settle_charge_cost_with_ids_and_authority(
2190 &mut self,
2191 capability_id: &str,
2192 grant_index: usize,
2193 exposed_cost_units: u64,
2194 realized_cost_units: u64,
2195 hold_id: Option<&str>,
2196 event_id: Option<&str>,
2197 authority: Option<&BudgetEventAuthority>,
2198 ) -> Result<(), BudgetStoreError> {
2199 if realized_cost_units > exposed_cost_units {
2200 return Err(BudgetStoreError::Invariant(
2201 "cannot realize spend larger than exposed cost".to_string(),
2202 ));
2203 }
2204
2205 let transaction = self
2206 .connection
2207 .transaction_with_behavior(TransactionBehavior::Immediate)?;
2208
2209 if SqliteBudgetStore::existing_event_allowed(
2210 &transaction,
2211 event_id,
2212 BudgetMutationKind::ReconcileSpend,
2213 capability_id,
2214 grant_index,
2215 hold_id,
2216 authority,
2217 exposed_cost_units,
2218 realized_cost_units,
2219 None,
2220 None,
2221 None,
2222 )?
2223 .is_some()
2224 {
2225 transaction.rollback()?;
2226 return Ok(());
2227 }
2228 if let Some(hold_id) = hold_id {
2229 let hold = SqliteBudgetStore::ensure_open_hold(
2230 &transaction,
2231 hold_id,
2232 capability_id,
2233 grant_index,
2234 )?;
2235 if hold.remaining_exposure_units != exposed_cost_units {
2236 transaction.rollback()?;
2237 return Err(BudgetStoreError::Invariant(format!(
2238 "budget hold `{hold_id}` does not match reconciled exposure"
2239 )));
2240 }
2241 SqliteBudgetStore::validate_hold_authority(
2242 hold_id,
2243 hold.authority.as_ref(),
2244 authority,
2245 )?;
2246 }
2247
2248 let current = transaction
2249 .query_row(
2250 r#"
2251 SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
2252 FROM capability_grant_budgets
2253 WHERE capability_id = ?1 AND grant_index = ?2
2254 "#,
2255 params![capability_id, grant_index as i64],
2256 |row| {
2257 Ok((
2258 row.get::<_, i64>(0)?,
2259 row.get::<_, i64>(1)?.max(0) as u64,
2260 row.get::<_, i64>(2)?.max(0) as u64,
2261 ))
2262 },
2263 )
2264 .optional()?;
2265
2266 let Some((invocation_count, total_cost_exposed, total_cost_realized_spend)) = current
2267 else {
2268 transaction.rollback()?;
2269 return Err(BudgetStoreError::Invariant(
2270 "missing charged budget row".to_string(),
2271 ));
2272 };
2273
2274 if invocation_count <= 0 {
2275 transaction.rollback()?;
2276 return Err(BudgetStoreError::Invariant(
2277 "cannot settle charge with zero invocation_count".to_string(),
2278 ));
2279 }
2280 if total_cost_exposed < exposed_cost_units {
2281 transaction.rollback()?;
2282 return Err(BudgetStoreError::Invariant(
2283 "cannot settle more exposure than total_cost_exposed".to_string(),
2284 ));
2285 }
2286
2287 let new_total_cost_exposed = total_cost_exposed - exposed_cost_units;
2288 let new_total_cost_realized_spend = total_cost_realized_spend
2289 .checked_add(realized_cost_units)
2290 .ok_or_else(|| {
2291 BudgetStoreError::Overflow(
2292 "total_cost_realized_spend + realized_cost_units overflowed u64".to_string(),
2293 )
2294 })?;
2295
2296 let seq = allocate_budget_replication_seq(&transaction)?;
2297 transaction.execute(
2298 r#"
2299 UPDATE capability_grant_budgets
2300 SET updated_at = ?3,
2301 seq = ?4,
2302 total_cost_exposed = ?5,
2303 total_cost_realized_spend = ?6
2304 WHERE capability_id = ?1 AND grant_index = ?2
2305 "#,
2306 params![
2307 capability_id,
2308 grant_index as i64,
2309 unix_now(),
2310 seq as i64,
2311 new_total_cost_exposed as i64,
2312 new_total_cost_realized_spend as i64,
2313 ],
2314 )?;
2315 if let Some(hold_id) = hold_id {
2316 let next_authority = SqliteBudgetStore::validate_hold_authority(
2317 hold_id,
2318 SqliteBudgetStore::ensure_open_hold(
2319 &transaction,
2320 hold_id,
2321 capability_id,
2322 grant_index,
2323 )?
2324 .authority
2325 .as_ref(),
2326 authority,
2327 )?;
2328 SqliteBudgetStore::update_hold(
2329 &transaction,
2330 hold_id,
2331 0,
2332 HoldDisposition::Reconciled,
2333 next_authority.as_ref(),
2334 )?;
2335 }
2336 SqliteBudgetStore::append_mutation_event(
2337 &transaction,
2338 event_id,
2339 hold_id,
2340 authority,
2341 capability_id,
2342 grant_index,
2343 BudgetMutationKind::ReconcileSpend,
2344 None,
2345 seq,
2346 Some(seq),
2347 exposed_cost_units,
2348 realized_cost_units,
2349 None,
2350 None,
2351 None,
2352 invocation_count.max(0) as u32,
2353 new_total_cost_exposed,
2354 new_total_cost_realized_spend,
2355 )?;
2356 transaction.commit()?;
2357 Ok(())
2358 }
2359
2360 fn list_usages(
2361 &self,
2362 limit: usize,
2363 capability_id: Option<&str>,
2364 ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
2365 let mut statement = self.connection.prepare(
2366 r#"
2367 SELECT
2368 capability_id,
2369 grant_index,
2370 invocation_count,
2371 updated_at,
2372 seq,
2373 total_cost_exposed,
2374 total_cost_realized_spend
2375 FROM capability_grant_budgets
2376 WHERE (?1 IS NULL OR capability_id = ?1)
2377 ORDER BY updated_at DESC, capability_id ASC, grant_index ASC
2378 LIMIT ?2
2379 "#,
2380 )?;
2381 let rows = statement.query_map(params![capability_id, limit as i64], record_from_row)?;
2382 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
2383 }
2384
2385 fn get_usage(
2386 &self,
2387 capability_id: &str,
2388 grant_index: usize,
2389 ) -> Result<Option<BudgetUsageRecord>, BudgetStoreError> {
2390 self.connection
2391 .query_row(
2392 r#"
2393 SELECT
2394 capability_id,
2395 grant_index,
2396 invocation_count,
2397 updated_at,
2398 seq,
2399 total_cost_exposed,
2400 total_cost_realized_spend
2401 FROM capability_grant_budgets
2402 WHERE capability_id = ?1 AND grant_index = ?2
2403 "#,
2404 params![capability_id, grant_index as i64],
2405 record_from_row,
2406 )
2407 .optional()
2408 .map_err(Into::into)
2409 }
2410
2411 fn list_mutation_events(
2412 &self,
2413 limit: usize,
2414 capability_id: Option<&str>,
2415 grant_index: Option<usize>,
2416 ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
2417 let mut statement = self.connection.prepare(
2418 r#"
2419 SELECT
2420 event_id,
2421 hold_id,
2422 capability_id,
2423 grant_index,
2424 kind,
2425 allowed,
2426 recorded_at,
2427 event_seq,
2428 usage_seq,
2429 exposure_units,
2430 realized_spend_units,
2431 max_invocations,
2432 max_exposure_per_invocation,
2433 max_total_exposure_units,
2434 invocation_count_after,
2435 total_cost_exposed_after,
2436 total_cost_realized_spend_after,
2437 authority_id,
2438 lease_id,
2439 lease_epoch
2440 FROM budget_mutation_events
2441 WHERE (?1 IS NULL OR capability_id = ?1)
2442 AND (?2 IS NULL OR grant_index = ?2)
2443 ORDER BY event_seq ASC
2444 LIMIT ?3
2445 "#,
2446 )?;
2447 let rows = statement.query_map(
2448 params![
2449 capability_id,
2450 grant_index.map(|value| value as i64),
2451 limit as i64
2452 ],
2453 mutation_record_from_row,
2454 )?;
2455 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
2456 }
2457}
2458
2459fn checked_committed_cost_units(
2460 total_cost_exposed: u64,
2461 total_cost_realized_spend: u64,
2462) -> Result<u64, BudgetStoreError> {
2463 total_cost_exposed
2464 .checked_add(total_cost_realized_spend)
2465 .ok_or_else(|| {
2466 BudgetStoreError::Overflow(
2467 "total_cost_exposed + total_cost_realized_spend overflowed u64".to_string(),
2468 )
2469 })
2470}
2471
2472fn record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<BudgetUsageRecord> {
2473 let total_cost_exposed = row.get::<_, i64>(5)?.max(0) as u64;
2474 let total_cost_realized_spend = row.get::<_, i64>(6)?.max(0) as u64;
2475 Ok(BudgetUsageRecord {
2476 capability_id: row.get(0)?,
2477 grant_index: row.get::<_, i64>(1)?.max(0) as u32,
2478 invocation_count: row.get::<_, i64>(2)?.max(0) as u32,
2479 updated_at: row.get(3)?,
2480 seq: row.get::<_, i64>(4)?.max(0) as u64,
2481 total_cost_exposed,
2482 total_cost_realized_spend,
2483 })
2484}
2485
2486fn mutation_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<BudgetMutationRecord> {
2487 let kind = row.get::<_, String>(4)?;
2488 let kind = BudgetMutationKind::parse(&kind).ok_or_else(|| {
2489 rusqlite::Error::FromSqlConversionFailure(
2490 4,
2491 rusqlite::types::Type::Text,
2492 Box::new(std::io::Error::new(
2493 std::io::ErrorKind::InvalidData,
2494 format!("unknown budget mutation kind `{kind}`"),
2495 )),
2496 )
2497 })?;
2498 let authority = sqlite_budget_event_authority(row.get(17)?, row.get(18)?, row.get(19)?)?;
2499 Ok(BudgetMutationRecord {
2500 event_id: row.get(0)?,
2501 hold_id: row.get(1)?,
2502 capability_id: row.get(2)?,
2503 grant_index: row.get::<_, i64>(3)?.max(0) as u32,
2504 kind,
2505 allowed: row.get::<_, Option<i64>>(5)?.map(|value| value > 0),
2506 recorded_at: row.get(6)?,
2507 event_seq: row.get::<_, i64>(7)?.max(0) as u64,
2508 usage_seq: row
2509 .get::<_, Option<i64>>(8)?
2510 .map(|value| value.max(0) as u64),
2511 exposure_units: row.get::<_, i64>(9)?.max(0) as u64,
2512 realized_spend_units: row.get::<_, i64>(10)?.max(0) as u64,
2513 max_invocations: row
2514 .get::<_, Option<i64>>(11)?
2515 .map(|value| value.max(0) as u32),
2516 max_cost_per_invocation: row
2517 .get::<_, Option<i64>>(12)?
2518 .map(|value| value.max(0) as u64),
2519 max_total_cost_units: row
2520 .get::<_, Option<i64>>(13)?
2521 .map(|value| value.max(0) as u64),
2522 invocation_count_after: row.get::<_, i64>(14)?.max(0) as u32,
2523 total_cost_exposed_after: row.get::<_, i64>(15)?.max(0) as u64,
2524 total_cost_realized_spend_after: row.get::<_, i64>(16)?.max(0) as u64,
2525 authority,
2526 })
2527}
2528
2529fn sqlite_budget_event_authority(
2530 authority_id: Option<String>,
2531 lease_id: Option<String>,
2532 lease_epoch: Option<i64>,
2533) -> rusqlite::Result<Option<BudgetEventAuthority>> {
2534 match (authority_id, lease_id, lease_epoch) {
2535 (None, None, None) => Ok(None),
2536 (Some(authority_id), Some(lease_id), Some(lease_epoch)) if lease_epoch >= 0 => {
2537 Ok(Some(BudgetEventAuthority {
2538 authority_id,
2539 lease_id,
2540 lease_epoch: lease_epoch as u64,
2541 }))
2542 }
2543 _ => Err(rusqlite::Error::FromSqlConversionFailure(
2544 0,
2545 rusqlite::types::Type::Text,
2546 Box::new(std::io::Error::new(
2547 std::io::ErrorKind::InvalidData,
2548 "invalid budget authority lease columns",
2549 )),
2550 )),
2551 }
2552}
2553
2554fn ensure_budget_seq_column(connection: &Connection) -> Result<(), BudgetStoreError> {
2555 let mut statement = connection.prepare("PRAGMA table_info(capability_grant_budgets)")?;
2556 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2557 let has_seq = columns
2558 .collect::<Result<Vec<_>, _>>()?
2559 .iter()
2560 .any(|column| column == "seq");
2561 if !has_seq {
2562 connection.execute(
2563 "ALTER TABLE capability_grant_budgets ADD COLUMN seq INTEGER NOT NULL DEFAULT 0",
2564 [],
2565 )?;
2566 }
2567 connection.execute(
2568 "CREATE INDEX IF NOT EXISTS idx_capability_grant_budgets_seq ON capability_grant_budgets(seq)",
2569 [],
2570 )?;
2571 Ok(())
2572}
2573
2574fn ensure_split_budget_cost_columns(connection: &Connection) -> Result<(), BudgetStoreError> {
2575 let mut statement = connection.prepare("PRAGMA table_info(capability_grant_budgets)")?;
2576 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2577 let columns = columns.collect::<Result<Vec<_>, _>>()?;
2578 if !columns.iter().any(|c| c == "total_cost_exposed")
2579 || !columns.iter().any(|c| c == "total_cost_realized_spend")
2580 {
2581 return Err(BudgetStoreError::Invariant(
2582 "unsupported budget schema: missing split cost columns `total_cost_exposed` and `total_cost_realized_spend`".to_string(),
2583 ));
2584 }
2585 Ok(())
2586}
2587
2588fn ensure_budget_hold_authority_columns(connection: &Connection) -> Result<(), BudgetStoreError> {
2589 let mut statement = connection.prepare("PRAGMA table_info(budget_authorization_holds)")?;
2590 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2591 let columns = columns.collect::<Result<Vec<_>, _>>()?;
2592 if !columns.iter().any(|column| column == "authority_id") {
2593 connection.execute(
2594 "ALTER TABLE budget_authorization_holds ADD COLUMN authority_id TEXT",
2595 [],
2596 )?;
2597 }
2598 if !columns.iter().any(|column| column == "lease_id") {
2599 connection.execute(
2600 "ALTER TABLE budget_authorization_holds ADD COLUMN lease_id TEXT",
2601 [],
2602 )?;
2603 }
2604 if !columns.iter().any(|column| column == "lease_epoch") {
2605 connection.execute(
2606 "ALTER TABLE budget_authorization_holds ADD COLUMN lease_epoch INTEGER",
2607 [],
2608 )?;
2609 }
2610 Ok(())
2611}
2612
2613fn ensure_budget_mutation_event_authority_columns(
2614 connection: &Connection,
2615) -> Result<(), BudgetStoreError> {
2616 let mut statement = connection.prepare("PRAGMA table_info(budget_mutation_events)")?;
2617 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2618 let columns = columns.collect::<Result<Vec<_>, _>>()?;
2619 if !columns.iter().any(|column| column == "authority_id") {
2620 connection.execute(
2621 "ALTER TABLE budget_mutation_events ADD COLUMN authority_id TEXT",
2622 [],
2623 )?;
2624 }
2625 if !columns.iter().any(|column| column == "lease_id") {
2626 connection.execute(
2627 "ALTER TABLE budget_mutation_events ADD COLUMN lease_id TEXT",
2628 [],
2629 )?;
2630 }
2631 if !columns.iter().any(|column| column == "lease_epoch") {
2632 connection.execute(
2633 "ALTER TABLE budget_mutation_events ADD COLUMN lease_epoch INTEGER",
2634 [],
2635 )?;
2636 }
2637 Ok(())
2638}
2639
2640fn ensure_budget_mutation_event_seq_column(
2641 connection: &Connection,
2642) -> Result<(), BudgetStoreError> {
2643 let mut statement = connection.prepare("PRAGMA table_info(budget_mutation_events)")?;
2644 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2645 let columns = columns.collect::<Result<Vec<_>, _>>()?;
2646 if !columns.iter().any(|column| column == "event_seq") {
2647 connection.execute(
2648 "ALTER TABLE budget_mutation_events ADD COLUMN event_seq INTEGER",
2649 [],
2650 )?;
2651 }
2652 connection.execute(
2653 "CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_mutation_events_event_seq ON budget_mutation_events(event_seq)",
2654 [],
2655 )?;
2656 Ok(())
2657}
2658
2659fn initialize_budget_replication_seq(connection: &mut Connection) -> Result<(), BudgetStoreError> {
2670 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2671 let mut next_seq = current_budget_replication_seq(&transaction)?
2672 .max(max_budget_usage_seq(&transaction)?)
2673 .max(max_budget_mutation_event_seq(&transaction)?);
2674 let mut statement = transaction.prepare(
2675 r#"
2676 SELECT rowid
2677 FROM capability_grant_budgets
2678 WHERE seq <= 0
2679 ORDER BY updated_at ASC, capability_id ASC, grant_index ASC
2680 "#,
2681 )?;
2682 let pending = statement
2683 .query_map([], |row| row.get::<_, i64>(0))?
2684 .collect::<Result<Vec<_>, _>>()?;
2685 drop(statement);
2686 for rowid in pending {
2687 next_seq = next_seq.saturating_add(1);
2688 transaction.execute(
2689 "UPDATE capability_grant_budgets SET seq = ?1 WHERE rowid = ?2",
2690 params![next_seq as i64, rowid],
2691 )?;
2692 }
2693
2694 let existing_event_seq_count = transaction.query_row(
2695 "SELECT COUNT(*) FROM budget_mutation_events WHERE event_seq IS NOT NULL AND event_seq > 0",
2696 [],
2697 |row| row.get::<_, i64>(0),
2698 )?;
2699 if existing_event_seq_count <= 0 {
2700 let mut statement = transaction.prepare(
2701 r#"
2702 SELECT rowid
2703 FROM budget_mutation_events
2704 ORDER BY rowid ASC
2705 "#,
2706 )?;
2707 let pending = statement
2708 .query_map([], |row| row.get::<_, i64>(0))?
2709 .collect::<Result<Vec<_>, _>>()?;
2710 drop(statement);
2711 let mut event_seq = 0u64;
2712 for rowid in pending {
2713 event_seq = event_seq.saturating_add(1);
2714 transaction.execute(
2715 "UPDATE budget_mutation_events SET event_seq = ?1 WHERE rowid = ?2",
2716 params![event_seq as i64, rowid],
2717 )?;
2718 }
2719 next_seq = next_seq.max(event_seq);
2720 } else {
2721 let mut statement = transaction.prepare(
2722 r#"
2723 SELECT rowid
2724 FROM budget_mutation_events
2725 WHERE event_seq IS NULL OR event_seq <= 0
2726 ORDER BY rowid ASC
2727 "#,
2728 )?;
2729 let pending = statement
2730 .query_map([], |row| row.get::<_, i64>(0))?
2731 .collect::<Result<Vec<_>, _>>()?;
2732 drop(statement);
2733 for rowid in pending {
2734 next_seq = next_seq.saturating_add(1);
2735 transaction.execute(
2736 "UPDATE budget_mutation_events SET event_seq = ?1 WHERE rowid = ?2",
2737 params![next_seq as i64, rowid],
2738 )?;
2739 }
2740 }
2741 set_budget_replication_seq(&transaction, next_seq)?;
2742 transaction.commit()?;
2743 Ok(())
2744}
2745
2746fn allocate_budget_replication_seq(
2747 transaction: &rusqlite::Transaction<'_>,
2748) -> Result<u64, BudgetStoreError> {
2749 let current = current_budget_replication_seq(transaction)?
2750 .max(max_budget_usage_seq(transaction)?)
2751 .max(max_budget_mutation_event_seq(transaction)?);
2752 let next_seq = current.saturating_add(1);
2753 set_budget_replication_seq(transaction, next_seq)?;
2754 Ok(next_seq)
2755}
2756
2757fn raise_budget_replication_seq_floor(
2758 transaction: &rusqlite::Transaction<'_>,
2759 seq: u64,
2760) -> Result<(), BudgetStoreError> {
2761 let current = current_budget_replication_seq(transaction)?;
2762 if seq > current {
2763 set_budget_replication_seq(transaction, seq)?;
2764 }
2765 Ok(())
2766}
2767
2768fn current_budget_replication_seq(
2769 transaction: &rusqlite::Transaction<'_>,
2770) -> Result<u64, BudgetStoreError> {
2771 let next_seq = transaction.query_row(
2772 "SELECT next_seq FROM budget_replication_meta WHERE singleton = 1",
2773 [],
2774 |row| row.get::<_, i64>(0),
2775 )?;
2776 Ok(next_seq.max(0) as u64)
2777}
2778
2779fn max_budget_usage_seq(transaction: &rusqlite::Transaction<'_>) -> Result<u64, BudgetStoreError> {
2780 let max_seq = transaction.query_row(
2781 "SELECT COALESCE(MAX(seq), 0) FROM capability_grant_budgets",
2782 [],
2783 |row| row.get::<_, i64>(0),
2784 )?;
2785 Ok(max_seq.max(0) as u64)
2786}
2787
2788fn max_budget_mutation_event_seq(
2789 transaction: &rusqlite::Transaction<'_>,
2790) -> Result<u64, BudgetStoreError> {
2791 let max_seq = transaction.query_row(
2792 "SELECT COALESCE(MAX(event_seq), 0) FROM budget_mutation_events",
2793 [],
2794 |row| row.get::<_, i64>(0),
2795 )?;
2796 Ok(max_seq.max(0) as u64)
2797}
2798
2799fn set_budget_replication_seq(
2800 transaction: &rusqlite::Transaction<'_>,
2801 seq: u64,
2802) -> Result<(), BudgetStoreError> {
2803 transaction.execute(
2804 "UPDATE budget_replication_meta SET next_seq = ?1 WHERE singleton = 1",
2805 params![seq as i64],
2806 )?;
2807 Ok(())
2808}
2809
2810fn unix_now() -> i64 {
2811 SystemTime::now()
2812 .duration_since(UNIX_EPOCH)
2813 .map(|duration| duration.as_secs() as i64)
2814 .unwrap_or(0)
2815}
2816
2817#[cfg(test)]
2818#[allow(clippy::expect_used, clippy::unwrap_used)]
2819mod tests {
2820 use super::*;
2821 use chio_kernel::InMemoryBudgetStore;
2822
2823 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
2824 let nonce = SystemTime::now()
2825 .duration_since(UNIX_EPOCH)
2826 .expect("time before epoch")
2827 .as_nanos();
2828 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
2829 }
2830
2831 fn usage_record(
2832 capability_id: &str,
2833 grant_index: u32,
2834 invocation_count: u32,
2835 updated_at: i64,
2836 seq: u64,
2837 total_cost_exposed: u64,
2838 total_cost_realized_spend: u64,
2839 ) -> BudgetUsageRecord {
2840 BudgetUsageRecord {
2841 capability_id: capability_id.to_string(),
2842 grant_index,
2843 invocation_count,
2844 updated_at,
2845 seq,
2846 total_cost_exposed,
2847 total_cost_realized_spend,
2848 }
2849 }
2850
2851 fn assert_usage_totals(record: &BudgetUsageRecord, exposed: u64, realized: u64) {
2852 assert_eq!(record.total_cost_exposed, exposed);
2853 assert_eq!(record.total_cost_realized_spend, realized);
2854 assert_eq!(record.committed_cost_units().unwrap(), exposed + realized);
2855 }
2856
2857 fn authority(authority_id: &str, lease_id: &str, lease_epoch: u64) -> BudgetEventAuthority {
2858 BudgetEventAuthority {
2859 authority_id: authority_id.to_string(),
2860 lease_id: lease_id.to_string(),
2861 lease_epoch,
2862 }
2863 }
2864
2865 #[test]
2866 fn sqlite_budget_store_persists_across_reopen() {
2867 let path = unique_db_path("chio-budgets");
2868 {
2869 let mut store = SqliteBudgetStore::open(&path).unwrap();
2870 assert!(store.try_increment("cap-1", 0, Some(2)).unwrap());
2871 assert!(store.try_increment("cap-1", 0, Some(2)).unwrap());
2872 assert!(!store.try_increment("cap-1", 0, Some(2)).unwrap());
2873 }
2874
2875 let reopened = SqliteBudgetStore::open(&path).unwrap();
2876 let records = reopened.list_usages(10, Some("cap-1")).unwrap();
2877 assert_eq!(records.len(), 1);
2878 assert_eq!(records[0].invocation_count, 2);
2879
2880 let _ = fs::remove_file(path);
2881 }
2882
2883 #[test]
2884 fn sqlite_budget_store_rejects_pre_split_budget_schema() {
2885 let path = unique_db_path("chio-budget-pre-split-schema");
2886 {
2887 let connection = Connection::open(&path).unwrap();
2888 connection
2889 .execute_batch(
2890 r#"
2891 CREATE TABLE capability_grant_budgets (
2892 capability_id TEXT NOT NULL,
2893 grant_index INTEGER NOT NULL,
2894 invocation_count INTEGER NOT NULL,
2895 updated_at INTEGER NOT NULL,
2896 total_cost_charged INTEGER NOT NULL DEFAULT 0,
2897 PRIMARY KEY (capability_id, grant_index)
2898 );
2899 INSERT INTO capability_grant_budgets (
2900 capability_id,
2901 grant_index,
2902 invocation_count,
2903 updated_at,
2904 total_cost_charged
2905 ) VALUES ('cap-1', 0, 1, 10, 55);
2906 "#,
2907 )
2908 .unwrap();
2909 }
2910
2911 let error = match SqliteBudgetStore::open(&path) {
2912 Ok(_) => panic!("pre-split budget schema should be rejected"),
2913 Err(error) => error,
2914 };
2915 assert!(error.to_string().contains(
2916 "missing split cost columns `total_cost_exposed` and `total_cost_realized_spend`"
2917 ));
2918
2919 let _ = fs::remove_file(path);
2920 }
2921
2922 #[test]
2923 fn sqlite_budget_store_upsert_usage_keeps_newer_seq_state() {
2924 let path = unique_db_path("chio-budget-upsert");
2925 let mut store = SqliteBudgetStore::open(&path).unwrap();
2926 store
2927 .upsert_usage(&usage_record("cap-1", 0, 3, 10, 3, 300, 0))
2928 .unwrap();
2929 store
2930 .upsert_usage(&usage_record("cap-1", 0, 2, 9, 2, 200, 0))
2931 .unwrap();
2932
2933 let records = store.list_usages(10, Some("cap-1")).unwrap();
2934 assert_eq!(records[0].invocation_count, 3);
2935 assert_usage_totals(&records[0], 300, 0);
2936 assert_eq!(records[0].seq, 3);
2937
2938 let _ = fs::remove_file(path);
2939 }
2940
2941 #[test]
2942 fn sqlite_budget_store_uses_seq_for_same_key_delta_queries() {
2943 let path = unique_db_path("chio-budget-seq-delta");
2944 let mut store = SqliteBudgetStore::open(&path).unwrap();
2945
2946 assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2947 let first = store.list_usages(10, Some("cap-1")).unwrap();
2948 assert_eq!(first.len(), 1);
2949 let first_seq = first[0].seq;
2950
2951 assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2952 assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2953
2954 let delta = store.list_usages_after(10, Some(first_seq)).unwrap();
2955 assert_eq!(delta.len(), 1);
2956 assert_eq!(delta[0].invocation_count, 3);
2957 assert!(delta[0].seq > first_seq);
2958
2959 let _ = fs::remove_file(path);
2960 }
2961
2962 #[test]
2963 fn sqlite_budget_store_preserves_imported_seq_across_failover_writes() {
2964 let path = unique_db_path("chio-budget-seq-floor");
2965 let mut store = SqliteBudgetStore::open(&path).unwrap();
2966
2967 store
2968 .upsert_usage(&usage_record("cap-1", 0, 3, 10, 42, 0, 0))
2969 .unwrap();
2970 assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2971
2972 let records = store.list_usages(10, Some("cap-1")).unwrap();
2973 assert_eq!(records.len(), 1);
2974 assert_eq!(records[0].invocation_count, 4);
2975 assert_eq!(records[0].seq, 43);
2976
2977 let _ = fs::remove_file(path);
2978 }
2979
2980 #[test]
2983 fn budget_store_try_charge_cost_within_limits_returns_true_sqlite() {
2984 let path = unique_db_path("chio-charge-cost-ok");
2985 let mut store = SqliteBudgetStore::open(&path).unwrap();
2986 let ok = store
2988 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
2989 .unwrap();
2990 assert!(ok);
2991
2992 let records = store.list_usages(10, Some("cap-1")).unwrap();
2993 assert_eq!(records[0].invocation_count, 1);
2994 assert_usage_totals(&records[0], 100, 0);
2995
2996 let _ = fs::remove_file(path);
2997 }
2998
2999 #[test]
3000 fn budget_store_try_charge_cost_exceeds_per_invocation_cap_sqlite() {
3001 let path = unique_db_path("chio-charge-cost-per-inv");
3002 let mut store = SqliteBudgetStore::open(&path).unwrap();
3003 let ok = store
3005 .try_charge_cost("cap-1", 0, Some(10), 500, Some(200), Some(1000))
3006 .unwrap();
3007 assert!(!ok);
3008
3009 let records = store.list_usages(10, Some("cap-1")).unwrap();
3011 assert!(records.is_empty() || records[0].invocation_count == 0);
3012
3013 let _ = fs::remove_file(path);
3014 }
3015
3016 #[test]
3017 fn budget_store_try_charge_cost_exceeds_total_cap_sqlite() {
3018 let path = unique_db_path("chio-charge-cost-total");
3019 let mut store = SqliteBudgetStore::open(&path).unwrap();
3020 assert!(store
3022 .try_charge_cost("cap-1", 0, Some(10), 900, Some(1000), Some(1000))
3023 .unwrap());
3024 let ok = store
3026 .try_charge_cost("cap-1", 0, Some(10), 200, Some(1000), Some(1000))
3027 .unwrap();
3028 assert!(!ok);
3029
3030 let records = store.list_usages(10, Some("cap-1")).unwrap();
3031 assert_usage_totals(&records[0], 900, 0);
3032
3033 let _ = fs::remove_file(path);
3034 }
3035
3036 #[test]
3037 fn budget_store_try_charge_cost_atomic_increment_sqlite() {
3038 let path = unique_db_path("chio-charge-cost-atomic");
3039 let mut store = SqliteBudgetStore::open(&path).unwrap();
3040 assert!(store
3041 .try_charge_cost("cap-1", 0, None, 100, Some(200), Some(1000))
3042 .unwrap());
3043 assert!(store
3044 .try_charge_cost("cap-1", 0, None, 150, Some(200), Some(1000))
3045 .unwrap());
3046
3047 let records = store.list_usages(10, Some("cap-1")).unwrap();
3048 assert_eq!(records[0].invocation_count, 2);
3049 assert_usage_totals(&records[0], 250, 0);
3050
3051 let _ = fs::remove_file(path);
3052 }
3053
3054 #[test]
3055 fn budget_store_try_charge_cost_within_limits_returns_true_inmemory() {
3056 let mut store = InMemoryBudgetStore::new();
3057 let ok = store
3058 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3059 .unwrap();
3060 assert!(ok);
3061
3062 let records = store.list_usages(10, Some("cap-1")).unwrap();
3063 assert_eq!(records[0].invocation_count, 1);
3064 assert_usage_totals(&records[0], 100, 0);
3065 }
3066
3067 #[test]
3068 fn budget_store_try_charge_cost_exceeds_per_invocation_cap_inmemory() {
3069 let mut store = InMemoryBudgetStore::new();
3070 let ok = store
3071 .try_charge_cost("cap-1", 0, Some(10), 500, Some(200), Some(1000))
3072 .unwrap();
3073 assert!(!ok);
3074 }
3075
3076 #[test]
3077 fn budget_store_try_charge_cost_exceeds_total_cap_inmemory() {
3078 let mut store = InMemoryBudgetStore::new();
3079 assert!(store
3080 .try_charge_cost("cap-1", 0, Some(10), 900, Some(1000), Some(1000))
3081 .unwrap());
3082 let ok = store
3083 .try_charge_cost("cap-1", 0, Some(10), 200, Some(1000), Some(1000))
3084 .unwrap();
3085 assert!(!ok);
3086 }
3087
3088 #[test]
3089 fn budget_usage_record_includes_split_cost_state() {
3090 let mut store = InMemoryBudgetStore::new();
3091 assert!(store
3092 .try_charge_cost("cap-1", 0, None, 42, None, None)
3093 .unwrap());
3094 let records = store.list_usages(10, Some("cap-1")).unwrap();
3095 assert_usage_totals(&records[0], 42, 0);
3096 }
3097
3098 #[test]
3099 fn budget_store_reverse_charge_cost_restores_prior_state_inmemory() {
3100 let mut store = InMemoryBudgetStore::new();
3101 assert!(store
3102 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3103 .unwrap());
3104
3105 store.reverse_charge_cost("cap-1", 0, 100).unwrap();
3106
3107 let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3108 assert_eq!(record.invocation_count, 0);
3109 assert_usage_totals(&record, 0, 0);
3110 }
3111
3112 #[test]
3113 fn budget_store_reverse_charge_cost_restores_prior_state_sqlite() {
3114 let path = unique_db_path("chio-reverse-charge");
3115 let mut store = SqliteBudgetStore::open(&path).unwrap();
3116 assert!(store
3117 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3118 .unwrap());
3119
3120 store.reverse_charge_cost("cap-1", 0, 100).unwrap();
3121
3122 let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3123 assert_eq!(record.invocation_count, 0);
3124 assert_usage_totals(&record, 0, 0);
3125
3126 let _ = fs::remove_file(path);
3127 }
3128
3129 #[test]
3130 fn budget_store_reduce_charge_cost_releases_exposure_only_inmemory() {
3131 let mut store = InMemoryBudgetStore::new();
3132 assert!(store
3133 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3134 .unwrap());
3135
3136 store.reduce_charge_cost("cap-1", 0, 25).unwrap();
3137
3138 let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3139 assert_eq!(record.invocation_count, 1);
3140 assert_usage_totals(&record, 75, 0);
3141 }
3142
3143 #[test]
3144 fn budget_store_reduce_charge_cost_releases_exposure_only_sqlite() {
3145 let path = unique_db_path("chio-reduce-charge");
3146 let mut store = SqliteBudgetStore::open(&path).unwrap();
3147 assert!(store
3148 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3149 .unwrap());
3150
3151 store.reduce_charge_cost("cap-1", 0, 25).unwrap();
3152
3153 let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3154 assert_eq!(record.invocation_count, 1);
3155 assert_usage_totals(&record, 75, 0);
3156
3157 let _ = fs::remove_file(path);
3158 }
3159
3160 #[test]
3161 fn budget_store_settle_charge_cost_moves_exposure_to_realized_inmemory() {
3162 let mut store = InMemoryBudgetStore::new();
3163 assert!(store
3164 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3165 .unwrap());
3166
3167 store.settle_charge_cost("cap-1", 0, 100, 75).unwrap();
3168
3169 let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3170 assert_eq!(record.invocation_count, 1);
3171 assert_usage_totals(&record, 0, 75);
3172 }
3173
3174 #[test]
3175 fn budget_store_settle_charge_cost_moves_exposure_to_realized_sqlite() {
3176 let path = unique_db_path("chio-settle-charge");
3177 let mut store = SqliteBudgetStore::open(&path).unwrap();
3178 assert!(store
3179 .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3180 .unwrap());
3181
3182 store.settle_charge_cost("cap-1", 0, 100, 75).unwrap();
3183
3184 let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3185 assert_eq!(record.invocation_count, 1);
3186 assert_usage_totals(&record, 0, 75);
3187
3188 let _ = fs::remove_file(path);
3189 }
3190
3191 #[test]
3192 fn budget_store_try_charge_cost_with_ids_is_idempotent_inmemory() {
3193 let mut store = InMemoryBudgetStore::new();
3194 let hold_id = "hold-cap-1-0";
3195 let event_id = "hold-cap-1-0:authorize";
3196
3197 assert!(store
3198 .try_charge_cost_with_ids(
3199 "cap-1",
3200 0,
3201 Some(10),
3202 100,
3203 Some(200),
3204 Some(1000),
3205 Some(hold_id),
3206 Some(event_id),
3207 )
3208 .unwrap());
3209 assert!(store
3210 .try_charge_cost_with_ids(
3211 "cap-1",
3212 0,
3213 Some(10),
3214 100,
3215 Some(200),
3216 Some(1000),
3217 Some(hold_id),
3218 Some(event_id),
3219 )
3220 .unwrap());
3221
3222 let usage = store.get_usage("cap-1", 0).unwrap().unwrap();
3223 assert_eq!(usage.invocation_count, 1);
3224 assert_usage_totals(&usage, 100, 0);
3225
3226 let events = store
3227 .list_mutation_events(10, Some("cap-1"), Some(0))
3228 .unwrap();
3229 assert_eq!(events.len(), 1);
3230 assert_eq!(events[0].event_id, event_id);
3231 assert_eq!(events[0].hold_id.as_deref(), Some(hold_id));
3232 assert_eq!(events[0].kind, BudgetMutationKind::AuthorizeExposure);
3233 assert_eq!(events[0].allowed, Some(true));
3234 }
3235
3236 #[test]
3237 fn budget_store_try_charge_cost_with_ids_is_idempotent_sqlite() {
3238 let path = unique_db_path("chio-charge-cost-idempotent");
3239 let mut store = SqliteBudgetStore::open(&path).unwrap();
3240 let hold_id = "hold-cap-1-0";
3241 let event_id = "hold-cap-1-0:authorize";
3242
3243 assert!(store
3244 .try_charge_cost_with_ids(
3245 "cap-1",
3246 0,
3247 Some(10),
3248 100,
3249 Some(200),
3250 Some(1000),
3251 Some(hold_id),
3252 Some(event_id),
3253 )
3254 .unwrap());
3255 assert!(store
3256 .try_charge_cost_with_ids(
3257 "cap-1",
3258 0,
3259 Some(10),
3260 100,
3261 Some(200),
3262 Some(1000),
3263 Some(hold_id),
3264 Some(event_id),
3265 )
3266 .unwrap());
3267
3268 let usage = store.get_usage("cap-1", 0).unwrap().unwrap();
3269 assert_eq!(usage.invocation_count, 1);
3270 assert_usage_totals(&usage, 100, 0);
3271
3272 let events = store
3273 .list_mutation_events(10, Some("cap-1"), Some(0))
3274 .unwrap();
3275 assert_eq!(events.len(), 1);
3276 assert_eq!(events[0].event_id, event_id);
3277 assert_eq!(events[0].hold_id.as_deref(), Some(hold_id));
3278 assert_eq!(events[0].kind, BudgetMutationKind::AuthorizeExposure);
3279 assert_eq!(events[0].allowed, Some(true));
3280
3281 let _ = fs::remove_file(path);
3282 }
3283
3284 #[test]
3285 fn budget_store_settle_with_ids_is_idempotent_and_append_only_sqlite() {
3286 let path = unique_db_path("chio-settle-charge-idempotent");
3287 let mut store = SqliteBudgetStore::open(&path).unwrap();
3288 let hold_id = "hold-cap-1-0";
3289 let authorize_event_id = "hold-cap-1-0:authorize";
3290 let reconcile_event_id = "hold-cap-1-0:reconcile";
3291
3292 assert!(store
3293 .try_charge_cost_with_ids(
3294 "cap-1",
3295 0,
3296 Some(10),
3297 100,
3298 Some(200),
3299 Some(1000),
3300 Some(hold_id),
3301 Some(authorize_event_id),
3302 )
3303 .unwrap());
3304 store
3305 .settle_charge_cost_with_ids(
3306 "cap-1",
3307 0,
3308 100,
3309 75,
3310 Some(hold_id),
3311 Some(reconcile_event_id),
3312 )
3313 .unwrap();
3314 store
3315 .settle_charge_cost_with_ids(
3316 "cap-1",
3317 0,
3318 100,
3319 75,
3320 Some(hold_id),
3321 Some(reconcile_event_id),
3322 )
3323 .unwrap();
3324
3325 let usage = store.get_usage("cap-1", 0).unwrap().unwrap();
3326 assert_eq!(usage.invocation_count, 1);
3327 assert_usage_totals(&usage, 0, 75);
3328
3329 let events = store
3330 .list_mutation_events(10, Some("cap-1"), Some(0))
3331 .unwrap();
3332 assert_eq!(events.len(), 2);
3333 assert_eq!(events[0].event_id, authorize_event_id);
3334 assert_eq!(events[1].event_id, reconcile_event_id);
3335 assert_eq!(events[1].hold_id.as_deref(), Some(hold_id));
3336 assert_eq!(events[1].kind, BudgetMutationKind::ReconcileSpend);
3337 assert_eq!(events[1].exposure_units, 100);
3338 assert_eq!(events[1].realized_spend_units, 75);
3339 assert_eq!(events[1].total_cost_exposed_after, 0);
3340 assert_eq!(events[1].total_cost_realized_spend_after, 75);
3341
3342 let _ = fs::remove_file(path);
3343 }
3344
3345 #[test]
3346 fn budget_store_reduce_charge_cost_allows_zero_invocation_release_sqlite() {
3347 let path = unique_db_path("chio-reduce-charge-zero-invocations");
3348 let mut store = SqliteBudgetStore::open(&path).unwrap();
3349 store
3350 .upsert_usage(&usage_record("cap-zero", 0, 0, 10, 10, 40, 0))
3351 .unwrap();
3352
3353 store.reduce_charge_cost("cap-zero", 0, 25).unwrap();
3354
3355 let usage = store.get_usage("cap-zero", 0).unwrap().unwrap();
3356 assert_eq!(usage.invocation_count, 0);
3357 assert_usage_totals(&usage, 15, 0);
3358
3359 let events = store
3360 .list_mutation_events(10, Some("cap-zero"), Some(0))
3361 .unwrap();
3362 assert_eq!(events.len(), 1);
3363 assert_eq!(events[0].kind, BudgetMutationKind::ReleaseExposure);
3364 assert_eq!(events[0].invocation_count_after, 0);
3365 assert_eq!(events[0].total_cost_exposed_after, 15);
3366
3367 let _ = fs::remove_file(path);
3368 }
3369
3370 #[test]
3371 fn budget_store_list_mutation_events_preserves_append_order_sqlite() {
3372 let path = unique_db_path("chio-budget-event-order");
3373 let mut store = SqliteBudgetStore::open(&path).unwrap();
3374
3375 assert!(store
3376 .try_charge_cost_with_ids(
3377 "cap-order",
3378 0,
3379 Some(10),
3380 100,
3381 Some(200),
3382 Some(1000),
3383 Some("hold-cap-order-0"),
3384 Some("z-authorize"),
3385 )
3386 .unwrap());
3387 store
3388 .reduce_charge_cost_with_ids(
3389 "cap-order",
3390 0,
3391 25,
3392 Some("hold-cap-order-0"),
3393 Some("a-release"),
3394 )
3395 .unwrap();
3396
3397 let events = store
3398 .list_mutation_events(10, Some("cap-order"), Some(0))
3399 .unwrap();
3400 let event_ids = events
3401 .iter()
3402 .map(|record| record.event_id.as_str())
3403 .collect::<Vec<_>>();
3404 assert_eq!(event_ids, vec!["z-authorize", "a-release"]);
3405
3406 let _ = fs::remove_file(path);
3407 }
3408
3409 #[test]
3410 fn budget_store_hold_authority_requires_exact_lease_inmemory() {
3411 let mut store = InMemoryBudgetStore::new();
3412 let hold_id = "hold-cap-lease-0";
3413 let authorize_event_id = "hold-cap-lease-0:authorize";
3414 let release_event_id = "hold-cap-lease-0:release";
3415 let reconcile_event_id = "hold-cap-lease-0:reconcile";
3416 let initial = authority("budget-primary", "lease-7", 7);
3417 let advanced = authority("budget-primary", "lease-7", 8);
3418 let stale = authority("budget-primary", "lease-7", 6);
3419
3420 assert!(store
3421 .try_charge_cost_with_ids_and_authority(
3422 "cap-lease",
3423 0,
3424 Some(10),
3425 100,
3426 Some(200),
3427 Some(1000),
3428 Some(hold_id),
3429 Some(authorize_event_id),
3430 Some(&initial),
3431 )
3432 .unwrap());
3433
3434 let missing = store
3435 .reduce_charge_cost_with_ids_and_authority(
3436 "cap-lease",
3437 0,
3438 25,
3439 Some(hold_id),
3440 Some("hold-cap-lease-0:release-missing"),
3441 None,
3442 )
3443 .expect_err("missing lease metadata should fail closed");
3444 assert!(missing
3445 .to_string()
3446 .contains("requires authority lease metadata"));
3447
3448 let stale_error = store
3449 .reduce_charge_cost_with_ids_and_authority(
3450 "cap-lease",
3451 0,
3452 25,
3453 Some(hold_id),
3454 Some("hold-cap-lease-0:release-stale"),
3455 Some(&stale),
3456 )
3457 .expect_err("stale lease epoch should fail closed");
3458 assert!(stale_error.to_string().contains("lease epoch regressed"));
3459
3460 let advanced_error = store
3461 .reduce_charge_cost_with_ids_and_authority(
3462 "cap-lease",
3463 0,
3464 25,
3465 Some(hold_id),
3466 Some(release_event_id),
3467 Some(&advanced),
3468 )
3469 .expect_err("advanced lease epoch should fail closed");
3470 assert!(advanced_error
3471 .to_string()
3472 .contains("advanced beyond the open lease"));
3473
3474 store
3475 .reduce_charge_cost_with_ids_and_authority(
3476 "cap-lease",
3477 0,
3478 25,
3479 Some(hold_id),
3480 Some(release_event_id),
3481 Some(&initial),
3482 )
3483 .unwrap();
3484 store
3485 .settle_charge_cost_with_ids_and_authority(
3486 "cap-lease",
3487 0,
3488 75,
3489 75,
3490 Some(hold_id),
3491 Some(reconcile_event_id),
3492 Some(&initial),
3493 )
3494 .unwrap();
3495
3496 let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3497 assert_eq!(usage.invocation_count, 1);
3498 assert_usage_totals(&usage, 0, 75);
3499
3500 let events = store
3501 .list_mutation_events(10, Some("cap-lease"), Some(0))
3502 .unwrap();
3503 assert_eq!(events.len(), 3);
3504 assert_eq!(events[0].authority.as_ref(), Some(&initial));
3505 assert_eq!(events[1].authority.as_ref(), Some(&initial));
3506 assert_eq!(events[2].authority.as_ref(), Some(&initial));
3507 }
3508
3509 #[test]
3510 fn budget_store_event_id_reuse_rejects_authority_rollover_sqlite() {
3511 let path = unique_db_path("chio-hold-authority-event-reuse");
3512 let mut store = SqliteBudgetStore::open(&path).unwrap();
3513 let hold_id = "hold-cap-lease-0";
3514 let event_id = "hold-cap-lease-0:authorize";
3515 let initial = authority("budget-primary", "lease-7", 7);
3516 let changed = authority("budget-primary", "lease-8", 8);
3517
3518 assert!(store
3519 .try_charge_cost_with_ids_and_authority(
3520 "cap-lease",
3521 0,
3522 Some(10),
3523 100,
3524 Some(200),
3525 Some(1000),
3526 Some(hold_id),
3527 Some(event_id),
3528 Some(&initial),
3529 )
3530 .unwrap());
3531
3532 let error = store
3533 .try_charge_cost_with_ids_and_authority(
3534 "cap-lease",
3535 0,
3536 Some(10),
3537 100,
3538 Some(200),
3539 Some(1000),
3540 Some(hold_id),
3541 Some(event_id),
3542 Some(&changed),
3543 )
3544 .expect_err("reused event id with different authority should fail closed");
3545 assert!(error
3546 .to_string()
3547 .contains("was reused for a different mutation"));
3548
3549 let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3550 assert_eq!(usage.invocation_count, 1);
3551 assert_usage_totals(&usage, 100, 0);
3552
3553 let events = store
3554 .list_mutation_events(10, Some("cap-lease"), Some(0))
3555 .unwrap();
3556 assert_eq!(events.len(), 1);
3557 assert_eq!(events[0].authority.as_ref(), Some(&initial));
3558
3559 let _ = fs::remove_file(path);
3560 }
3561
3562 #[test]
3563 fn budget_store_deleted_provisional_event_allows_retry_after_compensation_sqlite() {
3564 let path = unique_db_path("chio-hold-authority-compensation");
3565 let mut store = SqliteBudgetStore::open(&path).unwrap();
3566 let hold_id = "hold-cap-lease-0";
3567 let event_id = "hold-cap-lease-0:authorize";
3568 let initial = authority("budget-primary", "lease-7", 7);
3569 let changed = authority("budget-primary", "lease-8", 8);
3570
3571 assert!(store
3572 .try_charge_cost_with_ids_and_authority(
3573 "cap-lease",
3574 0,
3575 Some(10),
3576 100,
3577 Some(200),
3578 Some(1000),
3579 Some(hold_id),
3580 Some(event_id),
3581 Some(&initial),
3582 )
3583 .unwrap());
3584 store
3585 .reverse_charge_cost_with_ids_and_authority(
3586 "cap-lease",
3587 0,
3588 100,
3589 Some(hold_id),
3590 Some("hold-cap-lease-0:authorize:rollback"),
3591 Some(&initial),
3592 )
3593 .unwrap();
3594 store.delete_hold(hold_id).unwrap();
3595 store.delete_mutation_event(event_id).unwrap();
3596
3597 assert!(store
3598 .try_charge_cost_with_ids_and_authority(
3599 "cap-lease",
3600 0,
3601 Some(10),
3602 100,
3603 Some(200),
3604 Some(1000),
3605 Some(hold_id),
3606 Some(event_id),
3607 Some(&changed),
3608 )
3609 .unwrap());
3610
3611 let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3612 assert_eq!(usage.invocation_count, 1);
3613 assert_usage_totals(&usage, 100, 0);
3614
3615 let events = store
3616 .list_mutation_events(10, Some("cap-lease"), Some(0))
3617 .unwrap();
3618 let event_ids = events
3619 .iter()
3620 .map(|record| record.event_id.as_str())
3621 .collect::<Vec<_>>();
3622 assert_eq!(
3623 event_ids,
3624 vec![
3625 "hold-cap-lease-0:authorize:rollback",
3626 "hold-cap-lease-0:authorize"
3627 ]
3628 );
3629
3630 let _ = fs::remove_file(path);
3631 }
3632
3633 #[test]
3634 fn budget_store_rollback_artifact_allows_retry_with_new_authority_sqlite() {
3635 let path = unique_db_path("chio-hold-authority-rollback-retry");
3636 let mut store = SqliteBudgetStore::open(&path).unwrap();
3637 let hold_id = "hold-cap-lease-0";
3638 let event_id = "hold-cap-lease-0:authorize";
3639 let rollback_event_id = "hold-cap-lease-0:authorize:rollback:2";
3640 let initial = authority("budget-primary", "lease-7", 7);
3641 let changed = authority("budget-primary", "lease-8", 8);
3642
3643 assert!(store
3644 .try_charge_cost_with_ids_and_authority(
3645 "cap-lease",
3646 0,
3647 Some(10),
3648 100,
3649 Some(200),
3650 Some(1000),
3651 Some(hold_id),
3652 Some(event_id),
3653 Some(&initial),
3654 )
3655 .unwrap());
3656 store
3657 .reverse_charge_cost_with_ids_and_authority(
3658 "cap-lease",
3659 0,
3660 100,
3661 Some(hold_id),
3662 Some(rollback_event_id),
3663 Some(&initial),
3664 )
3665 .unwrap();
3666
3667 assert!(store
3668 .try_charge_cost_with_ids_and_authority(
3669 "cap-lease",
3670 0,
3671 Some(10),
3672 100,
3673 Some(200),
3674 Some(1000),
3675 Some(hold_id),
3676 Some(event_id),
3677 Some(&changed),
3678 )
3679 .unwrap());
3680
3681 let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3682 assert_eq!(usage.invocation_count, 1);
3683 assert_usage_totals(&usage, 100, 0);
3684
3685 let events = store
3686 .list_mutation_events(10, Some("cap-lease"), Some(0))
3687 .unwrap();
3688 let authorize = events
3689 .iter()
3690 .find(|record| record.event_id == event_id)
3691 .expect("replacement authorize event");
3692 assert_eq!(authorize.authority.as_ref(), Some(&changed));
3693
3694 let _ = fs::remove_file(path);
3695 }
3696
3697 #[test]
3698 fn budget_store_retry_after_rollback_replaces_orphaned_open_hold_sqlite() {
3699 let path = unique_db_path("chio-hold-rollback-orphan-retry");
3700 let mut store = SqliteBudgetStore::open(&path).unwrap();
3701 let hold_id = "hold-cap-orphan-0";
3702 let event_id = "hold-cap-orphan-0:authorize";
3703 let rollback_event_id = "hold-cap-orphan-0:authorize:rollback:5";
3704 let initial = authority("budget-primary", "lease-7", 7);
3705 let changed = authority("budget-primary", "lease-8", 8);
3706
3707 for _ in 0..3 {
3708 assert!(store
3709 .try_increment_with_event_id("cap-orphan", 0, Some(10), None)
3710 .unwrap());
3711 }
3712 assert!(store
3713 .try_charge_cost_with_ids_and_authority(
3714 "cap-orphan",
3715 0,
3716 Some(10),
3717 75,
3718 Some(100),
3719 Some(400),
3720 Some(hold_id),
3721 Some(event_id),
3722 Some(&initial),
3723 )
3724 .unwrap());
3725 store
3726 .reverse_charge_cost_with_ids_and_authority(
3727 "cap-orphan",
3728 0,
3729 75,
3730 Some(hold_id),
3731 Some(rollback_event_id),
3732 Some(&initial),
3733 )
3734 .unwrap();
3735
3736 let transaction = store
3737 .connection
3738 .transaction_with_behavior(TransactionBehavior::Immediate)
3739 .unwrap();
3740 transaction
3741 .execute(
3742 "DELETE FROM budget_mutation_events WHERE event_id = ?1",
3743 params![event_id],
3744 )
3745 .unwrap();
3746 SqliteBudgetStore::upsert_hold(
3747 &transaction,
3748 hold_id,
3749 "cap-orphan",
3750 0,
3751 75,
3752 75,
3753 HoldDisposition::Open,
3754 Some(&initial),
3755 )
3756 .unwrap();
3757 transaction.commit().unwrap();
3758
3759 assert!(store
3760 .try_charge_cost_with_ids_and_authority(
3761 "cap-orphan",
3762 0,
3763 Some(10),
3764 75,
3765 Some(100),
3766 Some(400),
3767 Some(hold_id),
3768 Some(event_id),
3769 Some(&changed),
3770 )
3771 .unwrap());
3772
3773 let usage = store.get_usage("cap-orphan", 0).unwrap().unwrap();
3774 assert_eq!(usage.invocation_count, 4);
3775 assert_usage_totals(&usage, 75, 0);
3776
3777 let events = store
3778 .list_mutation_events(20, Some("cap-orphan"), Some(0))
3779 .unwrap();
3780 let rollback = events
3781 .iter()
3782 .find(|record| record.event_id == rollback_event_id)
3783 .expect("rollback event");
3784 let retry = events
3785 .iter()
3786 .find(|record| record.event_id == event_id)
3787 .expect("retry authorize event");
3788 assert!(retry.event_seq > rollback.event_seq);
3789 assert_eq!(retry.authority.as_ref(), Some(&changed));
3790
3791 let transaction = store
3792 .connection
3793 .transaction_with_behavior(TransactionBehavior::Immediate)
3794 .unwrap();
3795 let hold = SqliteBudgetStore::load_hold(&transaction, hold_id)
3796 .unwrap()
3797 .expect("retry open hold");
3798 assert_eq!(hold.remaining_exposure_units, 75);
3799 assert_eq!(hold.disposition, HoldDisposition::Open);
3800 drop(transaction);
3801
3802 let transaction = store
3803 .connection
3804 .transaction_with_behavior(TransactionBehavior::Immediate)
3805 .unwrap();
3806 transaction
3807 .execute(
3808 "DELETE FROM budget_mutation_events WHERE event_id = ?1",
3809 params![event_id],
3810 )
3811 .unwrap();
3812 transaction.commit().unwrap();
3813
3814 assert!(store
3815 .try_charge_cost_with_ids_and_authority(
3816 "cap-orphan",
3817 0,
3818 Some(10),
3819 75,
3820 Some(100),
3821 Some(400),
3822 Some(hold_id),
3823 Some(event_id),
3824 Some(&changed),
3825 )
3826 .unwrap());
3827 let events = store
3828 .list_mutation_events(20, Some("cap-orphan"), Some(0))
3829 .unwrap();
3830 let replayed_retry = events
3831 .iter()
3832 .find(|record| record.event_id == event_id)
3833 .expect("replayed retry authorize event");
3834 assert_eq!(replayed_retry.allowed, Some(true));
3835 assert_eq!(replayed_retry.usage_seq, Some(usage.seq));
3836
3837 let _ = fs::remove_file(path);
3838 }
3839
3840 #[test]
3841 fn import_mutation_record_keeps_duplicate_release_events_idempotent_sqlite() {
3842 let path = unique_db_path("chio-budget-import-release-idempotent");
3843 let mut store = SqliteBudgetStore::open(&path).unwrap();
3844 let hold_id = "hold-cap-import-0";
3845 let authorize_event_id = "hold-cap-import-0:authorize";
3846 let release_event_id = "hold-cap-import-0:release";
3847
3848 assert!(store
3849 .try_charge_cost_with_ids(
3850 "cap-import",
3851 0,
3852 Some(10),
3853 100,
3854 Some(200),
3855 Some(1000),
3856 Some(hold_id),
3857 Some(authorize_event_id),
3858 )
3859 .unwrap());
3860 store
3861 .reduce_charge_cost_with_ids(
3862 "cap-import",
3863 0,
3864 100,
3865 Some(hold_id),
3866 Some(release_event_id),
3867 )
3868 .unwrap();
3869
3870 let release_record = store
3871 .list_mutation_events(10, Some("cap-import"), Some(0))
3872 .unwrap()
3873 .into_iter()
3874 .find(|record| record.event_id == release_event_id)
3875 .expect("release event record");
3876
3877 store.import_mutation_record(&release_record).unwrap();
3878
3879 let usage = store.get_usage("cap-import", 0).unwrap().unwrap();
3880 assert_eq!(usage.invocation_count, 1);
3881 assert_usage_totals(&usage, 0, 0);
3882
3883 let transaction = store
3884 .connection
3885 .transaction_with_behavior(TransactionBehavior::Immediate)
3886 .unwrap();
3887 let hold = SqliteBudgetStore::load_hold(&transaction, hold_id)
3888 .unwrap()
3889 .expect("released hold state");
3890 assert_eq!(hold.remaining_exposure_units, 0);
3891 assert_eq!(hold.disposition, HoldDisposition::Released);
3892 drop(transaction);
3893
3894 let events = store
3895 .list_mutation_events(10, Some("cap-import"), Some(0))
3896 .unwrap();
3897 assert_eq!(events.len(), 2);
3898 assert_eq!(events[0].event_id, authorize_event_id);
3899 assert_eq!(events[1].event_id, release_event_id);
3900
3901 let _ = fs::remove_file(path);
3902 }
3903
3904 #[test]
3905 fn import_snapshot_records_replay_is_idempotent_when_peer_cursor_is_lost_sqlite() {
3906 let source_path = unique_db_path("chio-budget-import-replay-source");
3907 let target_path = unique_db_path("chio-budget-import-replay-target");
3908 let mut source = SqliteBudgetStore::open(&source_path).unwrap();
3909
3910 assert!(source
3911 .try_charge_cost_with_ids(
3912 "cap-import-replay",
3913 0,
3914 Some(5),
3915 25,
3916 Some(50),
3917 Some(250),
3918 Some("hold-import-replay-0"),
3919 Some("hold-import-replay-0:authorize"),
3920 )
3921 .unwrap());
3922 let usage = source
3923 .get_usage("cap-import-replay", 0)
3924 .unwrap()
3925 .expect("source usage");
3926 let events = source
3927 .list_mutation_events(10, Some("cap-import-replay"), Some(0))
3928 .unwrap();
3929
3930 let mut target = SqliteBudgetStore::open(&target_path).unwrap();
3931 target
3932 .import_snapshot_records(std::slice::from_ref(&usage), &events)
3933 .unwrap();
3934 target
3935 .import_snapshot_records(std::slice::from_ref(&usage), &events)
3936 .unwrap();
3937
3938 let replicated_usage = target
3939 .get_usage("cap-import-replay", 0)
3940 .unwrap()
3941 .expect("replicated usage");
3942 assert_eq!(replicated_usage.invocation_count, 1);
3943 assert_usage_totals(&replicated_usage, 25, 0);
3944 let replicated_events = target
3945 .list_mutation_events(10, Some("cap-import-replay"), Some(0))
3946 .unwrap();
3947 assert_eq!(replicated_events.len(), 1);
3948 assert_eq!(
3949 replicated_events[0].event_id,
3950 "hold-import-replay-0:authorize"
3951 );
3952
3953 let _ = fs::remove_file(source_path);
3954 let _ = fs::remove_file(target_path);
3955 }
3956
3957 #[test]
3958 fn import_snapshot_records_rolls_back_usage_rows_when_mutation_conflicts_sqlite() {
3959 let path = unique_db_path("chio-budget-import-atomic");
3960 let mut store = SqliteBudgetStore::open(&path).unwrap();
3961 let initial_authority = authority("budget-primary", "lease-1", 1);
3962 let conflicting_authority = authority("budget-primary", "lease-2", 2);
3963
3964 assert!(store
3965 .try_charge_cost_with_ids_and_authority(
3966 "cap-import",
3967 0,
3968 Some(10),
3969 25,
3970 Some(100),
3971 Some(500),
3972 Some("hold-cap-import-atomic-0"),
3973 Some("hold-cap-import-atomic-0:authorize"),
3974 Some(&initial_authority),
3975 )
3976 .unwrap());
3977
3978 let mut conflicting_event = store
3979 .list_mutation_events(10, Some("cap-import"), Some(0))
3980 .unwrap()
3981 .into_iter()
3982 .find(|record| record.event_id == "hold-cap-import-atomic-0:authorize")
3983 .expect("existing authorize event");
3984 conflicting_event.authority = Some(conflicting_authority);
3985
3986 let imported_usage = usage_record("cap-import-rollback", 0, 2, unix_now(), 88, 40, 5);
3987
3988 let error = store
3989 .import_snapshot_records(&[imported_usage], &[conflicting_event])
3990 .expect_err("conflicting event import should fail atomically");
3991 assert!(error
3992 .to_string()
3993 .contains("reused for a different mutation"));
3994 assert!(store.get_usage("cap-import-rollback", 0).unwrap().is_none());
3995
3996 let existing = store.get_usage("cap-import", 0).unwrap().unwrap();
3997 assert_eq!(existing.invocation_count, 1);
3998 assert_usage_totals(&existing, 25, 0);
3999
4000 let _ = fs::remove_file(path);
4001 }
4002
4003 #[test]
4004 fn budget_store_open_hold_recovers_missing_authorize_event_sqlite() {
4005 let path = unique_db_path("chio-hold-authority-recover-missing-event");
4006 let mut store = SqliteBudgetStore::open(&path).unwrap();
4007 let hold_id = "hold-cap-recover-0";
4008 let event_id = "hold-cap-recover-0:authorize";
4009 let authority = authority("budget-primary", "lease-7", 7);
4010
4011 assert!(store
4012 .try_charge_cost_with_ids_and_authority(
4013 "cap-recover",
4014 0,
4015 Some(10),
4016 100,
4017 Some(200),
4018 Some(1000),
4019 Some(hold_id),
4020 Some(event_id),
4021 Some(&authority),
4022 )
4023 .unwrap());
4024 store.delete_mutation_event(event_id).unwrap();
4025
4026 assert!(store
4027 .try_charge_cost_with_ids_and_authority(
4028 "cap-recover",
4029 0,
4030 Some(10),
4031 100,
4032 Some(200),
4033 Some(1000),
4034 Some(hold_id),
4035 Some(event_id),
4036 Some(&authority),
4037 )
4038 .unwrap());
4039
4040 let events = store
4041 .list_mutation_events(10, Some("cap-recover"), Some(0))
4042 .unwrap();
4043 assert_eq!(events.len(), 1);
4044 assert_eq!(events[0].event_id, event_id);
4045
4046 let _ = fs::remove_file(path);
4047 }
4048
4049 #[test]
4050 fn upsert_usage_preserves_newer_split_cost_state() {
4051 let path = unique_db_path("chio-budget-upsert-cost");
4052 let mut store = SqliteBudgetStore::open(&path).unwrap();
4053
4054 store
4056 .upsert_usage(&usage_record("cap-1", 0, 5, 10, 10, 500, 0))
4057 .unwrap();
4058
4059 store
4061 .upsert_usage(&usage_record("cap-1", 0, 3, 12, 5, 300, 0))
4062 .unwrap();
4063
4064 let records = store.list_usages(10, Some("cap-1")).unwrap();
4065 assert_usage_totals(&records[0], 500, 0);
4066 assert_eq!(records[0].seq, 10);
4067
4068 let _ = fs::remove_file(path);
4069 }
4070
4071 #[test]
4072 fn upsert_usage_does_not_resurrect_split_cost_state_from_stale_seq() {
4073 let path = unique_db_path("chio-budget-upsert-split");
4074 let mut store = SqliteBudgetStore::open(&path).unwrap();
4075
4076 store
4077 .upsert_usage(&usage_record("cap-1", 0, 1, 20, 20, 0, 75))
4078 .unwrap();
4079 store
4080 .upsert_usage(&usage_record("cap-1", 0, 1, 10, 10, 100, 0))
4081 .unwrap();
4082
4083 let records = store.list_usages(10, Some("cap-1")).unwrap();
4084 assert_usage_totals(&records[0], 0, 75);
4085 assert_eq!(records[0].seq, 20);
4086
4087 let _ = fs::remove_file(path);
4088 }
4089
4090 #[test]
4099 fn concurrent_charge_overrun_bound() {
4100 let path_a = unique_db_path("chio-overrun-node-a");
4101 let path_b = unique_db_path("chio-overrun-node-b");
4102
4103 let max_per_invocation: u64 = 100;
4104 let total_budget: u64 = 150; let node_count: u64 = 2;
4106
4107 let mut node_a = SqliteBudgetStore::open(&path_a).unwrap();
4109 let mut node_b = SqliteBudgetStore::open(&path_b).unwrap();
4110
4111 let approved_a = node_a
4113 .try_charge_cost(
4114 "cap-split",
4115 0,
4116 None,
4117 max_per_invocation,
4118 Some(max_per_invocation),
4119 Some(total_budget),
4120 )
4121 .unwrap();
4122 let approved_b = node_b
4123 .try_charge_cost(
4124 "cap-split",
4125 0,
4126 None,
4127 max_per_invocation,
4128 Some(max_per_invocation),
4129 Some(total_budget),
4130 )
4131 .unwrap();
4132
4133 assert!(approved_a, "node A should approve");
4135 assert!(approved_b, "node B should approve");
4136
4137 let combined_spend = max_per_invocation * node_count;
4139 let max_overrun = max_per_invocation * node_count;
4141 assert!(
4142 combined_spend <= max_overrun,
4143 "HA overrun bound violated: combined_spend={combined_spend} > max_overrun={max_overrun}"
4144 );
4145
4146 let record_a = node_a.list_usages(1, Some("cap-split")).unwrap();
4148 let record_b = node_b.list_usages(1, Some("cap-split")).unwrap();
4149 let total_after_merge = record_a[0].total_cost_exposed + record_b[0].total_cost_exposed;
4150 assert!(
4151 total_after_merge <= max_overrun,
4152 "post-merge total {total_after_merge} exceeds bound {max_overrun}"
4153 );
4154
4155 let _ = fs::remove_file(path_a);
4156 let _ = fs::remove_file(path_b);
4157 }
4158
4159 #[test]
4160 fn budget_store_zero_max_total_denies_any_charge_inmemory() {
4161 let mut store = InMemoryBudgetStore::new();
4163 let ok = store
4164 .try_charge_cost("cap-zero-budget", 0, None, 1, None, Some(0))
4165 .unwrap();
4166 assert!(
4167 !ok,
4168 "any charge against a zero-unit total budget must be denied"
4169 );
4170 let records = store.list_usages(10, Some("cap-zero-budget")).unwrap();
4171 assert!(
4172 records.is_empty() || records[0].invocation_count == 0,
4173 "no invocations should be recorded against a zero-unit budget"
4174 );
4175 }
4176
4177 #[test]
4178 fn budget_store_zero_max_total_denies_any_charge_sqlite() {
4179 let path = unique_db_path("chio-zero-budget-sqlite");
4180 let mut store = SqliteBudgetStore::open(&path).unwrap();
4181 let ok = store
4182 .try_charge_cost("cap-zero-budget", 0, None, 1, None, Some(0))
4183 .unwrap();
4184 assert!(
4185 !ok,
4186 "any charge against a zero-unit total budget must be denied"
4187 );
4188 let records = store.list_usages(10, Some("cap-zero-budget")).unwrap();
4189 assert!(
4190 records.is_empty() || records[0].invocation_count == 0,
4191 "no invocations should be recorded against a zero-unit budget"
4192 );
4193 let _ = fs::remove_file(path);
4194 }
4195
4196 #[test]
4197 fn budget_store_zero_cost_invocation_succeeds_and_records_zero_inmemory() {
4198 let mut store = InMemoryBudgetStore::new();
4201 let ok = store
4202 .try_charge_cost("cap-zero-cost", 0, None, 0, None, Some(1000))
4203 .unwrap();
4204 assert!(
4205 ok,
4206 "zero-cost invocation should succeed when budget is available"
4207 );
4208 let records = store.list_usages(10, Some("cap-zero-cost")).unwrap();
4209 assert_eq!(records.len(), 1);
4210 assert_eq!(records[0].invocation_count, 1);
4211 assert_usage_totals(&records[0], 0, 0);
4212 }
4213
4214 #[test]
4215 fn budget_store_zero_cost_invocation_succeeds_and_records_zero_sqlite() {
4216 let path = unique_db_path("chio-zero-cost-sqlite");
4217 let mut store = SqliteBudgetStore::open(&path).unwrap();
4218 let ok = store
4219 .try_charge_cost("cap-zero-cost", 0, None, 0, None, Some(1000))
4220 .unwrap();
4221 assert!(
4222 ok,
4223 "zero-cost invocation should succeed when budget is available"
4224 );
4225 let records = store.list_usages(10, Some("cap-zero-cost")).unwrap();
4226 assert_eq!(records.len(), 1);
4227 assert_eq!(records[0].invocation_count, 1);
4228 assert_usage_totals(&records[0], 0, 0);
4229 let _ = fs::remove_file(path);
4230 }
4231}