1use std::collections::BTreeSet;
4
5use sqlx::{PgConnection, PgPool};
6use thiserror::Error;
7
8pub const SUPPORTED_POSTGRES_MAJOR_VERSION: u16 = 18;
11
12#[cfg(any(test, feature = "schema-contract-test-support"))]
16pub const V1_INSTALL_SQL: &str = include_str!("../schema/v1/install.sql");
18#[cfg(any(test, feature = "schema-contract-test-support"))]
19pub const V2_INSTALL_SQL: &str = include_str!("../schema/v2/install.sql");
21#[cfg(any(test, feature = "schema-contract-test-support"))]
22pub const V3_INSTALL_SQL: &str = include_str!("../schema/v3/install.sql");
24#[cfg(any(test, feature = "schema-contract-test-support"))]
25pub const V4_INSTALL_SQL: &str = include_str!("../schema/v4/install.sql");
27#[cfg(any(test, feature = "schema-contract-test-support"))]
28pub const V1_TO_V2_PREFLIGHT_SQL: &str = include_str!("../schema/v2/preflight_from_v1.sql");
30#[cfg(any(test, feature = "schema-contract-test-support"))]
31pub const V1_TO_V2_RETRY_RECLASSIFICATION_AUDIT_SQL: &str =
33 include_str!("../schema/v2/audit_retry_reclassification_from_v1.sql");
34#[cfg(any(test, feature = "schema-contract-test-support"))]
35pub const V1_TO_V2_UPGRADE_SQL: &str = include_str!("../schema/v2/upgrade_from_v1.sql");
37#[cfg(any(test, feature = "schema-contract-test-support"))]
38pub const V2_TO_V3_UPGRADE_SQL: &str = include_str!("../schema/v3/upgrade_from_v2.sql");
40#[cfg(any(test, feature = "schema-contract-test-support"))]
41pub const V3_TO_V4_PREPARE_SQL: &str = include_str!("../schema/v4/prepare_from_v3.sql");
43#[cfg(any(test, feature = "schema-contract-test-support"))]
44pub const V3_TO_V4_VALIDATE_SQL: &str = include_str!("../schema/v4/validate_from_v3.sql");
46#[cfg(any(test, feature = "schema-contract-test-support"))]
47pub const V3_TO_V4_INDEX_SQL: &str = include_str!("../schema/v4/index_from_v3.sql");
49#[cfg(any(test, feature = "schema-contract-test-support"))]
50pub const V3_TO_V4_UPGRADE_SQL: &str = include_str!("../schema/v4/upgrade_from_v3.sql");
52
53#[cfg(any(test, feature = "schema-contract-test-support"))]
56const V1_CATALOG_FINGERPRINT: u64 = 0xc949_7313_2b48_83d9;
57#[cfg(any(test, feature = "schema-contract-test-support"))]
58const V2_CATALOG_FINGERPRINT: u64 = 0x373b_9c1c_8b27_5be0;
59#[cfg(any(test, feature = "schema-contract-test-support"))]
60const V3_CATALOG_FINGERPRINT: u64 = 0x475d_91d1_6525_a966;
61const V4_CATALOG_FINGERPRINT: u64 = 0x0931_8e66_2d53_c5b6;
62const CONCURRENT_REINDEX_SHADOW_INDEX_PATTERN: &str = r"_cc(new|old)[0-9]*$";
63const REINDEX_TRANSITION_DETAIL: &str = "concurrent reindex state changed during schema validation";
64const REINDEX_TRANSITION_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);
65
66const REQUIRED_TABLES: &[&str] = &[
67 "billing_external_reversal_attestations",
68 "billing_gateway_accounts",
69 "billing_gateway_lifecycle_pending_updates",
70 "billing_gateway_lifecycle_quarantine_resolutions",
71 "billing_gateway_lifecycle_quarantines",
72 "billing_gateway_provider_rate_limits",
73 "billing_payment_attempts",
74 "billing_payment_methods",
75 "billing_processor_charges",
76 "billing_reconciliation_cursors",
77 "billing_subscription_discount_claims",
78 "billing_subscription_discount_codes",
79 "billing_subscription_discounts",
80 "billing_subscription_grants",
81 "billing_subscriptions",
82];
83
84const REQUIRED_VIEWS: &[&str] = &[
85 "billing_active_discount_facts",
86 "billing_current_subscriptions",
87 "billing_payment_facts",
88];
89
90const REQUIRED_FUNCTIONS: &[&str] = &[
91 "billing_canonical_gateway_transaction_id",
92 "billing_guard_processor_charge_evidence_update",
93 "billing_host_charge_ledger_admission",
94 "billing_set_attempt_review_required_at",
95 "billing_set_processor_charge_attempt_dimensions",
96];
97
98const REQUIRED_TRIGGERS: &[&str] = &[
99 "billing_payment_attempt_review_required_at",
100 "billing_processor_charge_attempt_dimensions",
101 "billing_processor_charge_evidence_immutable",
102];
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107enum IndexKeyOrdering {
108 AscNullsLast,
109 DescNullsFirst,
110}
111
112impl IndexKeyOrdering {
113 const fn catalog_label(self) -> &'static str {
114 match self {
115 Self::AscNullsLast => "ASC NULLS LAST",
116 Self::DescNullsFirst => "DESC NULLS FIRST",
117 }
118 }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122struct IndexKeyContract {
123 expression: &'static str,
124 ordering: IndexKeyOrdering,
125 opclass: &'static str,
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129struct IndexContract {
130 purpose: &'static str,
131 name: &'static str,
132 table: &'static str,
133 unique: bool,
134 keys: &'static [IndexKeyContract],
135 included_expressions: &'static [&'static str],
136 predicate: Option<&'static str>,
137}
138
139const GATEWAY_ORDER_INDEX_CONTRACT: IndexContract = IndexContract {
140 purpose: "gateway-order uniqueness",
141 name: "billing_payment_attempts_gateway_order_idx",
142 table: "billing_payment_attempts",
143 unique: true,
144 keys: &[
145 IndexKeyContract {
146 expression: "gateway_account_id",
147 ordering: IndexKeyOrdering::AscNullsLast,
148 opclass: "pg_catalog.uuid_ops",
149 },
150 IndexKeyContract {
151 expression: "gateway_order_id",
152 ordering: IndexKeyOrdering::AscNullsLast,
153 opclass: "pg_catalog.text_ops",
154 },
155 ],
156 included_expressions: &[],
157 predicate: None,
158};
159
160const RENEWAL_DISPATCH_INDEX_CONTRACT: IndexContract = IndexContract {
161 purpose: "renewal-dispatch keyset",
162 name: "billing_subscriptions_due_idx",
163 table: "billing_subscriptions",
164 unique: false,
165 keys: &[
166 IndexKeyContract {
167 expression: "next_payment_attempt_at",
168 ordering: IndexKeyOrdering::AscNullsLast,
169 opclass: "pg_catalog.timestamptz_ops",
170 },
171 IndexKeyContract {
172 expression: "id",
173 ordering: IndexKeyOrdering::AscNullsLast,
174 opclass: "pg_catalog.uuid_ops",
175 },
176 ],
177 included_expressions: &["billing_scope_id", "gateway_account_id", "next_renewal_at"],
178 predicate: Some(
179 "(status = ANY (ARRAY['active'::text, 'past_due'::text])) AND next_payment_attempt_at IS NOT NULL",
180 ),
181};
182
183const V4_RENEWAL_DISPATCH_INDEX_CONTRACT: IndexContract = IndexContract {
184 included_expressions: &[
185 "billing_scope_id",
186 "gateway_account_id",
187 "next_renewal_at",
188 "required_gateway_account_mode",
189 ],
190 ..RENEWAL_DISPATCH_INDEX_CONTRACT
191};
192
193const MODE_RENEWAL_DISPATCH_INDEX_CONTRACT: IndexContract = IndexContract {
194 purpose: "mode-specific renewal-dispatch keyset",
195 name: "billing_subscriptions_due_mode_idx",
196 table: "billing_subscriptions",
197 unique: false,
198 keys: &[
199 IndexKeyContract {
200 expression: "required_gateway_account_mode",
201 ordering: IndexKeyOrdering::AscNullsLast,
202 opclass: "pg_catalog.text_ops",
203 },
204 IndexKeyContract {
205 expression: "next_payment_attempt_at",
206 ordering: IndexKeyOrdering::AscNullsLast,
207 opclass: "pg_catalog.timestamptz_ops",
208 },
209 IndexKeyContract {
210 expression: "id",
211 ordering: IndexKeyOrdering::AscNullsLast,
212 opclass: "pg_catalog.uuid_ops",
213 },
214 ],
215 included_expressions: &["billing_scope_id", "gateway_account_id", "next_renewal_at"],
216 predicate: Some(
217 "(status = ANY (ARRAY['active'::text, 'past_due'::text])) AND next_payment_attempt_at IS NOT NULL",
218 ),
219};
220
221const SUBSCRIPTION_HISTORY_INDEX_CONTRACT: IndexContract = IndexContract {
222 purpose: "subscription-history keyset",
223 name: "billing_payment_attempts_subscription_history_idx",
224 table: "billing_payment_attempts",
225 unique: false,
226 keys: &[
227 IndexKeyContract {
228 expression: "billing_scope_id",
229 ordering: IndexKeyOrdering::AscNullsLast,
230 opclass: "pg_catalog.uuid_ops",
231 },
232 IndexKeyContract {
233 expression: "subscriber_id",
234 ordering: IndexKeyOrdering::AscNullsLast,
235 opclass: "pg_catalog.uuid_ops",
236 },
237 IndexKeyContract {
238 expression: "plan_key",
239 ordering: IndexKeyOrdering::AscNullsLast,
240 opclass: "pg_catalog.text_ops",
241 },
242 IndexKeyContract {
243 expression: "created_at",
244 ordering: IndexKeyOrdering::DescNullsFirst,
245 opclass: "pg_catalog.timestamptz_ops",
246 },
247 IndexKeyContract {
248 expression: "id",
249 ordering: IndexKeyOrdering::DescNullsFirst,
250 opclass: "pg_catalog.uuid_ops",
251 },
252 ],
253 included_expressions: &[],
254 predicate: Some("attempt_kind <> 'host_charge'::text"),
255};
256
257const PAYMENT_FACT_COLUMNS: &[&str] = &[
258 "attempt_id",
259 "billing_scope_id",
260 "subscriber_id",
261 "plan_key",
262 "host_charge_target_id",
263 "attempt_kind",
264 "status",
265 "amount_cents",
266 "currency",
267 "created_at",
268 "resolved_at",
269 "gateway_lifecycle_status",
270 "refunded_amount_cents",
271];
272
273#[cfg(any(test, feature = "schema-contract-test-support"))]
274const V1_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = &[
275 "id",
276 "billing_scope_id",
277 "gateway_account_id",
278 "subscriber_id",
279 "plan_key",
280 "status",
281 "payment_method_id",
282 "amount_cents",
283 "currency",
284 "current_period_start_at",
285 "current_period_end_at",
286 "next_renewal_at",
287 "initial_transaction_id",
288 "canceled_at",
289 "created_at",
290 "updated_at",
291 "current_subscription_rank",
292];
293
294#[cfg(any(test, feature = "schema-contract-test-support"))]
295const V2_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = &[
296 "id",
297 "billing_scope_id",
298 "gateway_account_id",
299 "subscriber_id",
300 "plan_key",
301 "status",
302 "payment_method_id",
303 "amount_cents",
304 "currency",
305 "current_period_start_at",
306 "current_period_end_at",
307 "next_renewal_at",
308 "initial_transaction_id",
309 "canceled_at",
310 "created_at",
311 "updated_at",
312 "current_subscription_rank",
313 "phase",
314 "recurring_period_kind",
315 "recurring_period_count",
316 "trial_amount_cents",
317 "trial_period_kind",
318 "trial_period_count",
319 "dunning_retry_delays_seconds",
320 "dunning_exhaustion",
321 "past_due_access",
322 "next_payment_attempt_at",
323 "unpaid_at",
324];
325
326#[cfg(any(test, feature = "schema-contract-test-support"))]
327const V3_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = V2_CURRENT_SUBSCRIPTION_COLUMNS;
328const V4_CURRENT_SUBSCRIPTION_COLUMNS: &[&str] = &[
329 "id",
330 "billing_scope_id",
331 "gateway_account_id",
332 "subscriber_id",
333 "plan_key",
334 "status",
335 "payment_method_id",
336 "amount_cents",
337 "currency",
338 "current_period_start_at",
339 "current_period_end_at",
340 "next_renewal_at",
341 "initial_transaction_id",
342 "canceled_at",
343 "created_at",
344 "updated_at",
345 "current_subscription_rank",
346 "phase",
347 "recurring_period_kind",
348 "recurring_period_count",
349 "trial_amount_cents",
350 "trial_period_kind",
351 "trial_period_count",
352 "dunning_retry_delays_seconds",
353 "dunning_exhaustion",
354 "past_due_access",
355 "next_payment_attempt_at",
356 "unpaid_at",
357 "required_gateway_account_mode",
358];
359
360#[non_exhaustive]
366#[derive(Debug, Error)]
367pub enum SchemaConformanceError {
368 #[error("schema conformance query failed: {0}")]
369 Database(#[from] sqlx::Error),
370 #[error(
371 "PostgreSQL major version {required_major} is required; connected server reported server_version_num={actual_server_version_num}"
372 )]
373 UnsupportedPostgresVersion {
374 required_major: u16,
375 actual_server_version_num: i32,
376 },
377 #[error("schema version {version} does not conform: {detail}")]
378 Contract { version: u16, detail: String },
379}
380
381#[derive(Debug)]
382enum SchemaConformanceAttemptError {
383 Final(SchemaConformanceError),
384 RetryableReindexTransition { fallback: SchemaConformanceError },
385}
386
387impl From<SchemaConformanceError> for SchemaConformanceAttemptError {
388 fn from(error: SchemaConformanceError) -> Self {
389 Self::Final(error)
390 }
391}
392
393impl From<sqlx::Error> for SchemaConformanceAttemptError {
394 fn from(error: sqlx::Error) -> Self {
395 Self::Final(error.into())
396 }
397}
398
399#[cfg(any(test, feature = "schema-contract-test-support"))]
420pub async fn assert_runtime_schema_v3_compatible(
421 pool: &PgPool,
422) -> Result<(), SchemaConformanceError> {
423 assert_schema_conforms_in_read_only_snapshot(
424 pool,
425 3,
426 V3_CURRENT_SUBSCRIPTION_COLUMNS,
427 V3_CATALOG_FINGERPRINT,
428 )
429 .await
430}
431
432pub async fn assert_runtime_schema_v4_compatible(
439 pool: &PgPool,
440) -> Result<(), SchemaConformanceError> {
441 assert_schema_conforms_in_read_only_snapshot(
442 pool,
443 4,
444 V4_CURRENT_SUBSCRIPTION_COLUMNS,
445 V4_CATALOG_FINGERPRINT,
446 )
447 .await
448}
449
450#[cfg(any(test, feature = "schema-contract-test-support"))]
451async fn assert_runtime_schema_v2_compatible(pool: &PgPool) -> Result<(), SchemaConformanceError> {
452 assert_schema_conforms_in_read_only_snapshot(
453 pool,
454 2,
455 V2_CURRENT_SUBSCRIPTION_COLUMNS,
456 V2_CATALOG_FINGERPRINT,
457 )
458 .await
459}
460
461#[cfg(any(test, feature = "schema-contract-test-support"))]
462pub async fn assert_v2_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
465 assert_runtime_schema_v2_compatible(pool).await
466}
467
468#[cfg(any(test, feature = "schema-contract-test-support"))]
469pub async fn assert_v3_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
472 assert_runtime_schema_v3_compatible(pool).await
473}
474
475#[cfg(any(test, feature = "schema-contract-test-support"))]
476pub async fn assert_v4_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
479 assert_runtime_schema_v4_compatible(pool).await
480}
481
482#[cfg(any(test, feature = "schema-contract-test-support"))]
483pub async fn assert_v1_conforms(pool: &PgPool) -> Result<(), SchemaConformanceError> {
490 assert_schema_conforms_in_read_only_snapshot(
491 pool,
492 1,
493 V1_CURRENT_SUBSCRIPTION_COLUMNS,
494 V1_CATALOG_FINGERPRINT,
495 )
496 .await
497}
498
499async fn assert_schema_conforms_in_read_only_snapshot(
500 pool: &PgPool,
501 version: u16,
502 current_subscription_columns: &[&str],
503 expected_fingerprint: u64,
504) -> Result<(), SchemaConformanceError> {
505 retry_reindex_transition_once(|| {
506 assert_schema_conforms_in_one_read_only_snapshot(
507 pool,
508 version,
509 current_subscription_columns,
510 expected_fingerprint,
511 )
512 })
513 .await
514}
515
516async fn retry_reindex_transition_once<F, Fut>(
517 mut validate: F,
518) -> Result<(), SchemaConformanceError>
519where
520 F: FnMut() -> Fut,
521 Fut: std::future::Future<Output = Result<(), SchemaConformanceAttemptError>>,
522{
523 match validate().await {
524 Ok(()) => return Ok(()),
525 Err(SchemaConformanceAttemptError::Final(error)) => return Err(error),
526 Err(SchemaConformanceAttemptError::RetryableReindexTransition { .. }) => {}
527 }
528 tokio::time::sleep(REINDEX_TRANSITION_RETRY_DELAY).await;
529 match validate().await {
530 Ok(()) => Ok(()),
531 Err(SchemaConformanceAttemptError::Final(error)) => Err(error),
532 Err(SchemaConformanceAttemptError::RetryableReindexTransition { fallback }) => {
533 Err(fallback)
534 }
535 }
536}
537
538async fn assert_schema_conforms_in_one_read_only_snapshot(
539 pool: &PgPool,
540 version: u16,
541 current_subscription_columns: &[&str],
542 expected_fingerprint: u64,
543) -> Result<(), SchemaConformanceAttemptError> {
544 let mut transaction = pool
545 .begin_with("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
546 .await?;
547 let result = async {
548 let server_version_num =
549 sqlx::query_scalar::<_, i32>("SELECT current_setting('server_version_num')::integer")
550 .fetch_one(&mut *transaction)
551 .await?;
552 require_supported_postgres_version_num(server_version_num)?;
553 assert_schema_conforms(
554 &mut transaction,
555 version,
556 current_subscription_columns,
557 expected_fingerprint,
558 )
559 .await
560 }
561 .await;
562 match result {
563 Ok(()) => transaction.commit().await?,
564 Err(error) => {
565 transaction.rollback().await?;
566 return Err(error);
567 }
568 }
569 Ok(())
570}
571
572fn require_supported_postgres_version_num(
573 actual_server_version_num: i32,
574) -> Result<(), SchemaConformanceError> {
575 if actual_server_version_num / 10_000 == i32::from(SUPPORTED_POSTGRES_MAJOR_VERSION) {
576 Ok(())
577 } else {
578 Err(SchemaConformanceError::UnsupportedPostgresVersion {
579 required_major: SUPPORTED_POSTGRES_MAJOR_VERSION,
580 actual_server_version_num,
581 })
582 }
583}
584
585async fn assert_schema_conforms(
586 connection: &mut PgConnection,
587 version: u16,
588 current_subscription_columns: &[&str],
589 expected_fingerprint: u64,
590) -> Result<(), SchemaConformanceAttemptError> {
591 let billing_indexes = load_billing_index_catalog(connection).await?;
592 require_relations(connection, version, 'r', REQUIRED_TABLES).await?;
593 require_relations(connection, version, 'v', REQUIRED_VIEWS).await?;
594 require_functions(connection, version).await?;
595 require_triggers(connection, version).await?;
596 require_view_columns(
597 connection,
598 version,
599 "billing_payment_facts",
600 PAYMENT_FACT_COLUMNS,
601 )
602 .await?;
603 require_view_columns(
604 connection,
605 version,
606 "billing_current_subscriptions",
607 current_subscription_columns,
608 )
609 .await?;
610 reject_legacy_columns(connection, version).await?;
611 require_validated_constraints(connection, version).await?;
612 require_ready_canonical_indexes(version, &billing_indexes)?;
613 require_index_contract(connection, version, GATEWAY_ORDER_INDEX_CONTRACT).await?;
614 if version >= 2 {
615 let renewal_dispatch_contract = if version >= 4 {
616 V4_RENEWAL_DISPATCH_INDEX_CONTRACT
617 } else {
618 RENEWAL_DISPATCH_INDEX_CONTRACT
619 };
620 require_index_contract(connection, version, renewal_dispatch_contract).await?;
621 require_index_contract(connection, version, SUBSCRIPTION_HISTORY_INDEX_CONTRACT).await?;
622 }
623 if version >= 4 {
624 require_index_contract(connection, version, MODE_RENEWAL_DISPATCH_INDEX_CONTRACT).await?;
625 }
626 require_catalog_fingerprint(connection, version, expected_fingerprint, &billing_indexes)
627 .await?;
628 require_unchanged_active_reindex_shadows(connection, version, &billing_indexes).await
629}
630
631async fn require_unchanged_active_reindex_shadows(
632 connection: &mut PgConnection,
633 version: u16,
634 initial_indexes: &[BillingIndexCatalogEntry],
635) -> Result<(), SchemaConformanceAttemptError> {
636 let initial_active_reindex_shadows = active_reindex_shadows(initial_indexes);
637 if initial_active_reindex_shadows.is_empty() {
638 return Ok(());
639 }
640 let rechecked_indexes = load_billing_index_catalog(connection).await?;
641 if initial_active_reindex_shadows == active_reindex_shadows(&rechecked_indexes) {
642 Ok(())
643 } else {
644 Err(SchemaConformanceAttemptError::RetryableReindexTransition {
645 fallback: contract_error(version, REINDEX_TRANSITION_DETAIL),
646 })
647 }
648}
649
650async fn require_relations(
651 connection: &mut PgConnection,
652 version: u16,
653 relation_kind: char,
654 expected: &[&str],
655) -> Result<(), SchemaConformanceError> {
656 let expected_names = expected
657 .iter()
658 .map(|name| (*name).to_owned())
659 .collect::<Vec<_>>();
660 let actual = sqlx::query_scalar::<_, String>(
661 r#"
662 SELECT relation.relname
663 FROM pg_catalog.pg_class AS relation
664 INNER JOIN pg_catalog.pg_namespace AS namespace
665 ON namespace.oid = relation.relnamespace
666 WHERE namespace.nspname = 'public'
667 AND relation.relkind = $1
668 AND relation.relname = ANY($2)
669 ORDER BY relation.relname
670 "#,
671 )
672 .bind(relation_kind.to_string())
673 .bind(&expected_names)
674 .fetch_all(&mut *connection)
675 .await?;
676 require_exact_set(version, "relations", expected, actual)
677}
678
679async fn require_functions(
680 connection: &mut PgConnection,
681 version: u16,
682) -> Result<(), SchemaConformanceError> {
683 let expected = REQUIRED_FUNCTIONS
684 .iter()
685 .map(|name| (*name).to_owned())
686 .collect::<Vec<_>>();
687 let actual = sqlx::query_scalar::<_, String>(
688 r#"
689 SELECT DISTINCT function.proname
690 FROM pg_catalog.pg_proc AS function
691 INNER JOIN pg_catalog.pg_namespace AS namespace
692 ON namespace.oid = function.pronamespace
693 WHERE namespace.nspname = 'public'
694 AND function.prokind = 'f'
695 AND function.proname = ANY($1)
696 ORDER BY function.proname
697 "#,
698 )
699 .bind(&expected)
700 .fetch_all(&mut *connection)
701 .await?;
702 require_exact_set(version, "functions", REQUIRED_FUNCTIONS, actual)
703}
704
705async fn require_triggers(
706 connection: &mut PgConnection,
707 version: u16,
708) -> Result<(), SchemaConformanceError> {
709 let expected = REQUIRED_TRIGGERS
710 .iter()
711 .map(|name| (*name).to_owned())
712 .collect::<Vec<_>>();
713 let actual = sqlx::query_scalar::<_, String>(
714 r#"
715 SELECT trigger.tgname
716 FROM pg_catalog.pg_trigger AS trigger
717 INNER JOIN pg_catalog.pg_class AS relation
718 ON relation.oid = trigger.tgrelid
719 INNER JOIN pg_catalog.pg_namespace AS namespace
720 ON namespace.oid = relation.relnamespace
721 WHERE namespace.nspname = 'public'
722 AND NOT trigger.tgisinternal
723 AND trigger.tgenabled = 'O'
724 AND trigger.tgname = ANY($1)
725 ORDER BY trigger.tgname
726 "#,
727 )
728 .bind(&expected)
729 .fetch_all(&mut *connection)
730 .await?;
731 require_exact_set(version, "triggers", REQUIRED_TRIGGERS, actual)
732}
733
734async fn require_view_columns(
735 connection: &mut PgConnection,
736 version: u16,
737 view: &str,
738 expected_columns: &[&str],
739) -> Result<(), SchemaConformanceError> {
740 let actual = sqlx::query_scalar::<_, String>(
741 r#"
742 SELECT column_name
743 FROM information_schema.columns
744 WHERE table_schema = 'public'
745 AND table_name = $1
746 ORDER BY ordinal_position
747 "#,
748 )
749 .bind(view)
750 .fetch_all(&mut *connection)
751 .await?;
752 let expected = expected_columns
753 .iter()
754 .map(|column| (*column).to_owned())
755 .collect::<Vec<_>>();
756 if actual == expected {
757 Ok(())
758 } else {
759 Err(contract_error(
760 version,
761 format!("{view} columns differ: expected {expected:?}, found {actual:?}"),
762 ))
763 }
764}
765
766async fn reject_legacy_columns(
767 connection: &mut PgConnection,
768 version: u16,
769) -> Result<(), SchemaConformanceError> {
770 let tables = REQUIRED_TABLES
771 .iter()
772 .map(|name| (*name).to_owned())
773 .collect::<Vec<_>>();
774 let legacy = sqlx::query_as::<_, (String, String)>(
775 r#"
776 SELECT table_name, column_name
777 FROM information_schema.columns
778 WHERE table_schema = 'public'
779 AND table_name = ANY($1)
780 AND (
781 column_name IN (
782 'tenant_id',
783 'user_id',
784 'product',
785 'admin_user_id',
786 'granted_by_admin_user_id',
787 'revoked_by_admin_user_id',
788 'app_resolution_code',
789 'acquisition_channel'
790 )
791 OR column_name LIKE 'nmi\_%' ESCAPE '\'
792 OR column_name LIKE 'base\_subscription\_%' ESCAPE '\'
793 OR column_name LIKE 'google\_ads\_%' ESCAPE '\'
794 )
795 ORDER BY table_name, column_name
796 "#,
797 )
798 .bind(&tables)
799 .fetch_all(&mut *connection)
800 .await?;
801 if legacy.is_empty() {
802 Ok(())
803 } else {
804 Err(contract_error(
805 version,
806 format!("legacy columns remain on canonical relations: {legacy:?}"),
807 ))
808 }
809}
810
811async fn require_validated_constraints(
812 connection: &mut PgConnection,
813 version: u16,
814) -> Result<(), SchemaConformanceError> {
815 let tables = REQUIRED_TABLES
816 .iter()
817 .map(|name| (*name).to_owned())
818 .collect::<Vec<_>>();
819 let invalid = sqlx::query_as::<_, (String, String)>(
820 r#"
821 SELECT relation.relname, catalog_constraint.conname
822 FROM pg_catalog.pg_constraint AS catalog_constraint
823 INNER JOIN pg_catalog.pg_class AS relation
824 ON relation.oid = catalog_constraint.conrelid
825 INNER JOIN pg_catalog.pg_namespace AS namespace
826 ON namespace.oid = relation.relnamespace
827 WHERE namespace.nspname = 'public'
828 AND relation.relname = ANY($1)
829 AND catalog_constraint.conname LIKE 'billing\_%' ESCAPE '\'
830 AND NOT catalog_constraint.convalidated
831 ORDER BY relation.relname, catalog_constraint.conname
832 "#,
833 )
834 .bind(&tables)
835 .fetch_all(&mut *connection)
836 .await?;
837 if invalid.is_empty() {
838 Ok(())
839 } else {
840 Err(contract_error(
841 version,
842 format!("canonical constraints are not validated: {invalid:?}"),
843 ))
844 }
845}
846
847fn require_ready_canonical_indexes(
848 version: u16,
849 billing_indexes: &[BillingIndexCatalogEntry],
850) -> Result<(), SchemaConformanceAttemptError> {
851 let unavailable = billing_indexes
852 .iter()
853 .filter(|index| {
854 index.active_concurrent_reindex_pid.is_none()
855 && !(index.is_valid && index.is_ready && index.is_live)
856 })
857 .collect::<Vec<_>>();
858 if unavailable.is_empty() {
859 return Ok(());
860 }
861 let retryable_reindex_transition = unavailable
862 .iter()
863 .all(|index| !index.is_valid && has_concurrent_reindex_shadow_suffix(&index.index_name));
864 let unavailable_detail = unavailable
865 .iter()
866 .map(|index| {
867 (
868 index.table_name.clone(),
869 index.index_name.clone(),
870 index.is_valid,
871 index.is_ready,
872 index.is_live,
873 )
874 })
875 .collect::<Vec<_>>();
876 let fallback = contract_error(
877 version,
878 format!(
879 "canonical indexes are not planner/write ready (table, index, valid, ready, live): {unavailable_detail:?}; invalid _ccnew/_ccold indexes are tolerated only while matching REINDEX CONCURRENTLY progress is visible to the validating role, and stale shadows left by failed maintenance must be dropped"
880 ),
881 );
882 if retryable_reindex_transition {
883 Err(SchemaConformanceAttemptError::RetryableReindexTransition { fallback })
884 } else {
885 Err(fallback.into())
886 }
887}
888
889fn has_concurrent_reindex_shadow_suffix(index_name: &str) -> bool {
890 ["_ccnew", "_ccold"].iter().any(|marker| {
891 index_name
892 .rsplit_once(marker)
893 .is_some_and(|(base, counter)| {
894 !base.is_empty() && counter.bytes().all(|byte| byte.is_ascii_digit())
895 })
896 })
897}
898
899#[derive(Clone, Debug, sqlx::FromRow)]
900struct BillingIndexCatalogEntry {
901 table_name: String,
902 index_name: String,
903 definition: String,
904 is_valid: bool,
905 is_ready: bool,
906 is_live: bool,
907 active_concurrent_reindex_pid: Option<i32>,
908}
909
910fn active_reindex_shadows(indexes: &[BillingIndexCatalogEntry]) -> Vec<(&str, &str, i32)> {
911 indexes
912 .iter()
913 .filter_map(|index| {
914 index
915 .active_concurrent_reindex_pid
916 .map(|pid| (index.table_name.as_str(), index.index_name.as_str(), pid))
917 })
918 .collect()
919}
920
921async fn load_billing_index_catalog(
922 connection: &mut PgConnection,
923) -> Result<Vec<BillingIndexCatalogEntry>, sqlx::Error> {
924 let tables = REQUIRED_TABLES
925 .iter()
926 .map(|name| (*name).to_owned())
927 .collect::<Vec<_>>();
928 sqlx::query_as::<_, BillingIndexCatalogEntry>(
929 r#"
930 SELECT
931 table_relation.relname AS table_name,
932 index_relation.relname AS index_name,
933 concat_ws(
934 '|',
935 table_relation.relname,
936 index_relation.relname,
937 pg_catalog.pg_get_indexdef(index_relation.oid)
938 ) AS definition,
939 catalog_index.indisvalid AS is_valid,
940 catalog_index.indisready AS is_ready,
941 catalog_index.indislive AS is_live,
942 CASE
943 WHEN NOT catalog_index.indisvalid
944 AND index_relation.relname ~ $2
945 THEN (
946 SELECT shadow_lock.pid
947 FROM pg_catalog.pg_class AS canonical_index_relation
948 INNER JOIN pg_catalog.pg_index AS canonical_index
949 ON canonical_index.indexrelid = canonical_index_relation.oid
950 INNER JOIN pg_catalog.pg_locks AS shadow_lock
951 ON shadow_lock.locktype = 'relation'
952 AND shadow_lock.relation = index_relation.oid
953 AND shadow_lock.database = (
954 SELECT database.oid
955 FROM pg_catalog.pg_database AS database
956 WHERE database.datname = current_database()
957 )
958 AND shadow_lock.mode = 'ShareUpdateExclusiveLock'
959 AND shadow_lock.granted
960 INNER JOIN pg_catalog.pg_stat_progress_create_index AS progress
961 ON progress.pid = shadow_lock.pid
962 AND progress.datid = shadow_lock.database
963 AND progress.relid = table_relation.oid
964 AND (
965 -- PostgreSQL 18 reports the transient index before
966 -- the swap and the new canonical index afterward.
967 progress.index_relid IN (
968 index_relation.oid,
969 canonical_index_relation.oid
970 )
971 -- Table-wide reindex reports only its current
972 -- index while retaining session locks for every
973 -- index it is rebuilding on this table.
974 OR 1 < (
975 SELECT count(*)
976 FROM pg_catalog.pg_index AS scope_index
977 INNER JOIN pg_catalog.pg_locks AS scope_lock
978 ON scope_lock.pid = shadow_lock.pid
979 AND scope_lock.locktype = 'relation'
980 AND scope_lock.database = shadow_lock.database
981 AND scope_lock.relation = scope_index.indexrelid
982 AND scope_lock.mode = 'ShareUpdateExclusiveLock'
983 AND scope_lock.granted
984 WHERE scope_index.indrelid = table_relation.oid
985 AND scope_index.indisvalid
986 AND scope_index.indisready
987 AND scope_index.indislive
988 )
989 )
990 AND progress.command = 'REINDEX CONCURRENTLY'
991 -- Table-wide reindex gathering locks skipped invalid
992 -- indexes only before progress leaves initialization.
993 AND progress.phase <> 'initializing'
994 INNER JOIN pg_catalog.pg_locks AS canonical_index_lock
995 ON canonical_index_lock.pid = shadow_lock.pid
996 AND canonical_index_lock.locktype = 'relation'
997 AND canonical_index_lock.database = shadow_lock.database
998 AND canonical_index_lock.relation = canonical_index_relation.oid
999 AND canonical_index_lock.mode = 'ShareUpdateExclusiveLock'
1000 AND canonical_index_lock.granted
1001 INNER JOIN pg_catalog.pg_locks AS table_lock
1002 ON table_lock.pid = shadow_lock.pid
1003 AND table_lock.locktype = 'relation'
1004 AND table_lock.database = shadow_lock.database
1005 AND table_lock.relation = table_relation.oid
1006 AND table_lock.mode = 'ShareUpdateExclusiveLock'
1007 AND table_lock.granted
1008 WHERE canonical_index.indrelid = table_relation.oid
1009 AND canonical_index.indisvalid
1010 AND canonical_index.indisready
1011 AND canonical_index.indislive
1012 AND canonical_index_relation.relname !~ $2
1013 AND pg_catalog.starts_with(
1014 canonical_index_relation.relname,
1015 pg_catalog.regexp_replace(
1016 index_relation.relname,
1017 $2,
1018 ''
1019 )
1020 )
1021 ORDER BY canonical_index_relation.oid
1022 LIMIT 1
1023 )
1024 END AS active_concurrent_reindex_pid
1025 FROM pg_catalog.pg_index AS catalog_index
1026 INNER JOIN pg_catalog.pg_class AS table_relation
1027 ON table_relation.oid = catalog_index.indrelid
1028 INNER JOIN pg_catalog.pg_class AS index_relation
1029 ON index_relation.oid = catalog_index.indexrelid
1030 INNER JOIN pg_catalog.pg_namespace AS namespace
1031 ON namespace.oid = table_relation.relnamespace
1032 WHERE namespace.nspname = 'public'
1033 AND table_relation.relname = ANY($1)
1034 AND index_relation.relname LIKE 'billing\_%' ESCAPE '\'
1035 ORDER BY table_relation.relname, index_relation.relname
1036 "#,
1037 )
1038 .bind(&tables)
1039 .bind(CONCURRENT_REINDEX_SHADOW_INDEX_PATTERN)
1040 .fetch_all(&mut *connection)
1041 .await
1042}
1043
1044async fn require_index_contract(
1045 connection: &mut PgConnection,
1046 version: u16,
1047 contract: IndexContract,
1048) -> Result<(), SchemaConformanceError> {
1049 let actual = catalog_index_shape(connection, contract.name).await?;
1050 validate_index_contract(version, contract, actual.as_ref())
1051}
1052
1053fn validate_index_contract(
1054 version: u16,
1055 contract: IndexContract,
1056 actual: Option<&CatalogIndexShape>,
1057) -> Result<(), SchemaConformanceError> {
1058 let Some(actual) = actual else {
1059 return Err(index_contract_error(version, contract, "is missing"));
1060 };
1061 let expected_key_expressions = contract
1062 .keys
1063 .iter()
1064 .map(|key| key.expression.to_owned())
1065 .collect::<Vec<_>>();
1066 let expected_key_orderings = contract
1067 .keys
1068 .iter()
1069 .map(|key| key.ordering.catalog_label().to_owned())
1070 .collect::<Vec<_>>();
1071 let expected_key_opclasses = contract
1072 .keys
1073 .iter()
1074 .map(|key| key.opclass.to_owned())
1075 .collect::<Vec<_>>();
1076 let expected_included_expressions = contract
1077 .included_expressions
1078 .iter()
1079 .map(|expression| (*expression).to_owned())
1080 .collect::<Vec<_>>();
1081
1082 if actual.table_name != contract.table {
1083 return Err(index_contract_error(
1084 version,
1085 contract,
1086 format!(
1087 "belongs to table {:?}; expected {:?}",
1088 actual.table_name, contract.table
1089 ),
1090 ));
1091 }
1092 if actual.access_method != "btree" {
1093 return Err(index_contract_error(
1094 version,
1095 contract,
1096 format!(
1097 "uses access method {:?}; expected \"btree\"",
1098 actual.access_method
1099 ),
1100 ));
1101 }
1102 if actual.is_unique != contract.unique {
1103 return Err(index_contract_error(
1104 version,
1105 contract,
1106 format!(
1107 "has unique={}; expected unique={}",
1108 actual.is_unique, contract.unique
1109 ),
1110 ));
1111 }
1112 if actual.key_expressions != expected_key_expressions {
1113 return Err(index_contract_error(
1114 version,
1115 contract,
1116 format!(
1117 "has key expressions {:?}; expected {expected_key_expressions:?}",
1118 actual.key_expressions
1119 ),
1120 ));
1121 }
1122 if actual.key_orderings != expected_key_orderings {
1123 return Err(index_contract_error(
1124 version,
1125 contract,
1126 format!(
1127 "has key ordering {:?}; expected {expected_key_orderings:?}",
1128 actual.key_orderings
1129 ),
1130 ));
1131 }
1132 if actual.key_opclasses != expected_key_opclasses {
1133 return Err(index_contract_error(
1134 version,
1135 contract,
1136 format!(
1137 "has key operator classes {:?}; expected {expected_key_opclasses:?}",
1138 actual.key_opclasses
1139 ),
1140 ));
1141 }
1142 if actual.included_expressions != expected_included_expressions {
1143 return Err(index_contract_error(
1144 version,
1145 contract,
1146 format!(
1147 "has included expressions {:?}; expected {expected_included_expressions:?}",
1148 actual.included_expressions
1149 ),
1150 ));
1151 }
1152 if actual.predicate.as_deref() != contract.predicate {
1153 return Err(index_contract_error(
1154 version,
1155 contract,
1156 format!(
1157 "has predicate {:?}; expected {:?}",
1158 actual.predicate, contract.predicate
1159 ),
1160 ));
1161 }
1162 if !actual.is_valid || !actual.is_ready || !actual.is_live {
1163 return Err(index_contract_error(
1164 version,
1165 contract,
1166 format!(
1167 "is not planner/write ready (valid={}, ready={}, live={})",
1168 actual.is_valid, actual.is_ready, actual.is_live
1169 ),
1170 ));
1171 }
1172 Ok(())
1173}
1174
1175fn index_contract_error(
1176 version: u16,
1177 contract: IndexContract,
1178 detail: impl std::fmt::Display,
1179) -> SchemaConformanceError {
1180 contract_error(
1181 version,
1182 format!("{} index {} {detail}", contract.purpose, contract.name),
1183 )
1184}
1185
1186#[derive(Clone, Debug, sqlx::FromRow)]
1187struct CatalogIndexShape {
1188 table_name: String,
1189 access_method: String,
1190 key_expressions: Vec<String>,
1191 key_orderings: Vec<String>,
1192 key_opclasses: Vec<String>,
1193 included_expressions: Vec<String>,
1194 predicate: Option<String>,
1195 is_unique: bool,
1196 is_valid: bool,
1197 is_ready: bool,
1198 is_live: bool,
1199}
1200
1201async fn catalog_index_shape(
1202 connection: &mut PgConnection,
1203 index_name: &str,
1204) -> Result<Option<CatalogIndexShape>, sqlx::Error> {
1205 sqlx::query_as::<_, CatalogIndexShape>(
1206 r#"
1207 SELECT
1208 table_relation.relname AS table_name,
1209 access_method.amname AS access_method,
1210 ARRAY(
1211 SELECT pg_catalog.pg_get_indexdef(
1212 catalog_index.indexrelid,
1213 key_position.position,
1214 true
1215 )
1216 FROM generate_series(
1217 1,
1218 catalog_index.indnkeyatts
1219 ) AS key_position(position)
1220 ORDER BY key_position.position
1221 ) AS key_expressions,
1222 ARRAY(
1223 SELECT concat(
1224 CASE WHEN pg_catalog.pg_index_column_has_property(
1225 catalog_index.indexrelid,
1226 key_position.position,
1227 'desc'
1228 ) THEN 'DESC' ELSE 'ASC' END,
1229 CASE WHEN pg_catalog.pg_index_column_has_property(
1230 catalog_index.indexrelid,
1231 key_position.position,
1232 'nulls_first'
1233 ) THEN ' NULLS FIRST' ELSE ' NULLS LAST' END
1234 )
1235 FROM generate_series(
1236 1,
1237 catalog_index.indnkeyatts
1238 ) AS key_position(position)
1239 ORDER BY key_position.position
1240 ) AS key_orderings,
1241 ARRAY(
1242 SELECT concat(operator_class_namespace.nspname, '.', operator_class.opcname)
1243 FROM unnest(catalog_index.indclass::oid[]) WITH ORDINALITY
1244 AS key_operator_class(operator_class_oid, position)
1245 INNER JOIN pg_catalog.pg_opclass AS operator_class
1246 ON operator_class.oid = key_operator_class.operator_class_oid
1247 INNER JOIN pg_catalog.pg_namespace AS operator_class_namespace
1248 ON operator_class_namespace.oid = operator_class.opcnamespace
1249 WHERE key_operator_class.position <= catalog_index.indnkeyatts
1250 ORDER BY key_operator_class.position
1251 ) AS key_opclasses,
1252 ARRAY(
1253 SELECT pg_catalog.pg_get_indexdef(
1254 catalog_index.indexrelid,
1255 included_position.position,
1256 true
1257 )
1258 FROM generate_series(
1259 catalog_index.indnkeyatts::integer + 1,
1260 catalog_index.indnatts::integer
1261 ) AS included_position(position)
1262 ORDER BY included_position.position
1263 ) AS included_expressions,
1264 pg_catalog.pg_get_expr(
1265 catalog_index.indpred,
1266 catalog_index.indrelid,
1267 true
1268 ) AS predicate,
1269 catalog_index.indisunique AS is_unique,
1270 catalog_index.indisvalid AS is_valid,
1271 catalog_index.indisready AS is_ready,
1272 catalog_index.indislive AS is_live
1273 FROM pg_catalog.pg_class AS index_relation
1274 INNER JOIN pg_catalog.pg_index AS catalog_index
1275 ON catalog_index.indexrelid = index_relation.oid
1276 INNER JOIN pg_catalog.pg_class AS table_relation
1277 ON table_relation.oid = catalog_index.indrelid
1278 INNER JOIN pg_catalog.pg_namespace AS index_namespace
1279 ON index_namespace.oid = index_relation.relnamespace
1280 INNER JOIN pg_catalog.pg_namespace AS table_namespace
1281 ON table_namespace.oid = table_relation.relnamespace
1282 INNER JOIN pg_catalog.pg_am AS access_method
1283 ON access_method.oid = index_relation.relam
1284 WHERE index_namespace.nspname = 'public'
1285 AND table_namespace.nspname = 'public'
1286 AND index_relation.relkind = 'i'
1287 AND index_relation.relname = $1
1288 "#,
1289 )
1290 .bind(index_name)
1291 .fetch_optional(&mut *connection)
1292 .await
1293}
1294
1295async fn require_catalog_fingerprint(
1296 connection: &mut PgConnection,
1297 version: u16,
1298 expected: u64,
1299 billing_indexes: &[BillingIndexCatalogEntry],
1300) -> Result<(), SchemaConformanceError> {
1301 let actual = canonical_catalog_fingerprint(connection, billing_indexes).await?;
1302 if actual == expected {
1303 Ok(())
1304 } else {
1305 Err(contract_error(
1306 version,
1307 format!(
1308 "canonical catalog fingerprint differs: expected {expected:#018x}, found {actual:#018x}"
1309 ),
1310 ))
1311 }
1312}
1313
1314async fn canonical_catalog_fingerprint(
1315 connection: &mut PgConnection,
1316 billing_indexes: &[BillingIndexCatalogEntry],
1317) -> Result<u64, SchemaConformanceError> {
1318 let tables = REQUIRED_TABLES
1319 .iter()
1320 .map(|name| (*name).to_owned())
1321 .collect::<Vec<_>>();
1322 let views = REQUIRED_VIEWS
1323 .iter()
1324 .map(|name| (*name).to_owned())
1325 .collect::<Vec<_>>();
1326 let functions = REQUIRED_FUNCTIONS
1327 .iter()
1328 .map(|name| (*name).to_owned())
1329 .collect::<Vec<_>>();
1330 let triggers = REQUIRED_TRIGGERS
1331 .iter()
1332 .map(|name| (*name).to_owned())
1333 .collect::<Vec<_>>();
1334
1335 let columns = sqlx::query_scalar::<_, String>(
1336 r#"
1337 SELECT concat_ws(
1338 '|',
1339 table_name,
1340 ordinal_position::text,
1341 column_name,
1342 data_type,
1343 udt_name,
1344 is_nullable,
1345 COALESCE(column_default, '')
1346 )
1347 FROM information_schema.columns
1348 WHERE table_schema = 'public'
1349 AND table_name = ANY($1)
1350 ORDER BY table_name, ordinal_position
1351 "#,
1352 )
1353 .bind(&tables)
1354 .fetch_all(&mut *connection)
1355 .await?;
1356 let constraints = sqlx::query_scalar::<_, String>(
1357 r#"
1358 SELECT concat_ws(
1359 '|',
1360 relation.relname,
1361 catalog_constraint.conname,
1362 catalog_constraint.contype::text,
1363 pg_catalog.pg_get_constraintdef(catalog_constraint.oid, true)
1364 )
1365 FROM pg_catalog.pg_constraint AS catalog_constraint
1366 INNER JOIN pg_catalog.pg_class AS relation
1367 ON relation.oid = catalog_constraint.conrelid
1368 INNER JOIN pg_catalog.pg_namespace AS namespace
1369 ON namespace.oid = relation.relnamespace
1370 WHERE namespace.nspname = 'public'
1371 AND relation.relname = ANY($1)
1372 AND catalog_constraint.contype <> 'n'
1373 AND catalog_constraint.conname LIKE 'billing\_%' ESCAPE '\'
1374 ORDER BY relation.relname, catalog_constraint.conname
1375 "#,
1376 )
1377 .bind(&tables)
1378 .fetch_all(&mut *connection)
1379 .await?;
1380 let indexes = billing_indexes
1381 .iter()
1382 .filter(|index| index.active_concurrent_reindex_pid.is_none())
1383 .map(|index| index.definition.clone())
1384 .collect::<Vec<_>>();
1385 let view_definitions = sqlx::query_scalar::<_, String>(
1386 r#"
1387 SELECT concat_ws(
1388 '|',
1389 relation.relname,
1390 pg_catalog.pg_get_viewdef(relation.oid, true)
1391 )
1392 FROM pg_catalog.pg_class AS relation
1393 INNER JOIN pg_catalog.pg_namespace AS namespace
1394 ON namespace.oid = relation.relnamespace
1395 WHERE namespace.nspname = 'public'
1396 AND relation.relkind = 'v'
1397 AND relation.relname = ANY($1)
1398 ORDER BY relation.relname
1399 "#,
1400 )
1401 .bind(&views)
1402 .fetch_all(&mut *connection)
1403 .await?;
1404 let function_definitions = sqlx::query_scalar::<_, String>(
1405 r#"
1406 SELECT concat_ws(
1407 '|',
1408 catalog_function.proname,
1409 pg_catalog.pg_get_function_identity_arguments(
1410 catalog_function.oid
1411 ),
1412 pg_catalog.pg_get_functiondef(catalog_function.oid)
1413 )
1414 FROM pg_catalog.pg_proc AS catalog_function
1415 INNER JOIN pg_catalog.pg_namespace AS namespace
1416 ON namespace.oid = catalog_function.pronamespace
1417 WHERE namespace.nspname = 'public'
1418 AND catalog_function.prokind = 'f'
1419 AND catalog_function.proname = ANY($1)
1420 ORDER BY
1421 catalog_function.proname,
1422 pg_catalog.pg_get_function_identity_arguments(
1423 catalog_function.oid
1424 )
1425 "#,
1426 )
1427 .bind(&functions)
1428 .fetch_all(&mut *connection)
1429 .await?;
1430 let trigger_definitions = sqlx::query_scalar::<_, String>(
1431 r#"
1432 SELECT concat_ws(
1433 '|',
1434 relation.relname,
1435 catalog_trigger.tgname,
1436 catalog_trigger.tgenabled::text,
1437 pg_catalog.pg_get_triggerdef(catalog_trigger.oid, true)
1438 )
1439 FROM pg_catalog.pg_trigger AS catalog_trigger
1440 INNER JOIN pg_catalog.pg_class AS relation
1441 ON relation.oid = catalog_trigger.tgrelid
1442 INNER JOIN pg_catalog.pg_namespace AS namespace
1443 ON namespace.oid = relation.relnamespace
1444 WHERE namespace.nspname = 'public'
1445 AND NOT catalog_trigger.tgisinternal
1446 AND catalog_trigger.tgname = ANY($1)
1447 ORDER BY relation.relname, catalog_trigger.tgname
1448 "#,
1449 )
1450 .bind(&triggers)
1451 .fetch_all(&mut *connection)
1452 .await?;
1453
1454 Ok(catalog_fingerprint([
1455 ("columns", columns.as_slice()),
1456 ("constraints", constraints.as_slice()),
1457 ("indexes", indexes.as_slice()),
1458 ("views", view_definitions.as_slice()),
1459 ("functions", function_definitions.as_slice()),
1460 ("triggers", trigger_definitions.as_slice()),
1461 ]))
1462}
1463
1464#[cfg(test)]
1465async fn canonical_catalog_fingerprint_for_pool(
1466 pool: &PgPool,
1467) -> Result<u64, SchemaConformanceError> {
1468 let mut connection = pool.acquire().await?;
1469 let billing_indexes = load_billing_index_catalog(&mut connection).await?;
1470 canonical_catalog_fingerprint(&mut connection, &billing_indexes).await
1471}
1472
1473fn catalog_fingerprint<'a>(categories: impl IntoIterator<Item = (&'a str, &'a [String])>) -> u64 {
1474 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1475 const PRIME: u64 = 0x0000_0100_0000_01b3;
1476
1477 fn add_bytes(mut state: u64, bytes: &[u8]) -> u64 {
1478 for byte in bytes {
1479 state ^= u64::from(*byte);
1480 state = state.wrapping_mul(PRIME);
1481 }
1482 state
1483 }
1484
1485 let mut state = OFFSET_BASIS;
1486 for (category, rows) in categories {
1487 state = add_bytes(state, category.as_bytes());
1488 state = add_bytes(state, &rows.len().to_be_bytes());
1489 for row in rows {
1490 state = add_bytes(state, &row.len().to_be_bytes());
1491 state = add_bytes(state, row.as_bytes());
1492 }
1493 }
1494 state
1495}
1496
1497fn require_exact_set(
1498 version: u16,
1499 category: &str,
1500 expected: &[&str],
1501 actual: Vec<String>,
1502) -> Result<(), SchemaConformanceError> {
1503 let expected = expected.iter().copied().collect::<BTreeSet<_>>();
1504 let actual = actual.iter().map(String::as_str).collect::<BTreeSet<_>>();
1505 if actual == expected {
1506 Ok(())
1507 } else {
1508 let missing = expected.difference(&actual).copied().collect::<Vec<_>>();
1509 Err(contract_error(
1510 version,
1511 format!("missing {category}: {missing:?}"),
1512 ))
1513 }
1514}
1515
1516fn contract_error(version: u16, detail: impl Into<String>) -> SchemaConformanceError {
1517 SchemaConformanceError::Contract {
1518 version,
1519 detail: detail.into(),
1520 }
1521}
1522
1523#[cfg(test)]
1524mod tests;