diff --git a/Cargo.lock b/Cargo.lock
index 8ba7ce10d..a9cf4427a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3045,7 +3045,9 @@ name = "db-sync-sqlx"
version = "1.8.1"
dependencies = [
"hex",
+ "log",
"num-traits",
+ "serde",
"sidechain-domain",
"sqlx",
]
@@ -7836,6 +7838,7 @@ dependencies = [
"blake2b_simd",
"clap",
"config",
+ "db-sync-sqlx",
"derive-new",
"documented",
"frame-benchmarking",
@@ -8494,6 +8497,7 @@ dependencies = [
"sp-runtime",
"sqlx",
"substrate-prometheus-endpoint",
+ "testcontainers-modules",
"thiserror 2.0.18",
"tokio",
]
diff --git a/changes/node/changed/configurable-db-sync-layout-and-schema-mode.md b/changes/node/changed/configurable-db-sync-layout-and-schema-mode.md
new file mode 100644
index 000000000..50dcfa3b2
--- /dev/null
+++ b/changes/node/changed/configurable-db-sync-layout-and-schema-mode.md
@@ -0,0 +1,12 @@
+#node
+# Support configurable db-sync layouts and read-only schema verification
+
+Add explicit node configuration for transaction-input storage (`auto`, `tx_in`, or `consumed`),
+address storage (`inline` or `address_table`), and schema management (`apply`, `verify`, or
+`skip`). Midnight data sources now adapt their queries to supported cardano-db-sync layouts and
+can verify operator-managed indexes without requiring database write privileges. The existing
+`auto`/`inline`/`apply` behavior remains the default for initialized databases; ambiguous empty
+input layouts now fail with an actionable request for explicit configuration.
+
+PR: https://github.com/midnightntwrk/midnight-node/pull/2065
+Issue: https://github.com/midnightntwrk/midnight-node/issues/1160
diff --git a/changes/toolkit/changed/configurable-db-sync-layout-and-schema-mode.md b/changes/toolkit/changed/configurable-db-sync-layout-and-schema-mode.md
new file mode 100644
index 000000000..e05beff5a
--- /dev/null
+++ b/changes/toolkit/changed/configurable-db-sync-layout-and-schema-mode.md
@@ -0,0 +1,15 @@
+#toolkit
+# Support configurable db-sync layouts and operator-managed indexes
+
+Partner Chains db-sync data sources now support both transaction-input representations (`tx_in`
+and `tx_out.consumed_by_tx_id`) and both address representations (inline `tx_out.address` and the
+normalized `address` table). Public configuration types allow callers to select an explicit
+layout or retain automatic transaction-input detection.
+
+Candidate data sources also support `apply`, read-only `verify`, and `skip` index policies. The
+runtime manifest includes the selected address and transaction-input indexes, accepts equivalent
+operator-managed indexes regardless of name, and preserves the existing automatic behavior by
+default for initialized databases. Ambiguous empty input layouts now require an explicit mode.
+
+PR: https://github.com/midnightntwrk/midnight-node/pull/2065
+Issue: https://github.com/midnightntwrk/midnight-node/issues/1160
diff --git a/docs/configuration-guide.md b/docs/configuration-guide.md
index c78cbca77..49e3251e9 100644
--- a/docs/configuration-guide.md
+++ b/docs/configuration-guide.md
@@ -58,6 +58,272 @@ CURRENT_VALUE: my_new_chain_id
...
```
+## Cardano db-sync compatibility
+
+Midnight reads Cardano data from an existing cardano-db-sync PostgreSQL database. The node can
+query both supported transaction-input representations and both supported address
+representations. Schema management is configured separately, so the PostgreSQL role used by the
+node can be read-only.
+
+| TOML key | Environment variable | Values | Default |
+|----------|----------------------|--------|---------|
+| `db_sync_tx_input_mode` | `DB_SYNC_TX_INPUT_MODE` | `auto`, `tx_in`, `consumed` | `auto` |
+| `db_sync_address_mode` | `DB_SYNC_ADDRESS_MODE` | `inline`, `address_table` | `inline` |
+| `db_sync_schema_mode` | `DB_SYNC_SCHEMA_MODE` | `apply`, `verify`, `skip` | `apply` |
+
+The defaults preserve the previous node behavior for initialized standard db-sync databases. An
+empty schema whose input representation cannot be inferred now fails safely. For production
+deployments, set the two layout options explicitly. Layout validation uses the connection's
+current PostgreSQL `search_path`, so the selected db-sync tables must resolve without
+schema-qualified names.
+
+### Selecting the db-sync layout
+
+`db_sync_tx_input_mode` maps to cardano-db-sync's `insert_options.tx_out` settings:
+
+| Midnight mode | Required db-sync representation |
+|---------------|---------------------------------|
+| `tx_in` | A complete `tx_in` table with `tx_in_id`, `tx_out_id`, and `tx_out_index`. This is produced by `tx_out.value = "enable"`, or by `tx_out.force_tx_in = true` with `tx_out.value = "consumed"`. |
+| `consumed` | A complete `tx_out.consumed_by_tx_id` history. This is produced by `tx_out.value = "consumed"`. |
+| `auto` | Uses `tx_in` when it has rows, otherwise uses `tx_out.consumed_by_tx_id` when at least one output records a consuming transaction. If both representations are structurally present but empty, startup fails as ambiguous and asks for an explicit mode. This does not prove that the selected representation has complete history. |
+
+`db_sync_address_mode` maps to `insert_options.tx_out.use_address_table`:
+
+| Midnight mode | db-sync setting | Required columns |
+|---------------|-----------------|------------------|
+| `inline` | `false` | `tx_out.address` |
+| `address_table` | `true` | `tx_out.address_id`, plus `address.id` and `address.address` |
+
+Both address layouts are supported. The transaction-output modes `prune`, `bootstrap`, and
+`disable` are not supported because they do not retain the complete output history required by
+Midnight queries. `consumed` with `force_tx_in = false` is supported with Midnight's `consumed`
+mode; `consumed` with `force_tx_in = true` can use either complete representation.
+
+The other db-sync data used by the main-chain follower must also be retained. In configurations
+that expose the individual switches, keep ledger-derived data, multi-assets, Plutus/datum data,
+and the C-to-M bridge metadata enabled. In current db-sync configuration terms, this means:
+
+- `insert_options.ledger = "enable"`
+- `insert_options.multi_asset.enable = true`
+- `insert_options.plutus.enable = true`
+- `insert_options.metadata.enable = true`
+- if `insert_options.metadata.keys` filters retained metadata, it includes key `6500973`
+- datum and metadata JSON data remains present. Both values of
+ `insert_options.remove_jsonb_from_schema` are supported; Midnight casts retained text values to
+ `jsonb` while reading them.
+
+The C-to-M bridge reads `tx_metadata` rows with key `6500973`. The current follower does not query
+db-sync governance tables, so `insert_options.governance` is not a compatibility requirement for
+this version.
+
+The db-sync `only_utxo`, `only_governance`, and `disable_all` presets are therefore not suitable
+for a full Midnight node. See the upstream
+[cardano-db-sync configuration reference](https://github.com/IntersectMBO/cardano-db-sync/blob/master/doc/configuration.md)
+for the behavior of these settings.
+
+#### Historical completeness
+
+Column presence is not proof of data completeness. In particular, changing a running db-sync
+instance from `tx_out.value = "enable"` to `"consumed"`, or enabling `force_tx_in` after part of
+the chain has already been synced, does not by itself guarantee that old spends have been
+backfilled. A pruned or bootstrapped database is also insufficient even when its current UTXO set
+is complete.
+
+Before selecting a mode, complete the cardano-db-sync migration or backfill procedure for the
+entire block and epoch range Midnight will query, or restore/resync from a compatible full-history
+snapshot. Validate the historical data separately. `auto` only detects schema shape and whether
+either input representation contains evidence; `verify` only checks schema shape and indexes. Neither mode audits
+historical completeness. Schema checks also cannot determine whether `tx_metadata` key `6500973`
+was filtered or whether its older rows are missing. Enabling the metadata key for new blocks does
+not backfill bridge metadata history.
+
+### Schema-management modes
+
+| Mode | Behavior | Database privileges |
+|------|----------|---------------------|
+| `apply` | Creates missing indexes for the current command with `CREATE INDEX CONCURRENTLY`. cNight genesis commands also set recommended per-table autovacuum reloptions. This is the backward-compatible default. | Ownership of the affected tables, or an equivalent administrative role, in addition to read access. |
+| `verify` | Performs read-only layout and index checks for the current command. A missing required index fails initialization. cNight genesis commands also warn about non-recommended autovacuum settings. | `CONNECT`, schema `USAGE`, table `SELECT`, and access to PostgreSQL catalog metadata. |
+| `skip` | Resolves and validates the selected layout but does not create, alter, or verify indexes and autovacuum settings. | Read access only, but the operator assumes responsibility for correctness and performance. |
+
+The managed manifest depends on the entry point:
+
+- Normal node startup and `generate-permissioned-candidates-genesis` apply or verify the
+ runtime/candidate manifest. It includes the selected transaction-input and address indexes.
+- `generate-c-night-genesis` applies or verifies the broader cNight genesis manifest and its
+ autovacuum recommendations. The cNight phase of `generate-genesis-config` does the same, and its
+ permissioned-candidates phase also manages the runtime/candidate manifest.
+- Standalone genesis commands that only resolve the query layout, such as ICS or reserve genesis,
+ do not manage an index manifest.
+
+Normal node startup does not enforce every cNight genesis index or autovacuum recommendation.
+Install the combined manifest below before using a read-only role for both genesis generation and
+normal operation. `db_sync_schema_mode` controls Midnight-issued `CREATE INDEX` and `ALTER TABLE`
+statements; the node never inserts, updates, or deletes Cardano chain data.
+
+#### Index manifests
+
+Verification is based on index structure, not index name. An existing index is accepted when it is
+valid, ready, non-partial, uses one of the listed access methods, and has the listed columns as its
+leading keys. For example, either `(tx_out_id)` or `(tx_out_id, ident)` satisfies the
+`ma_tx_out(tx_out_id)` requirement. This allows operators to retain standard db-sync indexes and
+their own index names.
+
+Layout-independent indexes in the combined runtime/candidate and cNight genesis manifests:
+
+| Relation | Access method | Leading keys | Managed by |
+|----------|---------------|--------------|------------|
+| `multi_asset` | btree | `policy`, `name` | cNight genesis |
+| `ma_tx_out` | btree | `ident` | Both |
+| `ma_tx_out` | btree | `tx_out_id` | Both |
+| `block` | btree | `block_no` | cNight genesis |
+| `tx` | btree | `block_id` | cNight genesis |
+| `tx_out` | btree | `tx_id` | cNight genesis |
+
+Additional indexes for the selected address layout:
+
+| Address mode | Relation | Access method | Leading keys | Managed by |
+|--------------|----------|---------------|--------------|------------|
+| `inline` | `tx_out` | hash or btree | `address` | Both |
+| `address_table` | `address` | hash or btree | `address` | Both |
+| `address_table` | `tx_out` | btree | `address_id` | Both |
+
+Additional indexes for the selected transaction-input layout:
+
+| Transaction-input mode | Relation | Access method | Leading keys | Managed by |
+|------------------------|----------|---------------|--------------|------------|
+| `tx_in` | `tx_in` | btree | `tx_in_id` | Both |
+| `tx_in` | `tx_in` | btree | `tx_out_id`, `tx_out_index` | Both |
+| `consumed` | `tx_out` | btree | `consumed_by_tx_id` | Both |
+
+#### Operator-managed SQL
+
+Run index creation as the db-sync table owner or a dedicated migration role, not as the Midnight
+read-only role. `CREATE INDEX CONCURRENTLY` must be run outside a transaction. The following names
+match the names used by `apply` mode; structurally compatible indexes with other names are also
+accepted. Do not blindly execute every statement: a normal db-sync database already contains
+several compatible indexes under different names, and PostgreSQL's `IF NOT EXISTS` only compares
+the proposed name. Run `verify` first (or inspect the catalog), then create only the structures it
+reports as missing.
+
+For the full combined manifest, independent of layout:
+
+```sql
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_multi_asset_policy_name
+ ON multi_asset (policy, name);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_ident
+ ON ma_tx_out (ident);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_id_ident
+ ON ma_tx_out (tx_out_id, ident);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_block_block_no
+ ON block (block_no);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_block_id
+ ON tx (block_id);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_tx_id
+ ON tx_out (tx_id);
+```
+
+The standard db-sync btree on `ma_tx_out(tx_out_id)` also satisfies the second requirement. Apply
+mode creates the covering `(tx_out_id, ident)` form only when no tx-out-id-leading index exists.
+
+For `db_sync_address_mode = "inline"`:
+
+```sql
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_address
+ ON tx_out USING hash (address);
+```
+
+For `db_sync_address_mode = "address_table"`:
+
+```sql
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_address_address
+ ON address USING hash (address);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_address_id
+ ON tx_out (address_id);
+```
+
+For `db_sync_tx_input_mode = "tx_in"`:
+
+```sql
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_in_tx_in_id
+ ON tx_in (tx_in_id);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_in_tx_out_id_tx_out_index
+ ON tx_in (tx_out_id, tx_out_index);
+```
+
+For `db_sync_tx_input_mode = "consumed"`:
+
+```sql
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_consumed_by_tx_id
+ ON tx_out (consumed_by_tx_id);
+```
+
+After applying the manifest, start the node or run the genesis command once with
+`db_sync_schema_mode = "verify"`. This catches an invalid or partial index, including a name
+collision where `IF NOT EXISTS` left an incompatible index in place.
+
+#### Autovacuum recommendations
+
+When the cNight genesis schema manifest is managed, `apply` sets the following reloptions on the
+db-sync tables queried by cNight observation:
+
+```sql
+ALTER TABLE
SET (
+ autovacuum_analyze_scale_factor = 0.01,
+ autovacuum_vacuum_scale_factor = 0.05
+);
+```
+
+The base table set is `block`, `tx`, `tx_out`, `ma_tx_out`, and `datum`. It also includes `tx_in`
+for the `tx_in` layout and `address` for the `address_table` layout. A DBA can apply equivalent
+per-table or cluster-level tuning. `verify` warns about missing table reloptions but does not fail,
+because suitable cluster-level settings cannot be inferred from relation metadata alone.
+
+### Read-only deployment workflow
+
+For an existing db-sync database managed separately from Midnight:
+
+1. Confirm the db-sync layout and verify that the selected transaction-input representation has
+ complete history for the range Midnight will query.
+2. Grant a separate Midnight login `CONNECT` on the database, `USAGE` on the db-sync schema, and
+ `SELECT` on the db-sync tables. Do not grant table ownership, `CREATE`, `INSERT`, `UPDATE`, or
+ `DELETE`.
+3. Configure explicit layout modes and set `db_sync_schema_mode = "verify"`. Run the normal node
+ and each manifest-managing genesis entry point you plan to use to identify only the missing
+ index structures.
+4. As the db-sync owner or migration role, create those missing indexes. Apply the recommended
+ per-table autovacuum settings, or document equivalent cluster-level tuning; a mismatch is a
+ performance warning rather than a `verify` failure.
+5. Rerun `verify`. Use `SHOW_CONFIG=1` to confirm the effective values before the final genesis and
+ node runs. Each entry point fails initialization when an index required by its managed manifest
+ is absent or unusable.
+
+For a db-sync database configured with `tx_out.value = "consumed"`,
+`tx_out.force_tx_in = false`, and `tx_out.use_address_table = true`, the exact read-only profile is:
+
+```toml
+db_sync_tx_input_mode = "consumed"
+db_sync_address_mode = "address_table"
+db_sync_schema_mode = "verify"
+```
+
+The equivalent environment variables are:
+
+```sh
+export DB_SYNC_TX_INPUT_MODE=consumed
+export DB_SYNC_ADDRESS_MODE=address_table
+export DB_SYNC_SCHEMA_MODE=verify
+```
+
+The connection role can be made read-only at the PostgreSQL level as an additional guardrail. For
+example, run the grants as an administrator, substituting the actual database, schema, and role:
+
+```sql
+GRANT CONNECT ON DATABASE cexplorer TO midnight_reader;
+GRANT USAGE ON SCHEMA public TO midnight_reader;
+GRANT SELECT ON ALL TABLES IN SCHEMA public TO midnight_reader;
+ALTER ROLE midnight_reader SET default_transaction_read_only = on;
+```
+
## Chainspecs
To run the node, you must supply a chainspec file. Chainspec files for known networks are stored in `res//` and are named `chain-spec.json` (human-readable) or `chain-spec-raw.json` (encoded for production use).
diff --git a/docs/genesis/README.md b/docs/genesis/README.md
index bea88e17f..4e90478dc 100644
--- a/docs/genesis/README.md
+++ b/docs/genesis/README.md
@@ -153,6 +153,9 @@ The `cardano-tip.json` file in each network's `res//` directory stores
2. **Cardano db-sync access**:
- Local: `postgres://postgres:postgres@localhost:5432/cexplorer`
- Set `DB_SYNC_POSTGRES_CONNECTION_STRING` environment variable
+ - Select the database layout and schema policy described in
+ [Cardano db-sync compatibility](../configuration-guide.md#cardano-db-sync-compatibility).
+ A read-only login requires `DB_SYNC_SCHEMA_MODE=verify` and operator-managed indexes.
3. **For verification**: Generated chain specification files
diff --git a/docs/genesis/construction.md b/docs/genesis/construction.md
index 4b5712878..95533b16c 100644
--- a/docs/genesis/construction.md
+++ b/docs/genesis/construction.md
@@ -214,6 +214,9 @@ earthly -P +rebuild-all-chainspecs
|----------|-------------|
| `CFG_PRESET` | Network preset (e.g., `qanet`, `preview`, `devnet`) |
| `DB_SYNC_POSTGRES_CONNECTION_STRING` | PostgreSQL connection to Cardano db-sync |
+| `DB_SYNC_TX_INPUT_MODE` | db-sync transaction-input layout: `auto`, `tx_in`, or `consumed` |
+| `DB_SYNC_ADDRESS_MODE` | db-sync address layout: `inline` or `address_table` |
+| `DB_SYNC_SCHEMA_MODE` | Schema handling: `apply`, read-only `verify`, or `skip` |
| `CARDANO_SECURITY_PARAMETER` | Cardano security parameter (default from pc-chain-config.json) |
| `ALLOW_NON_SSL` | Allow non-SSL database connections (dev only) |
@@ -262,6 +265,8 @@ The `genesis-construction.sh` script provides an interactive wizard for genesis
2. **Access to Cardano db-sync database**:
- Local: `postgres://postgres:postgres@localhost:5432/cexplorer`
- Or a remote db-sync instance
+ - Configure a supported layout and complete history as described in
+ [Cardano db-sync compatibility](../configuration-guide.md#cardano-db-sync-compatibility)
3. **Cardano block hash** (tip) for querying smart contract state
@@ -306,7 +311,7 @@ Generates configuration files from Cardano smart contract state:
midnight-node generate-genesis-config --cardano-tip
```
-**Note:** On the first run against a DB Sync database, the `cnight-config.json` generation automatically creates required PostgreSQL indexes. This can take up to ~4 hours on mainnet depending on disk speed and available memory. Subsequent runs reuse existing indexes and are much faster.
+**Note:** With the default `DB_SYNC_SCHEMA_MODE=apply`, the first run against a DB Sync database creates required PostgreSQL indexes and applies recommended autovacuum settings. Index creation can take up to ~4 hours on mainnet depending on disk speed and available memory. Subsequent runs reuse compatible indexes. For a read-only connection, a DBA must create the required indexes first and the command must run with `DB_SYNC_SCHEMA_MODE=verify`; see [Cardano db-sync compatibility](../configuration-guide.md#cardano-db-sync-compatibility). The schema checks do not prove that transaction-input history is complete.
**Output files:**
- `res//cnight-config.json`
diff --git a/docs/genesis/verification.md b/docs/genesis/verification.md
index e5120afea..888f014e1 100644
--- a/docs/genesis/verification.md
+++ b/docs/genesis/verification.md
@@ -240,8 +240,15 @@ Outputs status markers:
|----------|-------------|
| `CFG_PRESET` | Network preset (e.g., `qanet`, `preview`, `devnet`) |
| `DB_SYNC_POSTGRES_CONNECTION_STRING` | PostgreSQL connection to Cardano db-sync |
+| `DB_SYNC_TX_INPUT_MODE` | Step 1 regeneration only: db-sync transaction-input layout (`auto`, `tx_in`, or `consumed`) |
+| `DB_SYNC_ADDRESS_MODE` | Step 1 regeneration only: db-sync address layout (`inline` or `address_table`) |
+| `DB_SYNC_SCHEMA_MODE` | Step 1 regeneration only: schema handling (`apply`, read-only `verify`, or `skip`) |
| `ALLOW_NON_SSL` | Allow non-SSL database connections (dev only) |
+The layout and schema variables are consumed by the `generate-*-genesis` commands used in Step
+1. Verification-only commands do not resolve those settings or manage indexes; their database
+queries require only read access to the tables they inspect.
+
## Input Files
### Required for Verification
@@ -290,6 +297,11 @@ For a guided verification experience, use the interactive shell script:
2. **Access to Cardano db-sync database**:
- Local: `postgres://postgres:postgres@localhost:5432/cexplorer`
- Or a remote db-sync instance
+ - Verification-only commands need only a read-only login with `SELECT` access.
+ - Step 1 of the interactive tool also regenerates genesis files. For that step, configure the
+ same explicit layout modes used during construction. With a read-only login, pre-create the
+ required indexes and use `DB_SYNC_SCHEMA_MODE=verify`; see [Cardano db-sync
+ compatibility](../configuration-guide.md#cardano-db-sync-compatibility).
3. **Generated chain specification** and config files for the network
diff --git a/docs/tests/how-to-test-node.md b/docs/tests/how-to-test-node.md
index 0a5eb6e66..c20f796a3 100644
--- a/docs/tests/how-to-test-node.md
+++ b/docs/tests/how-to-test-node.md
@@ -11,7 +11,7 @@ A practical guide for SDETs working on `midnight-node`.
- A **Substrate-based** blockchain that operates as a **Cardano Partner Chain**.
- Privacy-preserving: uses **zero-knowledge proofs** for shielded transactions.
- Consensus: **AURA** (6 s block time) + **GRANDPA** (finality) + **BEEFY** (bridge security).
-- Reads from Cardano (mainchain) via **db-sync** through the **partner-chains** follower — the node consumes Cardano data but never writes back.
+- Reads from Cardano (mainchain) via **db-sync** through the **partner-chains** follower — the node never changes indexed Cardano rows. The default `db_sync_schema_mode=apply` can create runtime indexes, and cNight genesis commands can also tune table autovacuum settings; use `verify` with an operator-managed read-only database role.
### 1.2 Layout (the parts you'll touch most)
@@ -677,4 +677,3 @@ Comment on the PR to trigger:
---
-
diff --git a/node/Cargo.toml b/node/Cargo.toml
index 049189022..087d26a5b 100644
--- a/node/Cargo.toml
+++ b/node/Cargo.toml
@@ -125,6 +125,7 @@ sp-crypto-hashing.workspace = true
sp-sidechain.workspace = true
sp-session-validator-management.workspace = true
partner-chains-db-sync-data-sources = { workspace = true, features = ["block-source", "candidate-source", "mc-hash", "sidechain-rpc", "bridge"] }
+db-sync-sqlx.workspace = true
partner-chains-mock-data-sources = { workspace = true, features = ["block-source", "candidate-source", "mc-hash", "sidechain-rpc", "bridge"] }
pallet-session-validator-management-rpc.workspace = true
pallet-sidechain-rpc.workspace = true
diff --git a/node/src/cfg/midnight_cfg/mod.rs b/node/src/cfg/midnight_cfg/mod.rs
index b1c5be362..57f1dcce5 100644
--- a/node/src/cfg/midnight_cfg/mod.rs
+++ b/node/src/cfg/midnight_cfg/mod.rs
@@ -11,6 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+use db_sync_sqlx::{DbSyncAddressMode, DbSyncSchemaMode, DbSyncTxInputMode};
use documented::{Documented, DocumentedFields as _};
use serde::{Deserialize, Serialize};
use serde_valid::{Validate, validation};
@@ -84,6 +85,21 @@ pub struct MidnightCfg {
#[doc_tag(secret)]
pub db_sync_postgres_connection_string: Option,
+ /// Transaction-input representation used by db-sync queries: auto, tx_in, or consumed.
+ /// Explicit configuration is recommended for production deployments.
+ #[serde(default)]
+ pub db_sync_tx_input_mode: DbSyncTxInputMode,
+
+ /// Address representation used by db-sync queries: inline or address_table.
+ #[serde(default)]
+ pub db_sync_address_mode: DbSyncAddressMode,
+
+ /// Database schema management: apply changes, verify them read-only, or skip both.
+ /// This controls all Midnight-issued CREATE INDEX and ALTER TABLE statements.
+ /// Query-layout validation is always performed.
+ #[serde(default)]
+ pub db_sync_schema_mode: DbSyncSchemaMode,
+
/// see partner-chains CandidateDataSourceCacheConfig and DbSyncBlockDataSourceConfig
pub cardano_security_parameter: Option,
@@ -272,4 +288,12 @@ mod tests {
"validation error should name the offending parameter, got: {err}"
);
}
+
+ #[test]
+ fn db_sync_configuration_defaults_preserve_existing_behaviour() {
+ let cfg = MidnightCfg::default();
+ assert_eq!(cfg.db_sync_tx_input_mode, DbSyncTxInputMode::Auto);
+ assert_eq!(cfg.db_sync_address_mode, DbSyncAddressMode::Inline);
+ assert_eq!(cfg.db_sync_schema_mode, DbSyncSchemaMode::Apply);
+ }
}
diff --git a/node/src/command.rs b/node/src/command.rs
index a52ff4a2e..027346528 100644
--- a/node/src/command.rs
+++ b/node/src/command.rs
@@ -682,6 +682,12 @@ fn run_subcommand(subcommand: Subcommand, cfg: Cfg) -> sc_cli::Result<()> {
let pool =
crate::main_chain_follower::create_ics_genesis_pool(cfg.midnight_cfg.clone())
.await?;
+ let db_sync_config = crate::main_chain_follower::resolve_db_sync_query_config(
+ &pool,
+ &cfg.midnight_cfg,
+ )
+ .await
+ .map_err(|error| sc_cli::Error::Application(Box::new(error)))?;
let ics_addresses_str = std::fs::read_to_string(&ics_addresses)?;
let addresses: IcsAddresses =
@@ -690,11 +696,15 @@ fn run_subcommand(subcommand: Subcommand, cfg: Cfg) -> sc_cli::Result<()> {
"failed to read ICS addresses file as json: {e:?}"
))
})?;
- generate_ics_genesis(addresses, &pool, cmd.cardano_tip.clone(), &output)
- .await
- .map_err(|e| {
- sc_cli::Error::Input(format!("ICS genesis generation failed: {e}"))
- })?;
+ generate_ics_genesis(
+ addresses,
+ &pool,
+ cmd.cardano_tip.clone(),
+ db_sync_config,
+ &output,
+ )
+ .await
+ .map_err(|e| sc_cli::Error::Input(format!("ICS genesis generation failed: {e}")))?;
Ok(())
})
@@ -717,6 +727,12 @@ fn run_subcommand(subcommand: Subcommand, cfg: Cfg) -> sc_cli::Result<()> {
let pool =
crate::main_chain_follower::create_ics_genesis_pool(cfg.midnight_cfg.clone())
.await?;
+ let db_sync_config = crate::main_chain_follower::resolve_db_sync_query_config(
+ &pool,
+ &cfg.midnight_cfg,
+ )
+ .await
+ .map_err(|error| sc_cli::Error::Application(Box::new(error)))?;
let reserve_addresses_str = std::fs::read_to_string(&reserve_addresses)?;
let addresses: ReserveAddresses = serde_json::from_str(&reserve_addresses_str)
@@ -725,11 +741,17 @@ fn run_subcommand(subcommand: Subcommand, cfg: Cfg) -> sc_cli::Result<()> {
"failed to read reserve addresses file as json: {e:?}"
))
})?;
- generate_reserve_genesis(addresses, &pool, cmd.cardano_tip.clone(), &output)
- .await
- .map_err(|e| {
- sc_cli::Error::Input(format!("Reserve genesis generation failed: {e}"))
- })?;
+ generate_reserve_genesis(
+ addresses,
+ &pool,
+ cmd.cardano_tip.clone(),
+ db_sync_config,
+ &output,
+ )
+ .await
+ .map_err(|e| {
+ sc_cli::Error::Input(format!("Reserve genesis generation failed: {e}"))
+ })?;
Ok(())
})
@@ -1054,6 +1076,12 @@ fn run_subcommand(subcommand: Subcommand, cfg: Cfg) -> sc_cli::Result<()> {
let reserve_pool =
crate::main_chain_follower::create_ics_genesis_pool(cfg.midnight_cfg.clone())
.await?;
+ let db_sync_config = crate::main_chain_follower::resolve_db_sync_query_config(
+ &reserve_pool,
+ &cfg.midnight_cfg,
+ )
+ .await
+ .map_err(|error| sc_cli::Error::Application(Box::new(error)))?;
let reserve_addresses_str = std::fs::read_to_string(&reserve_addresses)?;
let reserve_addresses_parsed: ReserveAddresses =
@@ -1067,6 +1095,7 @@ fn run_subcommand(subcommand: Subcommand, cfg: Cfg) -> sc_cli::Result<()> {
reserve_addresses_parsed,
&reserve_pool,
cmd.cardano_tip.clone(),
+ db_sync_config,
&reserve_output,
)
.await
diff --git a/node/src/genesis/creation/ics_genesis.rs b/node/src/genesis/creation/ics_genesis.rs
index 96650a1d4..e9c77c2ff 100644
--- a/node/src/genesis/creation/ics_genesis.rs
+++ b/node/src/genesis/creation/ics_genesis.rs
@@ -21,6 +21,9 @@
//! allocated to the Midnight treasury at genesis.
// Re-export IcsAddresses for use in command.rs
+use db_sync_sqlx::{
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
pub use midnight_primitives_ics_observation::IcsAddresses;
use midnight_primitives_ics_observation::{IcsConfig, IcsUtxo};
use sidechain_domain::McBlockHash;
@@ -54,6 +57,7 @@ async fn query_ics_utxos(
policy_id: &str,
asset_name: &str,
at_block: &McBlockHash,
+ db_sync_config: ResolvedDbSyncQueryConfig,
) -> Result, IcsGenesisError> {
let block_hash_hex = hex::encode(at_block.0);
@@ -79,9 +83,8 @@ async fn query_ics_utxos(
// 2. Joining with ma_tx_out/multi_asset to filter only outputs containing
// the specific cNIGHT token (identified by policy_id and asset_name)
// 3. Filtering to outputs created at or before the reference block
- // 4. Excluding spent outputs using NOT EXISTS - a UTxO is spent if there's
- // a tx_in referencing it (by tx_id and output index) in a block at or
- // before the reference block
+ // 4. Excluding outputs spent at or before the reference block, using either
+ // tx_in or tx_out.consumed_by_tx_id according to the configured layout
// 5. Ordering deterministically by block number, tx index, and output index
//
// Parameters:
@@ -89,39 +92,62 @@ async fn query_ics_utxos(
// $2 - ICS validator address (bech32)
// $3 - cNIGHT policy ID (hex)
// $4 - cNIGHT asset name (hex, usually empty string)
- let utxos: Vec<(String, i16, i64)> = sqlx::query_as::<_, (String, i16, i64)>(
+ let (address_join, address_column) = match db_sync_config.address_mode {
+ ResolvedDbSyncAddressMode::Inline => ("", "txo.address"),
+ ResolvedDbSyncAddressMode::AddressTable => {
+ ("JOIN address txo_address ON txo_address.id = txo.address_id", "txo_address.address")
+ },
+ };
+ let spent_filter = match db_sync_config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => {
+ r#"
+ AND NOT EXISTS (
+ SELECT 1 FROM tx_in ti
+ JOIN tx spend_tx ON spend_tx.id = ti.tx_in_id
+ JOIN block spend_block ON spend_block.id = spend_tx.block_id
+ WHERE ti.tx_out_id = txo.tx_id
+ AND ti.tx_out_index = txo.index
+ AND spend_block.block_no <= ref_block.block_no
+ )"#
+ },
+ ResolvedDbSyncTxInputMode::Consumed => {
+ r#"
+ AND NOT EXISTS (
+ SELECT 1 FROM tx spend_tx
+ JOIN block spend_block ON spend_block.id = spend_tx.block_id
+ WHERE spend_tx.id = txo.consumed_by_tx_id
+ AND spend_block.block_no <= ref_block.block_no
+ )"#
+ },
+ };
+ let sql = format!(
r#"
SELECT
encode(tx.hash, 'hex') as tx_hash,
txo.index as output_index,
ma.quantity::BIGINT as amount
FROM tx_out txo
+ {address_join}
JOIN tx ON tx.id = txo.tx_id
JOIN block b ON b.id = tx.block_id
JOIN block ref_block ON ref_block.hash = decode($1, 'hex')
JOIN ma_tx_out ma ON ma.tx_out_id = txo.id
JOIN multi_asset asset ON asset.id = ma.ident
- WHERE txo.address = $2
- AND encode(asset.policy, 'hex') = $3
- AND encode(asset.name, 'hex') = $4
+ WHERE {address_column} = $2
+ AND asset.policy = decode($3, 'hex')
+ AND asset.name = decode($4, 'hex')
AND b.block_no <= ref_block.block_no
- AND NOT EXISTS (
- SELECT 1 FROM tx_in ti
- JOIN tx spend_tx ON spend_tx.id = ti.tx_in_id
- JOIN block spend_block ON spend_block.id = spend_tx.block_id
- WHERE ti.tx_out_id = txo.tx_id
- AND ti.tx_out_index = txo.index
- AND spend_block.block_no <= ref_block.block_no
- )
+ {spent_filter}
ORDER BY b.block_no, tx.block_index, txo.index
- "#,
- )
- .bind(&block_hash_hex)
- .bind(ics_address)
- .bind(policy_id)
- .bind(asset_name)
- .fetch_all(pool)
- .await?;
+ "#
+ );
+ let utxos: Vec<(String, i16, i64)> = sqlx::query_as::<_, (String, i16, i64)>(&sql)
+ .bind(&block_hash_hex)
+ .bind(ics_address)
+ .bind(policy_id)
+ .bind(asset_name)
+ .fetch_all(pool)
+ .await?;
Ok(utxos
.into_iter()
@@ -138,6 +164,7 @@ pub async fn generate_ics_genesis(
addresses: IcsAddresses,
pool: &PgPool,
cardano_tip: McBlockHash,
+ db_sync_config: ResolvedDbSyncQueryConfig,
output_path: impl AsRef,
) -> Result<(), IcsGenesisError> {
let output_path = output_path.as_ref();
@@ -161,6 +188,7 @@ pub async fn generate_ics_genesis(
&policy_id_hex,
&asset_name_hex,
&cardano_tip,
+ db_sync_config,
)
.await?;
diff --git a/node/src/genesis/creation/reserve_genesis.rs b/node/src/genesis/creation/reserve_genesis.rs
index 9b02baa59..a9660767d 100644
--- a/node/src/genesis/creation/reserve_genesis.rs
+++ b/node/src/genesis/creation/reserve_genesis.rs
@@ -17,6 +17,9 @@
//! for cNIGHT tokens locked at the reserve contract address.
// Re-export ReserveAddresses for use in command.rs
+use db_sync_sqlx::{
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
pub use midnight_primitives_reserve_observation::ReserveAddresses;
use midnight_primitives_reserve_observation::{ReserveConfig, ReserveUtxo};
use sidechain_domain::McBlockHash;
@@ -50,6 +53,7 @@ async fn query_reserve_utxos(
policy_id: &str,
asset_name: &str,
at_block: &McBlockHash,
+ db_sync_config: ResolvedDbSyncQueryConfig,
) -> Result, ReserveGenesisError> {
let block_hash_hex = hex::encode(at_block.0);
@@ -75,43 +79,65 @@ async fn query_reserve_utxos(
// 2. Joining with ma_tx_out/multi_asset to filter only outputs containing
// the specific cNIGHT token (identified by policy_id and asset_name)
// 3. Filtering to outputs created at or before the reference block
- // 4. Excluding spent outputs using NOT EXISTS - a UTxO is spent if there's
- // a tx_in referencing it (by tx_id and output index) in a block at or
- // before the reference block
+ // 4. Excluding outputs spent at or before the reference block, using either
+ // tx_in or tx_out.consumed_by_tx_id according to the configured layout
// 5. Ordering deterministically by block number, tx index, and output index
- let utxos: Vec<(String, i16, i64)> = sqlx::query_as::<_, (String, i16, i64)>(
+ let (address_join, address_column) = match db_sync_config.address_mode {
+ ResolvedDbSyncAddressMode::Inline => ("", "txo.address"),
+ ResolvedDbSyncAddressMode::AddressTable => {
+ ("JOIN address txo_address ON txo_address.id = txo.address_id", "txo_address.address")
+ },
+ };
+ let spent_filter = match db_sync_config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => {
+ r#"
+ AND NOT EXISTS (
+ SELECT 1 FROM tx_in ti
+ JOIN tx spend_tx ON spend_tx.id = ti.tx_in_id
+ JOIN block spend_block ON spend_block.id = spend_tx.block_id
+ WHERE ti.tx_out_id = txo.tx_id
+ AND ti.tx_out_index = txo.index
+ AND spend_block.block_no <= ref_block.block_no
+ )"#
+ },
+ ResolvedDbSyncTxInputMode::Consumed => {
+ r#"
+ AND NOT EXISTS (
+ SELECT 1 FROM tx spend_tx
+ JOIN block spend_block ON spend_block.id = spend_tx.block_id
+ WHERE spend_tx.id = txo.consumed_by_tx_id
+ AND spend_block.block_no <= ref_block.block_no
+ )"#
+ },
+ };
+ let sql = format!(
r#"
SELECT
encode(tx.hash, 'hex') as tx_hash,
txo.index as output_index,
ma.quantity::BIGINT as amount
FROM tx_out txo
+ {address_join}
JOIN tx ON tx.id = txo.tx_id
JOIN block b ON b.id = tx.block_id
JOIN block ref_block ON ref_block.hash = decode($1, 'hex')
JOIN ma_tx_out ma ON ma.tx_out_id = txo.id
JOIN multi_asset asset ON asset.id = ma.ident
- WHERE txo.address = $2
- AND encode(asset.policy, 'hex') = $3
- AND encode(asset.name, 'hex') = $4
+ WHERE {address_column} = $2
+ AND asset.policy = decode($3, 'hex')
+ AND asset.name = decode($4, 'hex')
AND b.block_no <= ref_block.block_no
- AND NOT EXISTS (
- SELECT 1 FROM tx_in ti
- JOIN tx spend_tx ON spend_tx.id = ti.tx_in_id
- JOIN block spend_block ON spend_block.id = spend_tx.block_id
- WHERE ti.tx_out_id = txo.tx_id
- AND ti.tx_out_index = txo.index
- AND spend_block.block_no <= ref_block.block_no
- )
+ {spent_filter}
ORDER BY b.block_no, tx.block_index, txo.index
- "#,
- )
- .bind(&block_hash_hex)
- .bind(reserve_address)
- .bind(policy_id)
- .bind(asset_name)
- .fetch_all(pool)
- .await?;
+ "#
+ );
+ let utxos: Vec<(String, i16, i64)> = sqlx::query_as::<_, (String, i16, i64)>(&sql)
+ .bind(&block_hash_hex)
+ .bind(reserve_address)
+ .bind(policy_id)
+ .bind(asset_name)
+ .fetch_all(pool)
+ .await?;
Ok(utxos
.into_iter()
@@ -128,6 +154,7 @@ pub async fn generate_reserve_genesis(
addresses: ReserveAddresses,
pool: &PgPool,
cardano_tip: McBlockHash,
+ db_sync_config: ResolvedDbSyncQueryConfig,
output_path: impl AsRef,
) -> Result<(), ReserveGenesisError> {
let output_path = output_path.as_ref();
@@ -150,6 +177,7 @@ pub async fn generate_reserve_genesis(
&policy_id_hex,
&asset_name_hex,
&cardano_tip,
+ db_sync_config,
)
.await?;
diff --git a/node/src/main_chain_follower.rs b/node/src/main_chain_follower.rs
index 3cb8e1ec7..1ccf6b005 100644
--- a/node/src/main_chain_follower.rs
+++ b/node/src/main_chain_follower.rs
@@ -12,6 +12,9 @@
// limitations under the License.
use authority_selection_inherents::AuthoritySelectionDataSource;
+use db_sync_sqlx::{
+ DbSyncQueryConfig, ResolvedDbSyncQueryConfig, candidate_index_specs, manage_indexes,
+};
use midnight_primitives_mainchain_follower::CandidatesDataSourceImpl;
use midnight_primitives_mainchain_follower::MidnightDataSourceMetrics;
use pallet_sidechain_rpc::SidechainRpcDataSource;
@@ -26,7 +29,6 @@ use partner_chains_mock_data_sources::{
use sc_service::error::Error as ServiceError;
use sidechain_mc_hash::McHashDataSource;
use sp_partner_chains_bridge::TokenBridgeDataSource;
-use sqlx::{Pool, Postgres};
use super::cfg::midnight_cfg::MidnightCfg;
use midnight_primitives::BridgeRecipient;
@@ -113,41 +115,6 @@ pub async fn create_mock_data_sources(
})
}
-pub async fn create_index_if_not_exists(pool: &Pool) {
- // Check if index already exists
- let index_exists: bool = sqlx::query_scalar(
- r#"
- SELECT EXISTS (
- SELECT 1 FROM pg_indexes
- WHERE indexname = 'idx_multi_asset_policy_name_hex'
- )
- "#,
- )
- .fetch_one(pool)
- .await
- .unwrap_or(false);
-
- if index_exists {
- log::info!("Index idx_multi_asset_policy_name_hex already exists, skipping creation.");
- } else {
- log::info!("Creating idx_multi_asset_policy_name_hex index. This may take a while.");
- let index_query_result = sqlx::query(
- r#"
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_multi_asset_policy_name_hex
- ON multi_asset ((encode(policy, 'hex')), (encode(name, 'hex')));
- "#,
- )
- .execute(pool)
- .await;
-
- if let Err(e) = index_query_result {
- log::warn!(
- "Warning: failed to create idx_multi_asset_policy_name_hex index (is your db-sync readonly?). Performance may be degraded: {e}"
- );
- }
- }
-}
-
const DB_SYNC_STARTUP_PROBE_WARN_THRESHOLD: StdDuration = StdDuration::from_millis(500);
async fn log_db_sync_startup_probe(block_data_source: &BlockDataSourceImpl) {
@@ -225,6 +192,21 @@ const BRIDGE_POOL_CFG: DbPoolCfg =
const ICS_POOL_CFG: DbPoolCfg =
DbPoolCfg { acquire_timeout: std::time::Duration::from_secs(30), max_connections: 5 };
+fn db_sync_query_config(cfg: &MidnightCfg) -> DbSyncQueryConfig {
+ DbSyncQueryConfig {
+ tx_input_mode: cfg.db_sync_tx_input_mode,
+ address_mode: cfg.db_sync_address_mode,
+ }
+}
+
+/// Resolve and validate the configured db-sync query layout against a connected database.
+pub async fn resolve_db_sync_query_config(
+ pool: &sqlx::PgPool,
+ cfg: &MidnightCfg,
+) -> Result {
+ db_sync_query_config(cfg).resolve(pool).await
+}
+
fn warn_deprecated_allow_non_ssl(cfg: &MidnightCfg) {
if cfg.allow_non_ssl {
log::warn!(
@@ -240,8 +222,9 @@ pub async fn create_cached_data_sources(
midnight_metrics_opt: Option,
) -> Result> {
warn_deprecated_allow_non_ssl(&cfg);
- let postgres_uri = &cfg
+ let postgres_uri = cfg
.db_sync_postgres_connection_string
+ .as_deref()
.ok_or(missing("db_sync_postgres_connection_string"))?;
let db_sync_block_data_source_config = DbSyncBlockDataSourceConfig {
@@ -274,16 +257,21 @@ pub async fn create_cached_data_sources(
e
})?;
- // All these pools are connections to the same database, so we can use any pool to create the index
- create_index_if_not_exists(&candidates_pool).await;
+ // All data-source pools connect to the same db-sync database. Resolve the layout once so
+ // every query family uses the same validated representation.
+ let db_sync_config = resolve_db_sync_query_config(&candidates_pool, &cfg).await?;
+ manage_indexes(
+ &candidates_pool,
+ cfg.db_sync_schema_mode,
+ &candidate_index_specs(db_sync_config),
+ )
+ .await?;
- let candidates_data_source =
- CandidatesDataSourceImpl::new(candidates_pool, midnight_metrics_opt.clone())
- .await
- .map_err(|e| {
- log::warn!("Failed to initialise candidates data source: {e}");
- e
- })?;
+ let candidates_data_source = CandidatesDataSourceImpl::new_with_db_sync_config(
+ candidates_pool,
+ midnight_metrics_opt.clone(),
+ db_sync_config,
+ );
let candidates_data_source_cached =
candidates_data_source.cached(CANDIDATES_FOR_EPOCH_CACHE_SIZE).map_err(|e| {
log::warn!("Failed to create candidates data source cache: {e}");
@@ -329,10 +317,11 @@ pub async fn create_cached_data_sources(
log::warn!("Failed to connect to database for cnight_observation data source: {e}");
e
})?;
- let cnight_observation = MidnightCNightObservationDataSourceImpl::new(
+ let cnight_observation = MidnightCNightObservationDataSourceImpl::new_with_db_sync_config(
cnight_observation_pool,
midnight_metrics_opt.clone(),
1000,
+ db_sync_config,
);
let federated_authority_observation_pool = get_connection(
@@ -347,11 +336,13 @@ pub async fn create_cached_data_sources(
);
e
})?;
- let federated_authority_observation = FederatedAuthorityObservationDataSourceImpl::new(
- federated_authority_observation_pool,
- midnight_metrics_opt,
- 1000,
- );
+ let federated_authority_observation =
+ FederatedAuthorityObservationDataSourceImpl::new_with_db_sync_config(
+ federated_authority_observation_pool,
+ midnight_metrics_opt,
+ 1000,
+ db_sync_config,
+ );
let bridge_pool = get_connection(postgres_uri, BRIDGE_POOL_CFG, cfg.ssl_root_cert.as_deref())
.await
@@ -360,11 +351,12 @@ pub async fn create_cached_data_sources(
e
})?;
- let bridge = CachedTokenBridgeDataSourceImpl::new(
+ let bridge = CachedTokenBridgeDataSourceImpl::new_with_resolved_db_sync_config(
bridge_pool,
mc_metrics_opt,
sidechain_block_data_source,
BRIDGE_TRANSFER_CACHE_LOOKAHEAD,
+ db_sync_config,
);
Ok(DataSources {
@@ -384,18 +376,28 @@ pub async fn create_cnight_observation_data_source(
) -> Result, Box> {
warn_deprecated_allow_non_ssl(&cfg);
let pool = get_connection(
- &cfg.db_sync_postgres_connection_string
+ cfg.db_sync_postgres_connection_string
+ .as_deref()
.ok_or(missing("db_sync_postgres_connection_string"))?,
CNIGHT_OBSERVATION_POOL_CFG,
cfg.ssl_root_cert.as_deref(),
)
.await?;
- midnight_primitives_mainchain_follower::db::create_cnight_observation_indexes(&pool).await?;
- midnight_primitives_mainchain_follower::db::apply_cnight_observation_autovacuum_tuning(&pool)
- .await?;
+ let db_sync_config = resolve_db_sync_query_config(&pool, &cfg).await?;
+ midnight_primitives_mainchain_follower::db::manage_cnight_observation_schema(
+ &pool,
+ db_sync_config,
+ cfg.db_sync_schema_mode,
+ )
+ .await?;
- Ok(Arc::new(MidnightCNightObservationDataSourceImpl::new(pool, metrics_opt, 1000)))
+ Ok(Arc::new(MidnightCNightObservationDataSourceImpl::new_with_db_sync_config(
+ pool,
+ metrics_opt,
+ 1000,
+ db_sync_config,
+ )))
}
pub async fn create_federated_authority_observation_data_source(
@@ -405,14 +407,21 @@ pub async fn create_federated_authority_observation_data_source(
{
warn_deprecated_allow_non_ssl(&cfg);
let pool = get_connection(
- &cfg.db_sync_postgres_connection_string
+ cfg.db_sync_postgres_connection_string
+ .as_deref()
.ok_or(missing("db_sync_postgres_connection_string"))?,
FEDERATED_AUTHORITY_OBSERVATION_POOL_CFG,
cfg.ssl_root_cert.as_deref(),
)
.await?;
- Ok(Arc::new(FederatedAuthorityObservationDataSourceImpl::new(pool, metrics_opt, 1000)))
+ let db_sync_config = resolve_db_sync_query_config(&pool, &cfg).await?;
+ Ok(Arc::new(FederatedAuthorityObservationDataSourceImpl::new_with_db_sync_config(
+ pool,
+ metrics_opt,
+ 1000,
+ db_sync_config,
+ )))
}
pub async fn create_authority_selection_data_source(
@@ -436,14 +445,21 @@ pub async fn create_authority_selection_data_source_with_pool(
> {
warn_deprecated_allow_non_ssl(&cfg);
let pool = get_connection(
- &cfg.db_sync_postgres_connection_string
+ cfg.db_sync_postgres_connection_string
+ .as_deref()
.ok_or(missing("db_sync_postgres_connection_string"))?,
CANDIDATES_POOL_CFG,
cfg.ssl_root_cert.as_deref(),
)
.await?;
- let candidates_data_source = CandidatesDataSourceImpl::new(pool.clone(), metrics_opt).await?;
+ let db_sync_config = resolve_db_sync_query_config(&pool, &cfg).await?;
+ manage_indexes(&pool, cfg.db_sync_schema_mode, &candidate_index_specs(db_sync_config)).await?;
+ let candidates_data_source = CandidatesDataSourceImpl::new_with_db_sync_config(
+ pool.clone(),
+ metrics_opt,
+ db_sync_config,
+ );
let candidates_data_source_cached =
candidates_data_source.cached(CANDIDATES_FOR_EPOCH_CACHE_SIZE)?;
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/bridge/cache.rs b/partner-chains/toolkit/data-sources/db-sync/src/bridge/cache.rs
index 18acd4f7a..a5cd82fee 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/bridge/cache.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/bridge/cache.rs
@@ -164,9 +164,47 @@ impl CachedTokenBridgeDataSourceImpl {
metrics_opt: Option,
blocks: Arc,
cache_lookahead: u32,
+ ) -> Self {
+ Self::new_with_db_sync_config(
+ pool,
+ metrics_opt,
+ blocks,
+ cache_lookahead,
+ DbSyncQueryConfig::default(),
+ )
+ }
+
+ /// Creates a cached token bridge data source for the selected db-sync query layout.
+ pub fn new_with_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ blocks: Arc,
+ cache_lookahead: u32,
+ query_config: DbSyncQueryConfig,
+ ) -> Self {
+ Self {
+ db_sync_config: DbSyncConfigurationProvider::new_with_config(
+ pool.clone(),
+ query_config,
+ ),
+ pool,
+ metrics_opt,
+ blocks,
+ cache: Arc::new(Mutex::new(TokenUtxoCache::new())),
+ cache_lookahead,
+ }
+ }
+
+ /// Creates a cached token bridge data source with an already validated db-sync layout.
+ pub fn new_with_resolved_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ blocks: Arc,
+ cache_lookahead: u32,
+ config: ResolvedDbSyncQueryConfig,
) -> Self {
Self {
- db_sync_config: DbSyncConfigurationProvider::new(pool.clone()),
+ db_sync_config: DbSyncConfigurationProvider::from_resolved(pool.clone(), config),
pool,
metrics_opt,
blocks,
@@ -208,7 +246,7 @@ impl CachedTokenBridgeDataSourceImpl {
min(to_block.saturating_add(self.cache_lookahead), latest_block);
let utxos = get_bridge_txs(
- self.db_sync_config.get_tx_in_config().await?,
+ self.db_sync_config.get_config().await?,
&self.pool,
&main_chain_scripts.illiquid_circulation_supply_validator_address.clone().into(),
&main_chain_scripts.reserve_validator_address.clone().into(),
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/bridge/mod.rs b/partner-chains/toolkit/data-sources/db-sync/src/bridge/mod.rs
index 8d354e4ae..436f86fc7 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/bridge/mod.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/bridge/mod.rs
@@ -69,7 +69,36 @@ pub struct TokenBridgeDataSourceImpl {
impl TokenBridgeDataSourceImpl {
/// Crates a new token bridge data source
pub fn new(pool: PgPool, metrics_opt: Option) -> Self {
- Self { db_sync_config: DbSyncConfigurationProvider::new(pool.clone()), pool, metrics_opt }
+ Self::new_with_db_sync_config(pool, metrics_opt, DbSyncQueryConfig::default())
+ }
+
+ /// Creates a token bridge data source for the selected db-sync query layout.
+ pub fn new_with_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ query_config: DbSyncQueryConfig,
+ ) -> Self {
+ Self {
+ db_sync_config: DbSyncConfigurationProvider::new_with_config(
+ pool.clone(),
+ query_config,
+ ),
+ pool,
+ metrics_opt,
+ }
+ }
+
+ /// Creates a token bridge data source with an already validated db-sync layout.
+ pub fn new_with_resolved_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ config: ResolvedDbSyncQueryConfig,
+ ) -> Self {
+ Self {
+ db_sync_config: DbSyncConfigurationProvider::from_resolved(pool.clone(), config),
+ pool,
+ metrics_opt,
+ }
}
}
@@ -116,7 +145,7 @@ observed_async_trait!(
};
let txs = get_bridge_txs(
- self.db_sync_config.get_tx_in_config().await?,
+ self.db_sync_config.get_config().await?,
&self.pool,
&main_chain_scripts.illiquid_circulation_supply_validator_address.into(),
&main_chain_scripts.reserve_validator_address.into(),
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/bridge/tests.rs b/partner-chains/toolkit/data-sources/db-sync/src/bridge/tests.rs
index d9c1eb77b..35f4696cf 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/bridge/tests.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/bridge/tests.rs
@@ -1,7 +1,9 @@
extern crate alloc;
use crate::bridge::cache::CachedTokenBridgeDataSourceImpl;
+use crate::tests::normalize_tx_out_addresses;
use crate::{BlockDataSourceImpl, DbSyncBlockDataSourceConfig, TokenBridgeDataSourceImpl};
+use db_sync_sqlx::{DbSyncAddressMode, DbSyncQueryConfig, DbSyncTxInputMode};
use hex_literal::hex;
use sidechain_domain::byte_string::ByteString;
use sidechain_domain::mainchain_epoch::{Duration, MainchainEpochConfig, Timestamp};
@@ -236,6 +238,47 @@ fn create_cached_source(pool: PgPool) -> CachedTokenBridgeDataSourceImpl {
CachedTokenBridgeDataSourceImpl::new(pool, None, blocks, cache_lookahead)
}
+async fn assert_address_table_bridge_flow(pool: PgPool, tx_input_mode: DbSyncTxInputMode) {
+ normalize_tx_out_addresses(&pool).await;
+ let data_source = TokenBridgeDataSourceImpl::new_with_db_sync_config(
+ pool,
+ None,
+ DbSyncQueryConfig { tx_input_mode, address_mode: DbSyncAddressMode::AddressTable },
+ );
+
+ let (transfers, new_checkpoint) = data_source
+ .get_transfers(
+ main_chain_scripts(),
+ BridgeDataCheckpoint::Tx(init_ics_tx_hash()),
+ 5,
+ block_4_hash(),
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(
+ transfers,
+ vec![
+ reserve_transfer(),
+ user_transfer_1(),
+ user_transfer_2(),
+ invalid_transfer_1(),
+ invalid_transfer_2(),
+ ]
+ );
+ assert_eq!(new_checkpoint, BridgeDataCheckpoint::Block(McBlockNumber(4)));
+}
+
+#[sqlx::test(migrations = "./testdata/bridge/migrations-tx-in-enabled")]
+async fn address_table_bridge_flow_tx_in(pool: PgPool) {
+ assert_address_table_bridge_flow(pool, DbSyncTxInputMode::TxIn).await;
+}
+
+#[sqlx::test(migrations = "./testdata/bridge/migrations-tx-in-consumed")]
+async fn address_table_bridge_flow_consumed(pool: PgPool) {
+ assert_address_table_bridge_flow(pool, DbSyncTxInputMode::Consumed).await;
+}
+
with_migration_versions_and_caching! {
async fn gets_transfers_from_init_to_block_2(data_source: &dyn TokenBridgeDataSource) {
let data_checkpoint = BridgeDataCheckpoint::Tx(init_ics_tx_hash());
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/candidates/mod.rs b/partner-chains/toolkit/data-sources/db-sync/src/candidates/mod.rs
index 86f99f1a0..e4e30ada5 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/candidates/mod.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/candidates/mod.rs
@@ -6,6 +6,7 @@ use crate::db_model::{
};
use crate::metrics::{McFollowerMetrics, observed_async_trait};
use authority_selection_inherents::*;
+use db_sync_sqlx::{DbSyncQueryConfig, DbSyncSchemaMode, candidate_index_specs, manage_indexes};
use itertools::Itertools;
use log::error;
use partner_chains_plutus_data::{
@@ -126,12 +127,28 @@ impl CandidatesDataSourceImpl {
pool: PgPool,
metrics_opt: Option,
) -> Result> {
- db_model::create_idx_ma_tx_out_ident(&pool).await?;
- db_model::create_idx_tx_out_address(&pool).await?;
+ Self::new_with_db_sync_config(
+ pool,
+ metrics_opt,
+ DbSyncQueryConfig::default(),
+ DbSyncSchemaMode::Apply,
+ )
+ .await
+ }
+
+ /// Creates a data source for a selected db-sync layout and schema-management mode.
+ pub async fn new_with_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ query_config: DbSyncQueryConfig,
+ schema_mode: DbSyncSchemaMode,
+ ) -> Result> {
+ let resolved = query_config.resolve(&pool).await?;
+ manage_indexes(&pool, schema_mode, &candidate_index_specs(resolved)).await?;
Ok(Self {
pool: pool.clone(),
metrics_opt,
- db_sync_config: DbSyncConfigurationProvider::new(pool),
+ db_sync_config: DbSyncConfigurationProvider::from_resolved(pool, resolved),
})
}
@@ -165,7 +182,7 @@ impl CandidatesDataSourceImpl {
&self.pool,
&address,
block,
- self.db_sync_config.get_tx_in_config().await?,
+ self.db_sync_config.get_config().await?,
)
.await?
},
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/candidates/tests.rs b/partner-chains/toolkit/data-sources/db-sync/src/candidates/tests.rs
index 5c748c5e2..a611ae9e9 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/candidates/tests.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/candidates/tests.rs
@@ -1,15 +1,20 @@
use crate::candidates::CandidatesDataSourceImpl;
-use crate::db_model::{DbSyncConfigurationProvider, TxInConfiguration, index_exists_unsafe};
+use crate::db_model::{DbSyncConfigurationProvider, index_exists_unsafe};
use crate::metrics::mock::test_metrics;
+use crate::tests::normalize_tx_out_addresses;
use authority_selection_inherents::AuthoritySelectionDataSource;
+use db_sync_sqlx::{
+ DbSyncAddressMode, DbSyncQueryConfig, DbSyncSchemaMode, DbSyncTxInputMode,
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
use hex_literal::hex;
use sidechain_domain::*;
use sqlx::PgPool;
-use std::cell::OnceCell;
use std::str::FromStr;
-use std::sync::Arc;
use tokio_test::assert_err;
+type TxInConfiguration = ResolvedDbSyncTxInputMode;
+
const D_PARAM_POLICY: [u8; 28] = hex!("500000000000000000000000000000000000434845434b504f494e69");
const PERMISSIONED_CANDIDATES_POLICY: [u8; 28] =
hex!("500000000000000000000000000000000000434845434b504f494e19");
@@ -22,7 +27,7 @@ macro_rules! with_migration_versions {
#[sqlx::test(migrations = "./testdata/migrations-tx-in-enabled")]
async fn $name_v1($pool: PgPool) {
- $name($pool, TxInConfiguration::Enabled).await
+ $name($pool, TxInConfiguration::TxIn).await
}
#[sqlx::test(migrations = "./testdata/migrations-tx-in-consumed")]
@@ -175,6 +180,33 @@ with_migration_versions! {
}
}
+async fn assert_address_table_candidate_flow(pool: PgPool, tx_input_mode: DbSyncTxInputMode) {
+ normalize_tx_out_addresses(&pool).await;
+ let source = CandidatesDataSourceImpl::new_with_db_sync_config(
+ pool,
+ None,
+ DbSyncQueryConfig { tx_input_mode, address_mode: DbSyncAddressMode::AddressTable },
+ DbSyncSchemaMode::Apply,
+ )
+ .await
+ .unwrap();
+
+ let mut candidates =
+ source.get_candidates(McEpochNumber(195), candidates_address()).await.unwrap();
+ candidates.sort_by_key(|candidate| candidate.mainchain_pub_key().0);
+ assert_eq!(candidates, vec![leader_candidate_spo_c(), leader_candidate_spo_b()]);
+}
+
+#[sqlx::test(migrations = "./testdata/migrations-tx-in-enabled")]
+async fn test_get_candidates_address_table_tx_in(pool: PgPool) {
+ assert_address_table_candidate_flow(pool, DbSyncTxInputMode::TxIn).await;
+}
+
+#[sqlx::test(migrations = "./testdata/migrations-tx-in-consumed")]
+async fn test_get_candidates_address_table_consumed(pool: PgPool) {
+ assert_address_table_candidate_flow(pool, DbSyncTxInputMode::Consumed).await;
+}
+
mod candidate_caching {
use super::super::*;
use crate::candidates::cached::CandidateDataSourceCached;
@@ -326,10 +358,13 @@ fn make_source(pool: PgPool, tx_in_config: TxInConfiguration) -> CandidatesDataS
CandidatesDataSourceImpl {
pool: pool.clone(),
metrics_opt: Some(test_metrics()),
- db_sync_config: DbSyncConfigurationProvider {
+ db_sync_config: DbSyncConfigurationProvider::from_resolved(
pool,
- tx_in_config: Arc::new(tokio::sync::Mutex::new(OnceCell::from(tx_in_config))),
- },
+ ResolvedDbSyncQueryConfig {
+ tx_input_mode: tx_in_config,
+ address_mode: ResolvedDbSyncAddressMode::Inline,
+ },
+ ),
}
}
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/db_model.rs b/partner-chains/toolkit/data-sources/db-sync/src/db_model.rs
index 6eed82f7e..3df5e6053 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/db_model.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/db_model.rs
@@ -4,78 +4,59 @@ use bigdecimal::ToPrimitive;
use cardano_serialization_lib::PlutusData;
use chrono::NaiveDateTime;
pub use db_sync_sqlx::*;
-use log::info;
use sidechain_domain::{
MainchainBlock, McBlockHash, McBlockNumber, McEpochNumber, McSlotNumber, McTxHash, UtxoId,
UtxoIndex,
};
+#[cfg(feature = "bridge")]
+use sqlx::types::JsonValue;
use sqlx::{
Decode, PgPool, Pool, Postgres, database::Database, error::BoxDynError, postgres::PgTypeInfo,
- types::JsonValue,
};
use std::{cell::OnceCell, str::FromStr, sync::Arc};
use tokio::sync::Mutex;
-/// Db-Sync `tx_in.value` configuration field
-#[derive(Debug, PartialEq, Copy, Clone)]
-pub(crate) enum TxInConfiguration {
- /// Transaction inputs are linked using `tx_in` table
- Enabled,
- /// Transaction inputs are linked using `consumed_by_tx_id` column in `tx_out` table
- Consumed,
-}
-
-impl TxInConfiguration {
- pub(crate) async fn from_connection(pool: &Pool) -> Result {
- let tx_in_exists = sqlx::query_scalar::<_, i64>(
- "select count(*) from information_schema.tables where table_name = 'tx_in';",
- )
- .fetch_one(pool)
- .await? == 1;
-
- if !tx_in_exists {
- return Ok(Self::Consumed);
- }
-
- let tx_in_populated = sqlx::query_scalar::<_, bool>("SELECT EXISTS (SELECT 1 FROM tx_in);")
- .fetch_one(pool)
- .await?;
-
- if tx_in_populated {
- return Ok(Self::Enabled);
- }
-
- Ok(Self::Consumed)
- }
-}
-
/// Structure that queries, caches and provides Db-Sync configuration
pub struct DbSyncConfigurationProvider {
/// Postgres connection pool
pub(crate) pool: PgPool,
- /// Transaction input configuration used by Db-Sync
- pub(crate) tx_in_config: Arc>>,
+ /// Requested query layout
+ pub(crate) query_config: DbSyncQueryConfig,
+ /// Validated query layout used by Db-Sync
+ pub(crate) resolved_config: Arc>>,
}
impl DbSyncConfigurationProvider {
- pub(crate) fn new(pool: PgPool) -> Self {
- Self { tx_in_config: Arc::new(Mutex::new(OnceCell::new())), pool }
+ #[cfg(any(feature = "candidate-source", feature = "bridge"))]
+ #[allow(dead_code)] // Not every partial feature combination exposes a caller.
+ pub(crate) fn new_with_config(pool: PgPool, query_config: DbSyncQueryConfig) -> Self {
+ Self { pool, query_config, resolved_config: Arc::new(Mutex::new(OnceCell::new())) }
}
- pub(crate) async fn get_tx_in_config(
+ pub(crate) fn from_resolved(pool: PgPool, config: ResolvedDbSyncQueryConfig) -> Self {
+ let resolved_config = OnceCell::new();
+ let _ = resolved_config.set(config);
+ Self {
+ pool,
+ query_config: DbSyncQueryConfig::default(),
+ resolved_config: Arc::new(Mutex::new(resolved_config)),
+ }
+ }
+
+ pub(crate) async fn get_config(
&self,
- ) -> std::result::Result {
- let lock = self.tx_in_config.lock().await;
- if let Some(tx_in_config) = lock.get() {
- return Ok(*tx_in_config);
+ ) -> std::result::Result {
+ let lock = self.resolved_config.lock().await;
+ if let Some(config) = lock.get() {
+ return Ok(*config);
} else {
- let tx_in_config = TxInConfiguration::from_connection(&self.pool).await?;
- lock.set(tx_in_config).map_err(|_| {
+ let config = self.query_config.resolve(&self.pool).await.map_err(SqlxError::from)?;
+ lock.set(config).map_err(|_| {
DataSourceError::InternalDataSourceError(
- "Failed to set tx_in_config in DbSyncConfigurationProvider".into(),
+ "Failed to cache db-sync query configuration".into(),
)
})?;
- return Ok(tx_in_config);
+ return Ok(config);
}
}
}
@@ -371,7 +352,7 @@ pub(crate) async fn get_token_utxo_for_epoch(
origin_block.block_no AS tx_block_no,
origin_block.slot_no AS tx_slot_no,
origin_tx.block_index AS tx_block_index,
- datum.value AS datum
+ datum.value::jsonb AS datum
FROM ma_tx_out
INNER JOIN multi_asset ON ma_tx_out.ident = multi_asset.id
INNER JOIN tx_out ON ma_tx_out.tx_out_id = tx_out.id
@@ -407,14 +388,14 @@ pub(crate) async fn get_utxos_for_address(
pool: &Pool,
address: &Address,
block: BlockNumber,
- tx_in_configuration: TxInConfiguration,
+ config: ResolvedDbSyncQueryConfig,
) -> Result, SqlxError> {
- match tx_in_configuration {
- TxInConfiguration::Enabled => {
- get_utxos_for_address_tx_in_enabled(pool, address, block).await
+ match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => {
+ get_utxos_for_address_tx_in_enabled(pool, address, block, config.address_mode).await
},
- TxInConfiguration::Consumed => {
- get_utxos_for_address_tx_in_consumed(pool, address, block).await
+ ResolvedDbSyncTxInputMode::Consumed => {
+ get_utxos_for_address_tx_in_consumed(pool, address, block, config.address_mode).await
},
}
}
@@ -424,18 +405,21 @@ pub(crate) async fn get_utxos_for_address_tx_in_enabled(
pool: &Pool,
address: &Address,
block: BlockNumber,
+ address_mode: ResolvedDbSyncAddressMode,
) -> Result, SqlxError> {
- let query = "SELECT
+ let (address_join, address_column) = address_query_parts(address_mode);
+ let query = format!("SELECT
origin_tx.hash as utxo_id_tx_hash,
tx_out.index as utxo_id_index,
origin_block.block_no as tx_block_no,
origin_block.slot_no as tx_slot_no,
origin_block.epoch_no as tx_epoch_no,
origin_tx.block_index as tx_index_in_block,
- tx_out.address,
- datum.value as datum,
- array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_in.tx_out_index)) as tx_inputs
+ {address_column} as address,
+ datum.value::jsonb as datum,
+ array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_in.tx_out_index)) as tx_inputs
FROM tx_out
+ {address_join}
INNER JOIN tx origin_tx ON tx_out.tx_id = origin_tx.id
INNER JOIN block origin_block ON origin_tx.block_id = origin_block.id
LEFT JOIN tx_in consuming_tx_in ON tx_out.tx_id = consuming_tx_in.tx_out_id AND tx_out.index = consuming_tx_in.tx_out_index
@@ -446,7 +430,7 @@ pub(crate) async fn get_utxos_for_address_tx_in_enabled(
LEFT JOIN tx consumes_tx ON consumes_tx.id = consumes_tx_out.tx_id
LEFT JOIN datum ON tx_out.data_hash = datum.hash
WHERE
- tx_out.address = $1 AND origin_block.block_no <= $2
+ {address_column} = $1 AND origin_block.block_no <= $2
AND (consuming_tx_in.id IS NULL OR consuming_block.block_no > $2)
GROUP BY (
utxo_id_tx_hash,
@@ -455,10 +439,10 @@ pub(crate) async fn get_utxos_for_address_tx_in_enabled(
tx_slot_no,
tx_epoch_no,
tx_index_in_block,
- tx_out.address,
+ {address_column},
datum
- )";
- let rows = sqlx::query_as::<_, MainchainTxOutputRow>(query)
+ )");
+ let rows = sqlx::query_as::<_, MainchainTxOutputRow>(&query)
.bind(&address.0)
.bind(block)
.fetch_all(pool)
@@ -473,18 +457,22 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
pool: &Pool,
address: &Address,
block: BlockNumber,
+ address_mode: ResolvedDbSyncAddressMode,
) -> Result, SqlxError> {
- let query = "SELECT
+ let (address_join, address_column) = address_query_parts(address_mode);
+ let query = format!(
+ "SELECT
origin_tx.hash as utxo_id_tx_hash,
tx_out.index as utxo_id_index,
origin_block.block_no as tx_block_no,
origin_block.slot_no as tx_slot_no,
origin_block.epoch_no as tx_epoch_no,
origin_tx.block_index as tx_index_in_block,
- tx_out.address,
- datum.value as datum,
- array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_out.index)) as tx_inputs
+ {address_column} as address,
+ datum.value::jsonb as datum,
+ array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_out.index)) as tx_inputs
FROM tx_out
+ {address_join}
INNER JOIN tx origin_tx ON tx_out.tx_id = origin_tx.id
INNER JOIN block origin_block ON origin_tx.block_id = origin_block.id
LEFT JOIN tx consuming_tx ON tx_out.consumed_by_tx_id = consuming_tx.id
@@ -493,7 +481,7 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
LEFT JOIN tx consumes_tx ON consumes_tx.id = consumes_tx_out.tx_id
LEFT JOIN datum ON tx_out.data_hash = datum.hash
WHERE
- tx_out.address = $1 AND origin_block.block_no <= $2
+ {address_column} = $1 AND origin_block.block_no <= $2
AND (tx_out.consumed_by_tx_id IS NULL OR consuming_block.block_no > $2)
GROUP BY (
utxo_id_tx_hash,
@@ -502,10 +490,11 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
tx_slot_no,
tx_epoch_no,
tx_index_in_block,
- tx_out.address,
+ {address_column},
datum
- )";
- let rows = sqlx::query_as::<_, MainchainTxOutputRow>(query)
+ )"
+ );
+ let rows = sqlx::query_as::<_, MainchainTxOutputRow>(&query)
.bind(&address.0)
.bind(block)
.fetch_all(pool)
@@ -514,37 +503,19 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
rows.into_iter().map(MainchainTxOutput::try_from).collect();
Ok(result?)
}
-/// Used by `get_token_utxo_for_epoch` (CandidatesDataSourceImpl),
-#[cfg(feature = "candidate-source")]
-pub(crate) async fn create_idx_ma_tx_out_ident(pool: &Pool) -> Result<(), SqlxError> {
- let exists = index_exists(pool, "idx_ma_tx_out_ident").await?;
- if exists {
- info!("Index 'idx_ma_tx_out_ident' already exists");
- } else {
- let sql = "CREATE INDEX IF NOT EXISTS idx_ma_tx_out_ident ON ma_tx_out(ident)";
- info!("Executing '{}', this might take a while", sql);
- sqlx::query(sql).execute(pool).await?;
- info!("Index 'idx_ma_tx_out_ident' has been created");
- }
- Ok(())
-}
-
-/// Used by multiple queries across functionalities.
-#[cfg(any(feature = "candidate-source"))]
-pub(crate) async fn create_idx_tx_out_address(pool: &Pool) -> Result<(), SqlxError> {
- let exists = index_exists(pool, "idx_tx_out_address").await?;
- if exists {
- info!("Index 'idx_tx_out_address' already exists");
- } else {
- let sql = "CREATE INDEX IF NOT EXISTS idx_tx_out_address ON tx_out USING hash (address)";
- info!("Executing '{}', this might take a long time", sql);
- sqlx::query(sql).execute(pool).await?;
- info!("Index 'idx_tx_out_address' has been created");
+
+fn address_query_parts(address_mode: ResolvedDbSyncAddressMode) -> (&'static str, &'static str) {
+ match address_mode {
+ ResolvedDbSyncAddressMode::Inline => ("", "tx_out.address"),
+ ResolvedDbSyncAddressMode::AddressTable => (
+ "INNER JOIN address tx_out_address ON tx_out_address.id = tx_out.address_id",
+ "tx_out_address.address",
+ ),
}
- Ok(())
}
/// Check if the index exists.
+#[cfg(test)]
async fn index_exists(pool: &Pool, index_name: &str) -> Result {
sqlx::query("select * from pg_indexes where indexname = $1")
.bind(index_name)
@@ -566,17 +537,17 @@ mod tests {
use sqlx::PgPool;
#[sqlx::test(migrations = "./testdata/migrations-tx-in-enabled")]
- async fn tx_in_configuration_is_enabled_if_tx_in_table_exists(pool: PgPool) {
- let tx_in_config = TxInConfiguration::from_connection(&pool).await.unwrap();
+ async fn auto_configuration_uses_populated_tx_in_table(pool: PgPool) {
+ let config = DbSyncQueryConfig::default().resolve(&pool).await.unwrap();
- assert_eq!(tx_in_config, TxInConfiguration::Enabled)
+ assert_eq!(config.tx_input_mode, ResolvedDbSyncTxInputMode::TxIn)
}
#[sqlx::test(migrations = false)]
- async fn tx_in_configuration_is_consumed_if_tx_in_table_does_not_exist(pool: PgPool) {
- let tx_in_config = TxInConfiguration::from_connection(&pool).await.unwrap();
+ async fn auto_configuration_rejects_missing_input_layout(pool: PgPool) {
+ let error = DbSyncQueryConfig::default().resolve(&pool).await.unwrap_err();
- assert_eq!(tx_in_config, TxInConfiguration::Consumed)
+ assert!(error.to_string().contains("transaction-input layout is unsupported"))
}
#[sqlx::test(migrations = "./testdata/migrations-tx-in-consumed")]
@@ -688,7 +659,7 @@ WHERE tx.hash = $1
#[cfg(feature = "bridge")]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn get_bridge_txs(
- tx_in_configuration: TxInConfiguration,
+ config: ResolvedDbSyncQueryConfig,
pool: &Pool,
ics_address: &Address,
reserve_address: &Address,
@@ -701,6 +672,7 @@ pub(crate) async fn get_bridge_txs(
use sqlx::QueryBuilder;
let max_rows = max_txs.as_ref().map(ToString::to_string).unwrap_or("null".into());
+ let (address_join, address_column) = address_query_parts(config.address_mode);
let checkpoint_limit = match checkpoint {
ResolvedBridgeDataCheckpoint::Block { number } => {
@@ -719,92 +691,110 @@ pub(crate) async fn get_bridge_txs(
// Collects every native-token tx_out at the ICS address that has
// been consumed by another tx, attributed to the consuming tx. Two variants depending on
// whether db-sync's `tx_out.consumed_by_tx_id` denormalization is populated.
- let bridge_inputs_subquery = match tx_in_configuration {
- TxInConfiguration::Consumed => {
- "
+ let bridge_inputs_subquery = match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::Consumed => {
+ format!(
+ "
SELECT
tx_out.consumed_by_tx_id AS consuming_tx_id
, ma_tx_out.quantity AS quantity
FROM tx_out
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
JOIN multi_asset ON multi_asset.id = ma_tx_out.ident
WHERE multi_asset.policy = $3
AND multi_asset.name = $4
- AND tx_out.address = $1
+ AND {address_column} = $1
AND tx_out.consumed_by_tx_id IS NOT NULL
"
+ )
},
- TxInConfiguration::Enabled => {
- "
+ ResolvedDbSyncTxInputMode::TxIn => {
+ format!(
+ "
SELECT
tx_in.tx_in_id AS consuming_tx_id
, ma_tx_out.quantity AS quantity
FROM tx_in
JOIN tx_out ON tx_out.tx_id = tx_in.tx_out_id AND tx_out.index = tx_in.tx_out_index
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
JOIN multi_asset ON multi_asset.id = ma_tx_out.ident
WHERE multi_asset.policy = $3
AND multi_asset.name = $4
- AND tx_out.address = $1
+ AND {address_column} = $1
"
+ )
},
};
- let reserve_inputs_subquery = match tx_in_configuration {
- TxInConfiguration::Consumed => {
- "
+ let reserve_inputs_subquery = match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::Consumed => {
+ format!(
+ "
SELECT
tx_out.consumed_by_tx_id AS consuming_tx_id
, ma_tx_out.quantity AS quantity
FROM tx_out
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
JOIN multi_asset ON multi_asset.id = ma_tx_out.ident
WHERE multi_asset.policy = $3
AND multi_asset.name = $4
- AND tx_out.address = $2
+ AND {address_column} = $2
AND tx_out.consumed_by_tx_id IS NOT NULL
"
+ )
},
- TxInConfiguration::Enabled => {
- "
+ ResolvedDbSyncTxInputMode::TxIn => {
+ format!(
+ "
SELECT
tx_in.tx_in_id AS consuming_tx_id
, ma_tx_out.quantity AS quantity
FROM tx_in
JOIN tx_out ON tx_out.tx_id = tx_in.tx_out_id AND tx_out.index = tx_in.tx_out_index
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
JOIN multi_asset ON multi_asset.id = ma_tx_out.ident
WHERE multi_asset.policy = $3
AND multi_asset.name = $4
- AND tx_out.address = $2
+ AND {address_column} = $2
"
+ )
},
};
// Collects every native-token tx_out at the ICS address, attributed to the producing tx.
- let bridge_outputs_subquery = "
+ let bridge_outputs_subquery = format!(
+ "
SELECT
tx_out.tx_id AS producing_tx_id
, ma_tx_out.quantity AS quantity
FROM tx_out
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
JOIN multi_asset ON multi_asset.id = ma_tx_out.ident
WHERE multi_asset.policy = $3
AND multi_asset.name = $4
- AND tx_out.address = $1
- ";
+ AND {address_column} = $1
+ "
+ );
- let reserve_outputs_subquery = "
+ let reserve_outputs_subquery = format!(
+ "
SELECT
tx_out.tx_id AS producing_tx_id
, ma_tx_out.quantity AS quantity
FROM tx_out
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
JOIN multi_asset ON multi_asset.id = ma_tx_out.ident
WHERE multi_asset.policy = $3
AND multi_asset.name = $4
- AND tx_out.address = $2
- ";
+ AND {address_column} = $2
+ "
+ );
// TODO: improve query by using metadata.ident "cache" and tx, tx_out, ma_tx_out,
// tx_metadata and tx_in ids boundaries. https://github.com/midnightntwrk/partner-chains/issues/26
@@ -821,7 +811,7 @@ pub(crate) async fn get_bridge_txs(
block.block_no AS block_number
, tx.block_index AS tx_ix
, tx.hash AS tx_hash
- , tx_metadata.json AS c2m_metadata
+ , tx_metadata.json::jsonb AS c2m_metadata
, COALESCE((SELECT sum(quantity) FROM bridge_inputs WHERE consuming_tx_id = tx.id), 0) AS bridge_in
, COALESCE((SELECT sum(quantity) FROM bridge_outputs WHERE producing_tx_id = tx.id), 0) AS bridge_out
, COALESCE((SELECT sum(quantity) FROM reserve_inputs WHERE consuming_tx_id = tx.id), 0) AS reserve_in
diff --git a/partner-chains/toolkit/data-sources/db-sync/src/lib.rs b/partner-chains/toolkit/data-sources/db-sync/src/lib.rs
index ce8e452bd..a9cfbc8ef 100644
--- a/partner-chains/toolkit/data-sources/db-sync/src/lib.rs
+++ b/partner-chains/toolkit/data-sources/db-sync/src/lib.rs
@@ -51,34 +51,87 @@
//!
//! ## Cardano DB Sync configuration
//!
-//! Partner Chains data sources require specific Db-Sync configuration to be set for them to
-//! operate correctly:
-//! - `insert_options.tx_out.value`: must be either `"enable"` (default) or `"consumed"`.
-//! The data sources in this crate that need to query transaction intputs automatically detect
-//! which option is used and adjust their queries accordingly. This requires the database to be
-//! already initialized by db-sync. When run for an uninitialized database, the data sources
-//! will default to the `"enable"` option.
-//! - `insert_options.tx_out.use_address_table`: must be `false` (default).
+//! The query layout and database schema policy are independent. [`DbSyncQueryConfig`] selects the
+//! transaction-input and address representations, while [`DbSyncSchemaMode`] controls index
+//! management.
+//!
+//! ### Transaction-input representation
+//!
+//! [`DbSyncTxInputMode`] supports:
+//!
+//! - `Auto`: use `tx_in` when it contains rows, otherwise use `tx_out.consumed_by_tx_id` when at
+//! least one output records a consuming transaction. Ambiguous empty schemas require an
+//! explicit mode. This is the backward-compatible default for initialized databases.
+//! - `TxIn`: require `tx_in(tx_in_id, tx_out_id, tx_out_index)`. This corresponds to
+//! `insert_options.tx_out.value = "enable"`, or `force_tx_in = true` with `"consumed"`.
+//! - `Consumed`: require `tx_out.consumed_by_tx_id`. This corresponds to
+//! `insert_options.tx_out.value = "consumed"`.
+//!
+//! The db-sync `prune`, `bootstrap`, and `disable` transaction-output modes are unsupported because
+//! Partner Chains queries require historical transaction outputs. Schema detection only checks
+//! columns and whether either representation contains evidence; it does not prove that old inputs or spends were
+//! backfilled. Use an explicit mode in production and ensure its representation is complete for
+//! the full block and epoch range the data source will query.
+//!
+//! ### Address representation
+//!
+//! [`DbSyncAddressMode`] supports both db-sync address layouts:
+//!
+//! - `Inline` requires `insert_options.tx_out.use_address_table = false` and reads
+//! `tx_out.address`. This is the backward-compatible default.
+//! - `AddressTable` requires `insert_options.tx_out.use_address_table = true` and joins
+//! `tx_out.address_id` to `address.id` to read `address.address`.
+//!
+//! The configured mode is validated against the relations visible on the PostgreSQL connection's
+//! `search_path`.
+//!
+//! ### Other required db-sync data
+//!
+//! Partner Chains data sources also require the following db-sync data to be retained:
+//!
//! - `insert_options.ledger`: must be `"enable"` (default).
-//! - `insert_options.multi_asset`: must be `true` (default).
-//! - `insert_options.governance`: must `"enable"` (default).
-//! - `insert_options.remove_jsonb_from_schema`: must be `"disable"` (default).
-//! - `insert_options.plutus`: must be `"enable"` (default).
+//! - `insert_options.multi_asset.enable`: must be `true` (default).
+//! - `insert_options.metadata.enable`: must be `true` (default). If
+//! `insert_options.metadata.keys` filters retained metadata, it must include the C-to-M bridge
+//! key `6500973`.
+//! - `insert_options.remove_jsonb_from_schema`: either value is supported when the JSON data is
+//! retained; text-backed values are cast to `jsonb` by the queries.
+//! - `insert_options.plutus.enable`: must be `true` (default).
+//!
+//! The bridge requires complete `tx_metadata` history for key `6500973`. The presence of the table
+//! and columns does not show whether db-sync filtered or omitted older rows, so operators must
+//! validate metadata completeness separately. These data sources do not query db-sync governance
+//! tables, so `insert_options.governance` is not a compatibility requirement at this version.
//!
//! The default Cardano DB Sync configuration meets these requirements, so Partner Chain node
//! operators that do not wish to use any custom configuration can use the defaults, otherwise
//! they must preserve the values described above. See [Db-Sync configuration docs] for more
//! information.
//!
-//! ## Custom Indexes
+//! ## Schema management and custom indexes
+//!
+//! [`DbSyncSchemaMode`] supports three policies:
+//!
+//! - `Apply` creates missing required indexes with `CREATE INDEX CONCURRENTLY`. This is the
+//! backward-compatible default.
+//! - `Verify` performs read-only structural checks. It accepts a compatible valid, ready,
+//! non-partial index under any name, and fails when a required index is missing.
+//! - `Skip` neither creates nor verifies indexes.
//!
-//! In addition to indexes automatically created by Db-Sync itself, data sources in this crate
-//! require additional ones to be created for some of the queries to execute efficiently. These
-//! indexes are:
-//! - `idx_ma_tx_out_ident ON ma_tx_out(ident)`
-//! - `idx_tx_out_address ON tx_out USING hash (address)`
+//! The candidate/runtime manifest always requires btree indexes with leading keys
+//! `ma_tx_out(ident)` and `ma_tx_out(tx_out_id)`. Existing composite indexes satisfy either
+//! requirement. It additionally requires:
//!
-//! The data sources in this crate automatically create these indexes when needed at node startup.
+//! - inline addresses: a hash or btree index on `tx_out(address)`;
+//! - address-table storage: a hash or btree index on `address(address)` and a btree index on
+//! `tx_out(address_id)`;
+//! - `tx_in` inputs: btree indexes on `tx_in(tx_in_id)` and
+//! `tx_in(tx_out_id, tx_out_index)`; or
+//! - consumed inputs: a btree index on `tx_out(consumed_by_tx_id)`.
+//!
+//! [`CandidatesDataSourceImpl::new`] uses the default `Auto`/`Inline`/`Apply` policy; its
+//! configurable constructor accepts all three axes. Query-only bridge constructors accept the
+//! layout configuration but do not manage the schema.
//!
//! [PgPool]: sqlx::PgPool
//! [BlockDataSourceImpl]: crate::block::BlockDataSourceImpl
@@ -93,6 +146,10 @@ pub use crate::{
data_sources::{ConnectionConfig, PgPool, get_connection_from_env},
metrics::{McFollowerMetrics, register_metrics_warn_errors},
};
+pub use db_sync_sqlx::{
+ DbSyncAddressMode, DbSyncQueryConfig, DbSyncSchemaMode, DbSyncTxInputMode,
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
#[cfg(feature = "block-source")]
pub use crate::block::{BlockDataSourceImpl, DbSyncBlockDataSourceConfig};
@@ -169,6 +226,8 @@ pub enum DataSourceError {
#[cfg(test)]
mod tests {
use ctor::{ctor, dtor};
+ use db_sync_sqlx::{DbSyncIndexSpec, DbSyncQueryConfig, DbSyncSchemaMode, manage_indexes};
+ use sqlx::PgPool;
use std::sync::{OnceLock, mpsc};
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::{
@@ -180,6 +239,140 @@ mod tests {
static POSTGRES: OnceLock> = OnceLock::new();
+ pub(crate) async fn normalize_tx_out_addresses(pool: &PgPool) {
+ sqlx::raw_sql(
+ r#"
+CREATE TABLE address (
+ id bigserial PRIMARY KEY,
+ address character varying NOT NULL UNIQUE,
+ raw bytea NOT NULL,
+ has_script boolean NOT NULL,
+ payment_cred hash28type,
+ stake_address_id bigint
+);
+
+INSERT INTO address (address, raw, has_script, payment_cred, stake_address_id)
+SELECT DISTINCT ON (address)
+ address,
+ address_raw,
+ address_has_script,
+ payment_cred,
+ stake_address_id
+FROM tx_out
+ORDER BY address, id;
+
+ALTER TABLE tx_out ADD COLUMN address_id bigint;
+
+UPDATE tx_out
+SET address_id = address.id
+FROM address
+WHERE address.address = tx_out.address;
+
+ALTER TABLE tx_out ALTER COLUMN address_id SET NOT NULL;
+ALTER TABLE tx_out
+ ADD CONSTRAINT tx_out_address_id_fkey
+ FOREIGN KEY (address_id) REFERENCES address(id) ON DELETE CASCADE ON UPDATE RESTRICT;
+ALTER TABLE tx_out
+ DROP COLUMN address,
+ DROP COLUMN address_raw,
+ DROP COLUMN address_has_script,
+ DROP COLUMN payment_cred;
+"#,
+ )
+ .execute(pool)
+ .await
+ .unwrap();
+ }
+
+ fn address_index_spec() -> DbSyncIndexSpec {
+ DbSyncIndexSpec {
+ name: "idx_address_address",
+ relation: "address",
+ access_methods: &["hash", "btree"],
+ keys: &["address"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_address_address ON address USING hash(address)",
+ }
+ }
+
+ async fn create_address_table(pool: &PgPool) {
+ sqlx::query(
+ "CREATE TABLE address (id bigint PRIMARY KEY, address character varying NOT NULL)",
+ )
+ .execute(pool)
+ .await
+ .unwrap();
+ }
+
+ #[sqlx::test]
+ async fn verify_and_apply_accept_desc_btree_index_without_creating_duplicate(pool: PgPool) {
+ create_address_table(&pool).await;
+ sqlx::query("CREATE INDEX operator_address_lookup ON address(address DESC)")
+ .execute(&pool)
+ .await
+ .unwrap();
+
+ manage_indexes(&pool, DbSyncSchemaMode::Verify, &[address_index_spec()])
+ .await
+ .unwrap();
+ manage_indexes(&pool, DbSyncSchemaMode::Apply, &[address_index_spec()])
+ .await
+ .unwrap();
+
+ let midnight_index: Option =
+ sqlx::query_scalar("SELECT to_regclass('idx_address_address')::text")
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ assert_eq!(midnight_index, None, "apply must not create a duplicate compatible index");
+ }
+
+ #[sqlx::test]
+ async fn verify_rejects_index_without_required_leading_key(pool: PgPool) {
+ create_address_table(&pool).await;
+ sqlx::query("CREATE INDEX operator_wrong_address_lookup ON address(id, address)")
+ .execute(&pool)
+ .await
+ .unwrap();
+
+ let error = manage_indexes(&pool, DbSyncSchemaMode::Verify, &[address_index_spec()])
+ .await
+ .expect_err("the required address key is not the leading index key");
+
+ assert!(
+ error.to_string().contains("address USING hash or btree (address)"),
+ "unexpected verification error: {error}"
+ );
+ }
+
+ #[sqlx::test]
+ async fn apply_creates_an_index_that_verify_accepts(pool: PgPool) {
+ create_address_table(&pool).await;
+
+ manage_indexes(&pool, DbSyncSchemaMode::Apply, &[address_index_spec()])
+ .await
+ .unwrap();
+ manage_indexes(&pool, DbSyncSchemaMode::Verify, &[address_index_spec()])
+ .await
+ .unwrap();
+ }
+
+ #[sqlx::test]
+ async fn auto_rejects_an_empty_schema_when_both_input_layouts_are_possible(pool: PgPool) {
+ sqlx::raw_sql(
+ "CREATE TABLE tx_out (address character varying NOT NULL, consumed_by_tx_id bigint); \
+ CREATE TABLE tx_in (tx_in_id bigint, tx_out_id bigint, tx_out_index smallint);",
+ )
+ .execute(&pool)
+ .await
+ .unwrap();
+
+ let error = DbSyncQueryConfig::default()
+ .resolve(&pool)
+ .await
+ .expect_err("an empty dual-layout schema cannot be inferred safely");
+ assert!(error.to_string().contains("both supported representations are empty"));
+ }
+
fn init_postgres() -> Container {
Postgres::default().with_tag("17.2").start().unwrap()
}
diff --git a/partner-chains/toolkit/utils/db-sync-sqlx/Cargo.toml b/partner-chains/toolkit/utils/db-sync-sqlx/Cargo.toml
index 02738bf21..3f453280a 100644
--- a/partner-chains/toolkit/utils/db-sync-sqlx/Cargo.toml
+++ b/partner-chains/toolkit/utils/db-sync-sqlx/Cargo.toml
@@ -16,6 +16,8 @@ sqlx = { workspace = true }
sidechain-domain = { workspace = true, features = ["std"] }
num-traits = { workspace = true }
hex = { workspace = true }
+serde = { workspace = true }
+log = { workspace = true }
[lib]
test = false
diff --git a/partner-chains/toolkit/utils/db-sync-sqlx/src/configuration.rs b/partner-chains/toolkit/utils/db-sync-sqlx/src/configuration.rs
new file mode 100644
index 000000000..265e219fc
--- /dev/null
+++ b/partner-chains/toolkit/utils/db-sync-sqlx/src/configuration.rs
@@ -0,0 +1,203 @@
+// This file is part of midnight-node.
+// Copyright (C) Midnight Foundation
+// SPDX-License-Identifier: Apache-2.0
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use serde::{Deserialize, Serialize};
+use sqlx::{Pool, Postgres};
+
+/// Selects how transaction inputs are read from Cardano db-sync.
+#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum DbSyncTxInputMode {
+ /// Preserve the legacy behaviour: use `tx_in` when it is populated, otherwise use
+ /// `tx_out.consumed_by_tx_id`.
+ #[default]
+ Auto,
+ /// Read transaction inputs from the `tx_in` table (`tx_out.value = "enable"`, or
+ /// `force_tx_in = true`).
+ TxIn,
+ /// Read transaction inputs from `tx_out.consumed_by_tx_id`
+ /// (`tx_out.value = "consumed"`).
+ Consumed,
+}
+
+/// Selects how output addresses are stored by Cardano db-sync.
+#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum DbSyncAddressMode {
+ /// Read address columns directly from `tx_out` (`use_address_table = false`).
+ #[default]
+ Inline,
+ /// Join `tx_out.address_id` to `address.id` (`use_address_table = true`).
+ AddressTable,
+}
+
+/// Controls whether Midnight changes or checks the db-sync database schema.
+#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum DbSyncSchemaMode {
+ /// Create missing recommended indexes and apply database-level query tuning.
+ #[default]
+ Apply,
+ /// Perform read-only verification of recommended indexes and query tuning.
+ Verify,
+ /// Do not apply or verify recommended indexes or query tuning. Query-layout validation is
+ /// still performed before queries are run.
+ Skip,
+}
+
+/// Requested db-sync query layout.
+#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)]
+pub struct DbSyncQueryConfig {
+ /// Requested transaction-input representation.
+ pub tx_input_mode: DbSyncTxInputMode,
+ /// Requested output-address representation.
+ pub address_mode: DbSyncAddressMode,
+}
+
+/// Resolved transaction-input representation used by SQL queries.
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+pub enum ResolvedDbSyncTxInputMode {
+ TxIn,
+ Consumed,
+}
+
+/// Resolved address representation used by SQL queries.
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+pub enum ResolvedDbSyncAddressMode {
+ Inline,
+ AddressTable,
+}
+
+/// Validated db-sync layout used by SQL queries.
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+pub struct ResolvedDbSyncQueryConfig {
+ /// Validated transaction-input representation.
+ pub tx_input_mode: ResolvedDbSyncTxInputMode,
+ /// Validated output-address representation.
+ pub address_mode: ResolvedDbSyncAddressMode,
+}
+
+impl DbSyncQueryConfig {
+ /// Validates and resolves this layout against the database visible on the connection's
+ /// `search_path`. Explicit modes fail fast when their required columns are unavailable.
+ pub async fn resolve(
+ self,
+ pool: &Pool,
+ ) -> Result {
+ let tx_input_mode = match self.tx_input_mode {
+ DbSyncTxInputMode::Auto => detect_tx_input_mode(pool).await?,
+ DbSyncTxInputMode::TxIn => {
+ require_columns(pool, "tx_in", &["tx_in_id", "tx_out_id", "tx_out_index"]).await?;
+ ResolvedDbSyncTxInputMode::TxIn
+ },
+ DbSyncTxInputMode::Consumed => {
+ require_columns(pool, "tx_out", &["consumed_by_tx_id"]).await?;
+ ResolvedDbSyncTxInputMode::Consumed
+ },
+ };
+
+ let address_mode = match self.address_mode {
+ DbSyncAddressMode::Inline => {
+ require_columns(pool, "tx_out", &["address"]).await?;
+ ResolvedDbSyncAddressMode::Inline
+ },
+ DbSyncAddressMode::AddressTable => {
+ require_columns(pool, "tx_out", &["address_id"]).await?;
+ require_columns(pool, "address", &["id", "address"]).await?;
+ ResolvedDbSyncAddressMode::AddressTable
+ },
+ };
+
+ Ok(ResolvedDbSyncQueryConfig { tx_input_mode, address_mode })
+ }
+}
+
+async fn detect_tx_input_mode(
+ pool: &Pool,
+) -> Result {
+ let has_tx_in = has_columns(pool, "tx_in", &["tx_in_id", "tx_out_id", "tx_out_index"]).await?;
+ let has_consumed = has_columns(pool, "tx_out", &["consumed_by_tx_id"]).await?;
+
+ if has_tx_in {
+ let populated = sqlx::query_scalar::<_, bool>("SELECT EXISTS (SELECT 1 FROM tx_in)")
+ .fetch_one(pool)
+ .await?;
+ if populated {
+ return Ok(ResolvedDbSyncTxInputMode::TxIn);
+ }
+ }
+
+ if has_consumed {
+ let populated = sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS (SELECT 1 FROM tx_out WHERE consumed_by_tx_id IS NOT NULL)",
+ )
+ .fetch_one(pool)
+ .await?;
+ if populated {
+ return Ok(ResolvedDbSyncTxInputMode::Consumed);
+ }
+ }
+
+ match (has_tx_in, has_consumed) {
+ (true, false) => Ok(ResolvedDbSyncTxInputMode::TxIn),
+ (false, true) => Ok(ResolvedDbSyncTxInputMode::Consumed),
+ (true, true) => Err(sqlx::Error::Protocol(
+ "db-sync transaction-input layout is ambiguous because both supported representations are empty; set db_sync_tx_input_mode to tx_in or consumed explicitly"
+ .to_string(),
+ )),
+ (false, false) => Err(sqlx::Error::Protocol(
+ "db-sync transaction-input layout is unsupported: expected tx_in(tx_in_id, tx_out_id, tx_out_index) or tx_out.consumed_by_tx_id"
+ .to_string(),
+ )),
+ }
+}
+
+async fn require_columns(
+ pool: &Pool,
+ relation: &str,
+ columns: &[&str],
+) -> Result<(), sqlx::Error> {
+ if has_columns(pool, relation, columns).await? {
+ return Ok(());
+ }
+
+ Err(sqlx::Error::Protocol(format!(
+ "configured db-sync layout requires {relation}({}), but those columns are not available on the current search_path",
+ columns.join(", ")
+ )))
+}
+
+async fn has_columns(
+ pool: &Pool,
+ relation: &str,
+ columns: &[&str],
+) -> Result {
+ let present = sqlx::query_scalar::<_, i64>(
+ r#"
+SELECT COUNT(*)
+FROM pg_catalog.pg_attribute
+WHERE attrelid = to_regclass($1)
+ AND attname = ANY($2)
+ AND attnum > 0
+ AND NOT attisdropped
+"#,
+ )
+ .bind(relation)
+ .bind(columns)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(present == columns.len() as i64)
+}
diff --git a/partner-chains/toolkit/utils/db-sync-sqlx/src/lib.rs b/partner-chains/toolkit/utils/db-sync-sqlx/src/lib.rs
index 80f3ebb69..691b73a79 100644
--- a/partner-chains/toolkit/utils/db-sync-sqlx/src/lib.rs
+++ b/partner-chains/toolkit/utils/db-sync-sqlx/src/lib.rs
@@ -12,6 +12,15 @@
//! [Db-Sync]: https://github.com/IntersectMBO/cardano-db-sync
//! [Db-Sync schema]: https://github.com/IntersectMBO/cardano-db-sync/blob/master/doc/schema.md
+mod configuration;
+mod schema;
+
+pub use configuration::{
+ DbSyncAddressMode, DbSyncQueryConfig, DbSyncSchemaMode, DbSyncTxInputMode,
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
+pub use schema::{DbSyncIndexSpec, candidate_index_specs, manage_indexes};
+
use num_traits::ToPrimitive;
use sidechain_domain::*;
use sqlx::database::Database;
diff --git a/partner-chains/toolkit/utils/db-sync-sqlx/src/schema.rs b/partner-chains/toolkit/utils/db-sync-sqlx/src/schema.rs
new file mode 100644
index 000000000..933569194
--- /dev/null
+++ b/partner-chains/toolkit/utils/db-sync-sqlx/src/schema.rs
@@ -0,0 +1,225 @@
+// This file is part of midnight-node.
+// Copyright (C) Midnight Foundation
+// SPDX-License-Identifier: Apache-2.0
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use crate::{DbSyncSchemaMode, ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig};
+use log::info;
+use sqlx::{FromRow, Pool, Postgres};
+
+/// A recommended index used by db-sync-backed queries.
+#[derive(Debug, Clone, Copy)]
+pub struct DbSyncIndexSpec {
+ /// Name used when the index is created by Midnight.
+ pub name: &'static str,
+ /// Relation resolved on the connection's `search_path`.
+ pub relation: &'static str,
+ /// Access methods that can serve the query. The first is used in diagnostics.
+ pub access_methods: &'static [&'static str],
+ /// Required leading index keys or expressions.
+ pub keys: &'static [&'static str],
+ /// DDL used in `apply` mode when no compatible index exists.
+ pub create_sql: &'static str,
+}
+
+/// Indexes used by candidate and Ariadne-parameter queries.
+pub fn candidate_index_specs(config: ResolvedDbSyncQueryConfig) -> Vec {
+ let mut indexes = vec![
+ DbSyncIndexSpec {
+ name: "idx_ma_tx_out_ident",
+ relation: "ma_tx_out",
+ access_methods: &["btree"],
+ keys: &["ident"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_ident ON ma_tx_out(ident)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_ma_tx_out_id_ident",
+ relation: "ma_tx_out",
+ access_methods: &["btree"],
+ // The standard db-sync tx_out_id index is sufficient because each output has a
+ // bounded asset set. Apply keeps the historical covering-index DDL when no
+ // tx_out_id-leading index exists.
+ keys: &["tx_out_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_id_ident ON ma_tx_out(tx_out_id, ident)",
+ },
+ ];
+
+ match config.address_mode {
+ ResolvedDbSyncAddressMode::Inline => indexes.push(DbSyncIndexSpec {
+ name: "idx_tx_out_address",
+ relation: "tx_out",
+ access_methods: &["hash", "btree"],
+ keys: &["address"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_address ON tx_out USING hash(address)",
+ }),
+ ResolvedDbSyncAddressMode::AddressTable => indexes.extend([
+ DbSyncIndexSpec {
+ name: "idx_address_address",
+ relation: "address",
+ access_methods: &["hash", "btree"],
+ keys: &["address"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_address_address ON address USING hash(address)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_tx_out_address_id",
+ relation: "tx_out",
+ access_methods: &["btree"],
+ keys: &["address_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_address_id ON tx_out(address_id)",
+ },
+ ]),
+ }
+
+ match config.tx_input_mode {
+ crate::ResolvedDbSyncTxInputMode::TxIn => indexes.extend([
+ DbSyncIndexSpec {
+ name: "idx_tx_in_tx_in_id",
+ relation: "tx_in",
+ access_methods: &["btree"],
+ keys: &["tx_in_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_in_tx_in_id ON tx_in(tx_in_id)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_tx_in_tx_out_id_tx_out_index",
+ relation: "tx_in",
+ access_methods: &["btree"],
+ keys: &["tx_out_id", "tx_out_index"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_in_tx_out_id_tx_out_index ON tx_in(tx_out_id, tx_out_index)",
+ },
+ ]),
+ crate::ResolvedDbSyncTxInputMode::Consumed => indexes.push(DbSyncIndexSpec {
+ name: "idx_tx_out_consumed_by_tx_id",
+ relation: "tx_out",
+ access_methods: &["btree"],
+ keys: &["consumed_by_tx_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_consumed_by_tx_id ON tx_out(consumed_by_tx_id)",
+ }),
+ }
+
+ indexes
+}
+
+/// Applies or verifies an index manifest. Verification accepts an index with any name when it is
+/// valid, ready, non-partial, uses an accepted access method, and has the requested leading keys.
+pub async fn manage_indexes(
+ pool: &Pool,
+ mode: DbSyncSchemaMode,
+ indexes: &[DbSyncIndexSpec],
+) -> Result<(), sqlx::Error> {
+ if mode == DbSyncSchemaMode::Skip {
+ info!("Skipping db-sync index management and verification");
+ return Ok(());
+ }
+
+ let mut missing = Vec::new();
+ for index in indexes {
+ if has_compatible_index(pool, index).await? {
+ info!(
+ "Compatible index for {}({}) already exists",
+ index.relation,
+ index.keys.join(", ")
+ );
+ continue;
+ }
+
+ if mode == DbSyncSchemaMode::Verify {
+ missing.push(format!(
+ "{} USING {} ({})",
+ index.relation,
+ index.access_methods.join(" or "),
+ index.keys.join(", ")
+ ));
+ continue;
+ }
+
+ info!("Creating db-sync index '{}'; this may take a while", index.name);
+ sqlx::query(index.create_sql).execute(pool).await?;
+
+ if !has_compatible_index(pool, index).await? {
+ let message = format!(
+ "index '{}' exists but does not provide a valid {} index on {}({}); remove or rename the conflicting index and retry",
+ index.name,
+ index.access_methods.join(" or "),
+ index.relation,
+ index.keys.join(", ")
+ );
+ return Err(sqlx::Error::Protocol(message));
+ }
+ }
+
+ if missing.is_empty() {
+ Ok(())
+ } else {
+ Err(sqlx::Error::Protocol(format!(
+ "db_sync_schema_mode=verify found missing or unusable indexes: {}. See docs/configuration-guide.md for operator-managed SQL",
+ missing.join("; ")
+ )))
+ }
+}
+
+#[derive(Debug, FromRow)]
+struct IndexDefinition {
+ access_method: String,
+ is_valid: bool,
+ is_ready: bool,
+ predicate: Option,
+ keys: Vec,
+}
+
+async fn has_compatible_index(
+ pool: &Pool,
+ spec: &DbSyncIndexSpec,
+) -> Result {
+ let definitions = sqlx::query_as::<_, IndexDefinition>(
+ r#"
+SELECT
+ access_method.amname AS access_method,
+ index.indisvalid AS is_valid,
+ index.indisready AS is_ready,
+ pg_get_expr(index.indpred, index.indrelid) AS predicate,
+ ARRAY(
+ SELECT COALESCE(
+ attribute.attname::text,
+ pg_get_indexdef(index.indexrelid, key_column.position::integer, true)
+ )
+ FROM unnest(index.indkey::smallint[]) WITH ORDINALITY
+ AS key_column(attribute_number, position)
+ LEFT JOIN pg_catalog.pg_attribute AS attribute
+ ON attribute.attrelid = index.indrelid
+ AND attribute.attnum = key_column.attribute_number
+ WHERE key_column.position <= index.indnkeyatts
+ ORDER BY key_column.position
+ ) AS keys
+FROM pg_catalog.pg_index AS index
+JOIN pg_catalog.pg_class AS index_class ON index_class.oid = index.indexrelid
+JOIN pg_catalog.pg_am AS access_method ON access_method.oid = index_class.relam
+WHERE index.indrelid = to_regclass($1)
+"#,
+ )
+ .bind(spec.relation)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(definitions.iter().any(|definition| {
+ definition.is_valid
+ && definition.is_ready
+ && definition.predicate.is_none()
+ && spec.access_methods.contains(&definition.access_method.as_str())
+ && definition.keys.len() >= spec.keys.len()
+ && definition
+ .keys
+ .iter()
+ .zip(spec.keys)
+ .all(|(actual, expected)| actual == expected)
+ }))
+}
diff --git a/partner-chains/toolkit/utils/db-sync-sqlx/tests/configuration_manifest.rs b/partner-chains/toolkit/utils/db-sync-sqlx/tests/configuration_manifest.rs
new file mode 100644
index 000000000..3176038bd
--- /dev/null
+++ b/partner-chains/toolkit/utils/db-sync-sqlx/tests/configuration_manifest.rs
@@ -0,0 +1,139 @@
+// This file is part of midnight-node.
+// Copyright (C) Midnight Foundation
+// SPDX-License-Identifier: Apache-2.0
+// Licensed under the Apache License, Version 2.0 (the "License");
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use db_sync_sqlx::{
+ DbSyncAddressMode, DbSyncQueryConfig, DbSyncSchemaMode, DbSyncTxInputMode,
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+ candidate_index_specs,
+};
+use serde::{Deserialize, de::value::StrDeserializer};
+use std::collections::BTreeSet;
+
+fn deserialize(value: &str) -> Result
+where
+ T: for<'de> Deserialize<'de>,
+{
+ T::deserialize(StrDeserializer::new(value))
+}
+
+fn resolved(address_mode: ResolvedDbSyncAddressMode) -> ResolvedDbSyncQueryConfig {
+ ResolvedDbSyncQueryConfig { tx_input_mode: ResolvedDbSyncTxInputMode::TxIn, address_mode }
+}
+
+#[test]
+fn defaults_preserve_legacy_query_and_schema_behaviour() {
+ assert_eq!(
+ DbSyncQueryConfig::default(),
+ DbSyncQueryConfig {
+ tx_input_mode: DbSyncTxInputMode::Auto,
+ address_mode: DbSyncAddressMode::Inline,
+ }
+ );
+ assert_eq!(DbSyncSchemaMode::default(), DbSyncSchemaMode::Apply)
+}
+
+#[test]
+fn documented_configuration_values_deserialize() {
+ assert_eq!(deserialize::("auto").unwrap(), DbSyncTxInputMode::Auto);
+ assert_eq!(deserialize::("tx_in").unwrap(), DbSyncTxInputMode::TxIn);
+ assert_eq!(deserialize::("consumed").unwrap(), DbSyncTxInputMode::Consumed);
+
+ assert_eq!(deserialize::("inline").unwrap(), DbSyncAddressMode::Inline);
+ assert_eq!(
+ deserialize::("address_table").unwrap(),
+ DbSyncAddressMode::AddressTable
+ );
+
+ assert_eq!(deserialize::("apply").unwrap(), DbSyncSchemaMode::Apply);
+ assert_eq!(deserialize::("verify").unwrap(), DbSyncSchemaMode::Verify);
+ assert_eq!(deserialize::("skip").unwrap(), DbSyncSchemaMode::Skip);
+
+ assert!(deserialize::("tx-in").is_err());
+ assert!(deserialize::("normalized").is_err());
+ assert!(deserialize::("disabled").is_err())
+}
+
+#[test]
+fn inline_manifest_targets_only_inline_address_storage() {
+ let indexes = candidate_index_specs(resolved(ResolvedDbSyncAddressMode::Inline));
+ let names: BTreeSet<_> = indexes.iter().map(|index| index.name).collect();
+
+ assert_eq!(names.len(), indexes.len(), "index names must be unique");
+ assert!(names.contains("idx_ma_tx_out_ident"));
+ assert!(names.contains("idx_ma_tx_out_id_ident"));
+ assert!(names.contains("idx_tx_out_address"));
+ assert!(!names.contains("idx_address_address"));
+ assert!(!names.contains("idx_tx_out_address_id"));
+ let by_output = indexes
+ .iter()
+ .find(|index| index.name == "idx_ma_tx_out_id_ident")
+ .expect("ma_tx_out output lookup index is present");
+ assert_eq!(by_output.keys, &["tx_out_id"]);
+
+ let address = indexes
+ .iter()
+ .find(|index| index.name == "idx_tx_out_address")
+ .expect("inline address index is present");
+ assert_eq!(address.relation, "tx_out");
+ assert_eq!(address.access_methods, &["hash", "btree"]);
+ assert_eq!(address.keys, &["address"]);
+}
+
+#[test]
+fn address_table_manifest_targets_only_normalized_address_storage() {
+ let indexes = candidate_index_specs(resolved(ResolvedDbSyncAddressMode::AddressTable));
+ let names: BTreeSet<_> = indexes.iter().map(|index| index.name).collect();
+
+ assert_eq!(names.len(), indexes.len(), "index names must be unique");
+ assert!(names.contains("idx_ma_tx_out_ident"));
+ assert!(names.contains("idx_ma_tx_out_id_ident"));
+ assert!(names.contains("idx_address_address"));
+ assert!(names.contains("idx_tx_out_address_id"));
+ assert!(!names.contains("idx_tx_out_address"));
+
+ let address = indexes
+ .iter()
+ .find(|index| index.name == "idx_address_address")
+ .expect("normalized address index is present");
+ assert_eq!(address.relation, "address");
+ assert_eq!(address.access_methods, &["hash", "btree"]);
+ assert_eq!(address.keys, &["address"]);
+
+ let foreign_key = indexes
+ .iter()
+ .find(|index| index.name == "idx_tx_out_address_id")
+ .expect("normalized address foreign-key index is present");
+ assert_eq!(foreign_key.relation, "tx_out");
+ assert_eq!(foreign_key.access_methods, &["btree"]);
+ assert_eq!(foreign_key.keys, &["address_id"]);
+}
+
+#[test]
+fn input_manifest_tracks_the_selected_transaction_input_layout() {
+ let tx_in = candidate_index_specs(resolved(ResolvedDbSyncAddressMode::Inline));
+ let tx_in_names: BTreeSet<_> = tx_in.iter().map(|index| index.name).collect();
+ assert!(tx_in_names.contains("idx_tx_in_tx_in_id"));
+ assert!(tx_in_names.contains("idx_tx_in_tx_out_id_tx_out_index"));
+ assert!(!tx_in_names.contains("idx_tx_out_consumed_by_tx_id"));
+
+ let consumed = candidate_index_specs(ResolvedDbSyncQueryConfig {
+ tx_input_mode: ResolvedDbSyncTxInputMode::Consumed,
+ address_mode: ResolvedDbSyncAddressMode::Inline,
+ });
+ let consumed_names: BTreeSet<_> = consumed.iter().map(|index| index.name).collect();
+ assert!(consumed_names.contains("idx_tx_out_consumed_by_tx_id"));
+ assert!(!consumed_names.contains("idx_tx_in_tx_in_id"));
+ assert!(!consumed_names.contains("idx_tx_in_tx_out_id_tx_out_index"))
+}
diff --git a/primitives/mainchain-follower/Cargo.toml b/primitives/mainchain-follower/Cargo.toml
index 71367ea07..7f12ec661 100644
--- a/primitives/mainchain-follower/Cargo.toml
+++ b/primitives/mainchain-follower/Cargo.toml
@@ -36,6 +36,7 @@ tokio = { workspace = true, features = ["full"], optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"]}
+testcontainers-modules = { version = "0.15", features = ["postgres"] }
[features]
default = ["std"]
diff --git a/primitives/mainchain-follower/src/data_source/candidates_data_source/db_model.rs b/primitives/mainchain-follower/src/data_source/candidates_data_source/db_model.rs
index 0f6ebfd66..2c45f74c8 100644
--- a/primitives/mainchain-follower/src/data_source/candidates_data_source/db_model.rs
+++ b/primitives/mainchain-follower/src/data_source/candidates_data_source/db_model.rs
@@ -14,17 +14,16 @@
use cardano_serialization_lib::{
PlutusData, PlutusDatumSchema::DetailedSchema, encode_json_value_to_plutus_datum,
};
-use db_sync_sqlx::{Address, BlockNumber, EpochNumber, SlotNumber, TxIndex, TxIndexInBlock};
-use log::info;
+use db_sync_sqlx::{
+ Address, BlockNumber, EpochNumber, ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig,
+ ResolvedDbSyncTxInputMode, SlotNumber, TxIndex, TxIndexInBlock,
+};
use sidechain_domain::{McTxHash, UtxoId, UtxoIndex};
use sqlx::error::BoxDynError;
use sqlx::postgres::PgTypeInfo;
use sqlx::types::JsonValue;
-use sqlx::{Decode, PgPool, Pool, Postgres};
-use std::cell::OnceCell;
+use sqlx::{Decode, Pool, Postgres};
use std::str::FromStr;
-use std::sync::Arc;
-use tokio::sync::Mutex;
/// Wraps PlutusData to provide sqlx::Decode and sqlx::Type implementations
#[derive(Debug, Clone, PartialEq)]
@@ -91,70 +90,6 @@ impl From for Box {
}
}
-/// Db-Sync `tx_in.value` configuration field
-#[derive(Debug, PartialEq, Copy, Clone)]
-pub(crate) enum TxInConfiguration {
- /// Transaction inputs are linked using `tx_in` table
- Enabled,
- /// Transaction inputs are linked using `consumed_by_tx_id` column in `tx_out` table
- Consumed,
-}
-
-impl TxInConfiguration {
- pub(crate) async fn from_connection(pool: &Pool) -> Result {
- let tx_in_exists = sqlx::query_scalar::<_, i64>(
- "select count(*) from information_schema.tables where table_name = 'tx_in';",
- )
- .fetch_one(pool)
- .await? == 1;
-
- if !tx_in_exists {
- return Ok(Self::Consumed);
- }
-
- let tx_in_populated = sqlx::query_scalar::<_, bool>("SELECT EXISTS (SELECT 1 FROM tx_in);")
- .fetch_one(pool)
- .await?;
-
- if tx_in_populated {
- return Ok(Self::Enabled);
- }
-
- Ok(Self::Consumed)
- }
-}
-
-/// Structure that queries, caches and provides Db-Sync configuration
-pub struct DbSyncConfigurationProvider {
- /// Postgres connection pool
- pub(crate) pool: PgPool,
- /// Transaction input configuration used by Db-Sync
- pub(crate) tx_in_config: Arc>>,
-}
-
-impl DbSyncConfigurationProvider {
- pub(crate) fn new(pool: PgPool) -> Self {
- Self { tx_in_config: Arc::new(Mutex::new(OnceCell::new())), pool }
- }
-
- pub(crate) async fn get_tx_in_config(
- &self,
- ) -> std::result::Result {
- let lock = self.tx_in_config.lock().await;
- if let Some(tx_in_config) = lock.get() {
- Ok(*tx_in_config)
- } else {
- let tx_in_config = TxInConfiguration::from_connection(&self.pool).await?;
- lock.set(tx_in_config).map_err(|_| {
- DataSourceError::InternalDataSourceError(
- "Failed to set tx_in_config in DbSyncConfigurationProvider".into(),
- )
- })?;
- Ok(tx_in_config)
- }
- }
-}
-
#[derive(Debug, Clone, sqlx::FromRow, PartialEq)]
pub(crate) struct Block {
pub block_no: BlockNumber,
@@ -293,7 +228,7 @@ pub(crate) async fn get_token_utxo_for_epoch(
origin_block.block_no AS tx_block_no,
origin_block.slot_no AS tx_slot_no,
origin_tx.block_index AS tx_block_index,
- datum.value AS datum
+ datum.value::jsonb AS datum
FROM ma_tx_out
INNER JOIN tx_out ON ma_tx_out.tx_out_id = tx_out.id
INNER JOIN tx origin_tx ON tx_out.tx_id = origin_tx.id
@@ -339,14 +274,14 @@ pub(crate) async fn get_utxos_for_address(
pool: &Pool,
address: &Address,
block: BlockNumber,
- tx_in_configuration: TxInConfiguration,
+ config: ResolvedDbSyncQueryConfig,
) -> Result, SqlxError> {
- match tx_in_configuration {
- TxInConfiguration::Enabled => {
- get_utxos_for_address_tx_in_enabled(pool, address, block).await
+ match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => {
+ get_utxos_for_address_tx_in_enabled(pool, address, block, config.address_mode).await
},
- TxInConfiguration::Consumed => {
- get_utxos_for_address_tx_in_consumed(pool, address, block).await
+ ResolvedDbSyncTxInputMode::Consumed => {
+ get_utxos_for_address_tx_in_consumed(pool, address, block, config.address_mode).await
},
}
}
@@ -355,18 +290,21 @@ pub(crate) async fn get_utxos_for_address_tx_in_enabled(
pool: &Pool,
address: &Address,
block: BlockNumber,
+ address_mode: ResolvedDbSyncAddressMode,
) -> Result, SqlxError> {
- let query = "SELECT
+ let (address_join, address_column) = address_query_parts(address_mode);
+ let query = format!("SELECT
origin_tx.hash as utxo_id_tx_hash,
tx_out.index as utxo_id_index,
origin_block.block_no as tx_block_no,
origin_block.slot_no as tx_slot_no,
origin_block.epoch_no as tx_epoch_no,
origin_tx.block_index as tx_index_in_block,
- tx_out.address,
- datum.value as datum,
- array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_in.tx_out_index)) as tx_inputs
+ {address_column} as address,
+ datum.value::jsonb as datum,
+ array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_in.tx_out_index)) as tx_inputs
FROM tx_out
+ {address_join}
INNER JOIN tx origin_tx ON tx_out.tx_id = origin_tx.id
INNER JOIN block origin_block ON origin_tx.block_id = origin_block.id
LEFT JOIN tx_in consuming_tx_in ON tx_out.tx_id = consuming_tx_in.tx_out_id AND tx_out.index = consuming_tx_in.tx_out_index
@@ -377,7 +315,7 @@ pub(crate) async fn get_utxos_for_address_tx_in_enabled(
LEFT JOIN tx consumes_tx ON consumes_tx.id = consumes_tx_out.tx_id
LEFT JOIN datum ON tx_out.data_hash = datum.hash
WHERE
- tx_out.address = $1 AND origin_block.block_no <= $2
+ {address_column} = $1 AND origin_block.block_no <= $2
AND (consuming_tx_in.id IS NULL OR consuming_block.block_no > $2)
GROUP BY (
utxo_id_tx_hash,
@@ -386,10 +324,10 @@ pub(crate) async fn get_utxos_for_address_tx_in_enabled(
tx_slot_no,
tx_epoch_no,
tx_index_in_block,
- tx_out.address,
+ {address_column},
datum
- )";
- let rows = sqlx::query_as::<_, MainchainTxOutputRow>(query)
+ )");
+ let rows = sqlx::query_as::<_, MainchainTxOutputRow>(&query)
.bind(&address.0)
.bind(block)
.fetch_all(pool)
@@ -403,18 +341,22 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
pool: &Pool,
address: &Address,
block: BlockNumber,
+ address_mode: ResolvedDbSyncAddressMode,
) -> Result, SqlxError> {
- let query = "SELECT
+ let (address_join, address_column) = address_query_parts(address_mode);
+ let query = format!(
+ "SELECT
origin_tx.hash as utxo_id_tx_hash,
tx_out.index as utxo_id_index,
origin_block.block_no as tx_block_no,
origin_block.slot_no as tx_slot_no,
origin_block.epoch_no as tx_epoch_no,
origin_tx.block_index as tx_index_in_block,
- tx_out.address,
- datum.value as datum,
- array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_out.index)) as tx_inputs
+ {address_column} as address,
+ datum.value::jsonb as datum,
+ array_agg(concat_ws('#', encode(consumes_tx.hash, 'hex'), consumes_tx_out.index)) as tx_inputs
FROM tx_out
+ {address_join}
INNER JOIN tx origin_tx ON tx_out.tx_id = origin_tx.id
INNER JOIN block origin_block ON origin_tx.block_id = origin_block.id
LEFT JOIN tx consuming_tx ON tx_out.consumed_by_tx_id = consuming_tx.id
@@ -423,7 +365,7 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
LEFT JOIN tx consumes_tx ON consumes_tx.id = consumes_tx_out.tx_id
LEFT JOIN datum ON tx_out.data_hash = datum.hash
WHERE
- tx_out.address = $1 AND origin_block.block_no <= $2
+ {address_column} = $1 AND origin_block.block_no <= $2
AND (tx_out.consumed_by_tx_id IS NULL OR consuming_block.block_no > $2)
GROUP BY (
utxo_id_tx_hash,
@@ -432,10 +374,11 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
tx_slot_no,
tx_epoch_no,
tx_index_in_block,
- tx_out.address,
+ {address_column},
datum
- )";
- let rows = sqlx::query_as::<_, MainchainTxOutputRow>(query)
+ )"
+ );
+ let rows = sqlx::query_as::<_, MainchainTxOutputRow>(&query)
.bind(&address.0)
.bind(block)
.fetch_all(pool)
@@ -445,52 +388,12 @@ pub(crate) async fn get_utxos_for_address_tx_in_consumed(
Ok(result?)
}
-/// Used by `get_token_utxo_for_epoch` (CandidatesDataSourceImpl),
-pub(crate) async fn create_idx_ma_tx_out_ident(pool: &Pool) -> Result<(), SqlxError> {
- let exists = index_exists(pool, "idx_ma_tx_out_ident").await?;
- if exists {
- info!("Index 'idx_ma_tx_out_ident' already exists");
- } else {
- let sql = "CREATE INDEX IF NOT EXISTS idx_ma_tx_out_ident ON ma_tx_out(ident)";
- info!("Executing '{}', this might take a while", sql);
- sqlx::query(sql).execute(pool).await?;
- info!("Index 'idx_ma_tx_out_ident' has been created");
- }
- Ok(())
-}
-
-/// Used by multiple queries across functionalities.
-pub(crate) async fn create_idx_tx_out_address(pool: &Pool) -> Result<(), SqlxError> {
- let exists = index_exists(pool, "idx_tx_out_address").await?;
- if exists {
- info!("Index 'idx_tx_out_address' already exists");
- } else {
- let sql = "CREATE INDEX IF NOT EXISTS idx_tx_out_address ON tx_out USING hash (address)";
- info!("Executing '{}', this might take a long time", sql);
- sqlx::query(sql).execute(pool).await?;
- info!("Index 'idx_tx_out_address' has been created");
+fn address_query_parts(address_mode: ResolvedDbSyncAddressMode) -> (&'static str, &'static str) {
+ match address_mode {
+ ResolvedDbSyncAddressMode::Inline => ("", "tx_out.address"),
+ ResolvedDbSyncAddressMode::AddressTable => (
+ "INNER JOIN address tx_out_address ON tx_out_address.id = tx_out.address_id",
+ "tx_out_address.address",
+ ),
}
- Ok(())
-}
-
-pub(crate) async fn create_idx_ma_tx_out_id_ident(pool: &Pool) -> Result<(), SqlxError> {
- let exists = index_exists(pool, "idx_ma_tx_out_id_ident").await?;
- if exists {
- info!("Index 'idx_ma_tx_out_id_ident' already exists");
- } else {
- let sql = "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_id_ident ON ma_tx_out (tx_out_id, ident)";
- info!("Executing '{}', this might take a while", sql);
- sqlx::query(sql).execute(pool).await?;
- info!("Index 'idx_ma_tx_out_id_ident' has been created");
- }
- Ok(())
-}
-
-/// Check if the index exists.
-async fn index_exists(pool: &Pool, index_name: &str) -> Result {
- sqlx::query("select * from pg_indexes where indexname = $1")
- .bind(index_name)
- .fetch_all(pool)
- .await
- .map(|rows| rows.len() == 1)
}
diff --git a/primitives/mainchain-follower/src/data_source/candidates_data_source/mod.rs b/primitives/mainchain-follower/src/data_source/candidates_data_source/mod.rs
index 0871ac3c2..74061e302 100644
--- a/primitives/mainchain-follower/src/data_source/candidates_data_source/mod.rs
+++ b/primitives/mainchain-follower/src/data_source/candidates_data_source/mod.rs
@@ -15,7 +15,10 @@
use crate::data_source::metrics::{MidnightDataSourceMetrics, start_sub_query_timer};
use crate::db::MultiAssetCache;
use authority_selection_inherents::*;
-use db_sync_sqlx::{Address, Asset, BlockNumber, EpochNumber};
+use db_sync_sqlx::{
+ Address, Asset, BlockNumber, DbSyncQueryConfig, DbSyncSchemaMode, EpochNumber,
+ ResolvedDbSyncQueryConfig, candidate_index_specs, manage_indexes,
+};
use itertools::Itertools;
use log::error;
use partner_chains_plutus_data::{
@@ -30,6 +33,9 @@ use std::error::Error;
pub mod cached;
mod db_model;
+#[cfg(test)]
+pub(crate) use db_model::get_utxos_for_address;
+
#[derive(Clone, Debug)]
struct ParsedCandidate {
utxo_info: UtxoInfo,
@@ -57,8 +63,8 @@ pub struct CandidatesDataSourceImpl {
pool: PgPool,
/// Prometheus metrics client
metrics_opt: Option,
- /// Configuration used by Db-Sync
- db_sync_config: db_model::DbSyncConfigurationProvider,
+ /// Validated query layout used by Db-Sync
+ db_sync_config: ResolvedDbSyncQueryConfig,
/// Cache for resolving multi_asset.id from (policy, name) pairs
multi_asset_cache: MultiAssetCache,
}
@@ -141,16 +147,20 @@ impl CandidatesDataSourceImpl {
pool: PgPool,
metrics_opt: Option,
) -> Result> {
- db_model::create_idx_ma_tx_out_ident(&pool).await?;
- db_model::create_idx_tx_out_address(&pool).await?;
- db_model::create_idx_ma_tx_out_id_ident(&pool).await?;
+ let config = DbSyncQueryConfig::default().resolve(&pool).await?;
+ manage_indexes(&pool, DbSyncSchemaMode::Apply, &candidate_index_specs(config)).await?;
+ Ok(Self::new_with_db_sync_config(pool, metrics_opt, config))
+ }
+
+ /// Creates a data source with an already validated db-sync query layout.
+ /// This constructor does not create or alter database objects.
+ pub fn new_with_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ db_sync_config: ResolvedDbSyncQueryConfig,
+ ) -> CandidatesDataSourceImpl {
let multi_asset_cache = MultiAssetCache::new(pool.clone());
- Ok(Self {
- pool: pool.clone(),
- metrics_opt,
- db_sync_config: db_model::DbSyncConfigurationProvider::new(pool),
- multi_asset_cache,
- })
+ Self { pool, metrics_opt, db_sync_config, multi_asset_cache }
}
/// Creates a new caching instance of the data source
@@ -188,7 +198,7 @@ impl CandidatesDataSourceImpl {
&self.pool,
&address,
block,
- self.db_sync_config.get_tx_in_config().await?,
+ self.db_sync_config,
)
.await?;
drop(_sq_timer);
diff --git a/primitives/mainchain-follower/src/data_source/cnight_observation.rs b/primitives/mainchain-follower/src/data_source/cnight_observation.rs
index 799961326..2540a1184 100644
--- a/primitives/mainchain-follower/src/data_source/cnight_observation.rs
+++ b/primitives/mainchain-follower/src/data_source/cnight_observation.rs
@@ -22,6 +22,9 @@ use cardano_serialization_lib::{
Address, BaseAddress, ConstrPlutusData, Credential, Ed25519KeyHash, EnterpriseAddress,
PlutusData, RewardAddress, ScriptHash,
};
+use db_sync_sqlx::{
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
use midnight_primitives_cnight_observation::{
CNightAddresses, CardanoPosition, CardanoRewardAddressBytes, DustPublicKeyBytes, ObservedUtxos,
};
@@ -89,6 +92,7 @@ pub struct MidnightCNightObservationDataSourceImpl {
#[allow(dead_code)]
cache_size: u16,
multi_asset_cache: MultiAssetCache,
+ db_sync_config: ResolvedDbSyncQueryConfig,
}
impl MidnightCNightObservationDataSourceImpl {
@@ -96,9 +100,26 @@ impl MidnightCNightObservationDataSourceImpl {
pool: PgPool,
metrics_opt: Option,
cache_size: u16,
+ ) -> Self {
+ Self::new_with_db_sync_config(
+ pool,
+ metrics_opt,
+ cache_size,
+ ResolvedDbSyncQueryConfig {
+ tx_input_mode: ResolvedDbSyncTxInputMode::TxIn,
+ address_mode: ResolvedDbSyncAddressMode::Inline,
+ },
+ )
+ }
+
+ pub fn new_with_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ cache_size: u16,
+ db_sync_config: ResolvedDbSyncQueryConfig,
) -> Self {
let multi_asset_cache = MultiAssetCache::new(pool.clone());
- Self { pool, metrics_opt, cache_size, multi_asset_cache }
+ Self { pool, metrics_opt, cache_size, multi_asset_cache, db_sync_config }
}
}
@@ -162,13 +183,21 @@ impl MidnightCNightObservationDataSource for MidnightCNightObservationDataSource
let (low_bounds, high_bounds) = tokio::try_join!(
async {
let _sq_timer = start_sub_query_timer(&self.metrics_opt, "cnight_get_low_bounds");
- crate::db::get_low_bounds(&self.pool, start_position.block_number.into())
+ crate::db::get_low_bounds(
+ &self.pool,
+ start_position.block_number.into(),
+ self.db_sync_config,
+ )
.await
.map_err(Into::>::into)
},
async {
let _sq_timer = start_sub_query_timer(&self.metrics_opt, "cnight_get_high_bounds");
- crate::db::get_high_bounds(&self.pool, end.block_number.into())
+ crate::db::get_high_bounds(
+ &self.pool,
+ end.block_number.into(),
+ self.db_sync_config,
+ )
.await
.map_err(Into::>::into)
},
@@ -348,7 +377,9 @@ impl MidnightCNightObservationDataSourceImpl {
address: &str,
query: &PagedQuery<'_>,
) -> Result, MidnightCNightObservationDataSourceError> {
- let rows = get_registrations(&self.pool, address, auth_token_ident, query).await?;
+ let rows =
+ get_registrations(&self.pool, address, auth_token_ident, query, self.db_sync_config)
+ .await?;
let mut utxos = Vec::new();
@@ -401,7 +432,7 @@ impl MidnightCNightObservationDataSourceImpl {
address: &str,
query: &PagedQuery<'_>,
) -> Result, MidnightCNightObservationDataSourceError> {
- let rows = get_deregistrations(&self.pool, address, query).await?;
+ let rows = get_deregistrations(&self.pool, address, query, self.db_sync_config).await?;
let mut utxos = Vec::new();
@@ -454,7 +485,8 @@ impl MidnightCNightObservationDataSourceImpl {
ident: i64,
query: &PagedQuery<'_>,
) -> Result, MidnightCNightObservationDataSourceError> {
- let rows = crate::db::get_asset_creates(&self.pool, ident, query).await?;
+ let rows =
+ crate::db::get_asset_creates(&self.pool, ident, query, self.db_sync_config).await?;
let mut utxos = Vec::new();
@@ -511,7 +543,8 @@ impl MidnightCNightObservationDataSourceImpl {
ident: i64,
query: &PagedQuery<'_>,
) -> Result, MidnightCNightObservationDataSourceError> {
- let rows = crate::db::get_asset_spends(&self.pool, ident, query).await?;
+ let rows =
+ crate::db::get_asset_spends(&self.pool, ident, query, self.db_sync_config).await?;
let mut utxos = Vec::new();
diff --git a/primitives/mainchain-follower/src/data_source/federated_authority_observation.rs b/primitives/mainchain-follower/src/data_source/federated_authority_observation.rs
index d4835cf17..58e6ee527 100644
--- a/primitives/mainchain-follower/src/data_source/federated_authority_observation.rs
+++ b/primitives/mainchain-follower/src/data_source/federated_authority_observation.rs
@@ -17,6 +17,9 @@ use crate::{
data_source::candidates_data_source::observed_async_trait, db::get_governance_body_utxo,
};
use cardano_serialization_lib::PlutusData;
+use db_sync_sqlx::{
+ ResolvedDbSyncAddressMode, ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode,
+};
use lru::LruCache;
use midnight_primitives_federated_authority_observation::{
AuthoritiesData, AuthorityMemberPublicKey, FederatedAuthorityData,
@@ -60,6 +63,7 @@ pub struct FederatedAuthorityObservationDataSourceImpl {
pub pool: PgPool,
pub metrics_opt: Option,
cache: Arc>>,
+ db_sync_config: ResolvedDbSyncQueryConfig,
}
impl FederatedAuthorityObservationDataSourceImpl {
@@ -67,10 +71,27 @@ impl FederatedAuthorityObservationDataSourceImpl {
pool: PgPool,
metrics_opt: Option,
cache_size: u16,
+ ) -> Self {
+ Self::new_with_db_sync_config(
+ pool,
+ metrics_opt,
+ cache_size,
+ ResolvedDbSyncQueryConfig {
+ tx_input_mode: ResolvedDbSyncTxInputMode::TxIn,
+ address_mode: ResolvedDbSyncAddressMode::Inline,
+ },
+ )
+ }
+
+ pub fn new_with_db_sync_config(
+ pool: PgPool,
+ metrics_opt: Option,
+ cache_size: u16,
+ db_sync_config: ResolvedDbSyncQueryConfig,
) -> Self {
let cap = NonZeroUsize::new(cache_size.max(1) as usize).unwrap();
let cache = Arc::new(Mutex::new(LruCache::new(cap)));
- Self { pool, metrics_opt, cache }
+ Self { pool, metrics_opt, cache, db_sync_config }
}
}
@@ -109,6 +130,7 @@ impl FederatedAuthorityObservationDataSource for FederatedAuthorityObservationDa
&config.council.address,
&config.council.policy_id,
block_number,
+ self.db_sync_config,
)
.await?;
drop(_council_timer);
@@ -143,6 +165,7 @@ impl FederatedAuthorityObservationDataSource for FederatedAuthorityObservationDa
&config.technical_committee.address,
&config.technical_committee.policy_id,
block_number,
+ self.db_sync_config,
)
.await?;
drop(_techcomm_timer);
diff --git a/primitives/mainchain-follower/src/db/queries/cnight_observation.rs b/primitives/mainchain-follower/src/db/queries/cnight_observation.rs
index 0832e2a61..c48fbc895 100644
--- a/primitives/mainchain-follower/src/db/queries/cnight_observation.rs
+++ b/primitives/mainchain-follower/src/db/queries/cnight_observation.rs
@@ -20,7 +20,11 @@ use crate::db::{
AssetCreateRow, AssetSpendRow, Block, DeregistrationRow, PagedQuery, QueryBounds,
RegistrationRow,
};
-use log::info;
+use db_sync_sqlx::{
+ DbSyncIndexSpec, DbSyncQueryConfig, DbSyncSchemaMode, ResolvedDbSyncAddressMode,
+ ResolvedDbSyncQueryConfig, ResolvedDbSyncTxInputMode, manage_indexes,
+};
+use log::{info, warn};
use sidechain_domain::*;
use sqlx::{Pool, Postgres, error::Error as SqlxError};
@@ -29,60 +33,64 @@ pub async fn get_registrations(
smart_contract_address: &str,
auth_token_ident: i64,
query: &PagedQuery<'_>,
+ config: ResolvedDbSyncQueryConfig,
) -> Result, SqlxError> {
assert!(query.limit < i32::MAX as usize);
assert!(query.offset < i32::MAX as usize);
- sqlx::query_as!(
- RegistrationRow,
+ let (address_join, address_column) = address_query_parts(config.address_mode);
+ let sql = format!(
r#"
SELECT
- datum.value::jsonb AS "full_datum!: _",
- block.block_no AS "block_number!: _",
- block.hash AS "block_hash: _",
- block.time AS "block_timestamp: _",
- tx.block_index AS "tx_index_in_block: _",
- tx.hash AS "tx_hash: _",
- tx_out.index AS "utxo_index: _"
+ datum.value::jsonb AS full_datum,
+ block.block_no AS block_number,
+ block.hash AS block_hash,
+ block.time AS block_timestamp,
+ tx.block_index AS tx_index_in_block,
+ tx.hash AS tx_hash,
+ tx_out.index AS utxo_index
FROM block
JOIN tx ON tx.block_id = block.id
JOIN tx_out ON tx_out.tx_id = tx.id
+ {address_join}
JOIN datum ON tx_out.data_hash = datum.hash
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
WHERE tx.id >= $9 AND tx.id <= $10
AND tx_out.id >= $11 AND tx_out.id <= $12
AND ma_tx_out.id >= $13 AND ma_tx_out.id <= $14
AND block.block_no >= $3 AND block.block_no <= $5
- AND tx_out.address = $1
+ AND {address_column} = $1
AND ma_tx_out.ident = $2
AND ma_tx_out.quantity = 1
AND (block.block_no > $3 OR (block.block_no = $3 AND tx.block_index >= $4))
AND (block.block_no < $5 OR (block.block_no = $5 AND tx.block_index < $6))
ORDER BY block.block_no, tx.block_index
LIMIT $7 OFFSET $8;
- "#,
- smart_contract_address,
- auth_token_ident,
- query.start.block_number as i32,
- query.start.tx_index_in_block as i32,
- query.end.block_number as i32,
- query.end.tx_index_in_block as i32,
- query.limit as i32,
- query.offset as i32,
- query.low_bound.tx_id,
- query.high_bound.tx_id,
- query.low_bound.tx_out_id,
- query.high_bound.tx_out_id,
- query.low_bound.ma_tx_out_id,
- query.high_bound.ma_tx_out_id,
- )
- .fetch_all(pool)
- .await
+ "#
+ );
+ sqlx::query_as::<_, RegistrationRow>(&sql)
+ .bind(smart_contract_address)
+ .bind(auth_token_ident)
+ .bind(query.start.block_number as i32)
+ .bind(query.start.tx_index_in_block as i32)
+ .bind(query.end.block_number as i32)
+ .bind(query.end.tx_index_in_block as i32)
+ .bind(query.limit as i32)
+ .bind(query.offset as i32)
+ .bind(query.low_bound.tx_id)
+ .bind(query.high_bound.tx_id)
+ .bind(query.low_bound.tx_out_id)
+ .bind(query.high_bound.tx_out_id)
+ .bind(query.low_bound.ma_tx_out_id)
+ .bind(query.high_bound.ma_tx_out_id)
+ .fetch_all(pool)
+ .await
}
pub async fn get_deregistrations(
pool: &Pool,
smart_contract_address: &str,
query: &PagedQuery<'_>,
+ config: ResolvedDbSyncQueryConfig,
) -> Result, SqlxError> {
assert!(query.limit < i32::MAX as usize);
assert!(query.offset < i32::MAX as usize);
@@ -90,72 +98,86 @@ pub async fn get_deregistrations(
// Once one valid deregistration can occur in a single tx, so we don't have to worry about
// ordering within txs
- sqlx::query_as!(
- DeregistrationRow,
+ let (address_join, address_column) = address_query_parts(config.address_mode);
+ let (input_join, input_bound) = match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => (
+ "JOIN tx_in ON tx_in.tx_in_id = tx.id\n JOIN tx_out ON tx_out.tx_id = tx_in.tx_out_id AND tx_out.index = tx_in.tx_out_index",
+ "AND tx_in.id >= $10 AND tx_in.id <= $11",
+ ),
+ ResolvedDbSyncTxInputMode::Consumed => {
+ ("JOIN tx_out ON tx_out.consumed_by_tx_id = tx.id", "")
+ },
+ };
+ let sql = format!(
r#"
SELECT
- datum.value::jsonb AS "full_datum!: _",
- block.block_no as "block_number!: _",
- block.hash as "block_hash: _",
- block.time as "block_timestamp: _",
- tx.block_index as "tx_index_in_block: _",
- tx.hash AS "tx_hash: _",
- tx_tx_out.hash as "utxo_tx_hash: _",
- tx_out.index as "utxo_index: _"
+ datum.value::jsonb AS full_datum,
+ block.block_no as block_number,
+ block.hash as block_hash,
+ block.time as block_timestamp,
+ tx.block_index as tx_index_in_block,
+ tx.hash AS tx_hash,
+ tx_tx_out.hash as utxo_tx_hash,
+ tx_out.index as utxo_index
FROM block
JOIN tx ON tx.block_id = block.id
- JOIN tx_in ON tx_in.tx_in_id = tx.id
- JOIN tx_out ON tx_out.tx_id = tx_in.tx_out_id
- AND tx_out.index = tx_in.tx_out_index
+ {input_join}
+ {address_join}
JOIN tx as tx_tx_out ON tx_out.tx_id = tx_tx_out.id
JOIN datum ON datum.hash = tx_out.data_hash
WHERE block.block_no >= $2 AND block.block_no <= $4
- AND tx_out.address = $1
+ AND {address_column} = $1
AND (block.block_no > $2 OR (block.block_no = $2 AND tx.block_index >= $3))
AND (block.block_no < $4 OR (block.block_no = $4 AND tx.block_index < $5))
AND tx.id >= $8 AND tx.id <=$9
- AND tx_in.id >= $10 AND tx_in.id <= $11
+ {input_bound}
ORDER BY block.block_no, tx.block_index
LIMIT $6 OFFSET $7;
- "#,
- smart_contract_address,
- query.start.block_number as i32,
- query.start.tx_index_in_block as i32,
- query.end.block_number as i32,
- query.end.tx_index_in_block as i32,
- query.limit as i32,
- query.offset as i32,
- query.low_bound.tx_id,
- query.high_bound.tx_id,
- query.low_bound.tx_in_id,
- query.high_bound.tx_in_id,
- )
- .fetch_all(pool)
- .await
+ "#
+ );
+ let query_builder = sqlx::query_as::<_, DeregistrationRow>(&sql)
+ .bind(smart_contract_address)
+ .bind(query.start.block_number as i32)
+ .bind(query.start.tx_index_in_block as i32)
+ .bind(query.end.block_number as i32)
+ .bind(query.end.tx_index_in_block as i32)
+ .bind(query.limit as i32)
+ .bind(query.offset as i32)
+ .bind(query.low_bound.tx_id)
+ .bind(query.high_bound.tx_id);
+ let query_builder = match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => {
+ query_builder.bind(query.low_bound.tx_in_id).bind(query.high_bound.tx_in_id)
+ },
+ ResolvedDbSyncTxInputMode::Consumed => query_builder,
+ };
+ query_builder.fetch_all(pool).await
}
pub(crate) async fn get_asset_creates(
pool: &Pool,
ident: i64,
query: &PagedQuery<'_>,
+ config: ResolvedDbSyncQueryConfig,
) -> Result, SqlxError> {
assert!(query.limit < i32::MAX as usize);
assert!(query.offset < i32::MAX as usize);
- sqlx::query_as!(
- AssetCreateRow,
+ let (address_join, address_column) = address_query_parts(config.address_mode);
+ let sql = format!(
r#"
SELECT
- block.block_no AS "block_number!: _",
- block.hash AS "block_hash: _",
- block.time AS "block_timestamp: _",
- tx.block_index AS "tx_index_in_block: _",
- ma_tx_out.quantity::BIGINT AS "quantity!",
- tx_out.address AS holder_address,
- tx.hash AS "tx_hash: _",
- tx_out.index AS "utxo_index: _"
+ block.block_no AS block_number,
+ block.hash AS block_hash,
+ block.time AS block_timestamp,
+ tx.block_index AS tx_index_in_block,
+ ma_tx_out.quantity::BIGINT AS quantity,
+ {address_column} AS holder_address,
+ tx.hash AS tx_hash,
+ tx_out.index AS utxo_index
FROM block
JOIN tx ON tx.block_id = block.id
JOIN tx_out ON tx_out.tx_id = tx.id
+ {address_join}
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
WHERE tx.id >= $8 AND tx.id <= $9
AND tx_out.id >= $10 AND tx_out.id <= $11
@@ -166,50 +188,60 @@ WHERE tx.id >= $8 AND tx.id <= $9
AND (block.block_no < $4 OR (block.block_no = $4 AND tx.block_index < $5))
ORDER BY block.block_no, tx.block_index, tx_out.index
LIMIT $6 OFFSET $7;
- "#,
- ident,
- query.start.block_number as i32,
- query.start.tx_index_in_block as i32,
- query.end.block_number as i32,
- query.end.tx_index_in_block as i32,
- query.limit as i32,
- query.offset as i32,
- query.low_bound.tx_id,
- query.high_bound.tx_id,
- query.low_bound.tx_out_id,
- query.high_bound.tx_out_id,
- query.low_bound.ma_tx_out_id,
- query.high_bound.ma_tx_out_id,
- )
- .fetch_all(pool)
- .await
+ "#
+ );
+ sqlx::query_as::<_, AssetCreateRow>(&sql)
+ .bind(ident)
+ .bind(query.start.block_number as i32)
+ .bind(query.start.tx_index_in_block as i32)
+ .bind(query.end.block_number as i32)
+ .bind(query.end.tx_index_in_block as i32)
+ .bind(query.limit as i32)
+ .bind(query.offset as i32)
+ .bind(query.low_bound.tx_id)
+ .bind(query.high_bound.tx_id)
+ .bind(query.low_bound.tx_out_id)
+ .bind(query.high_bound.tx_out_id)
+ .bind(query.low_bound.ma_tx_out_id)
+ .bind(query.high_bound.ma_tx_out_id)
+ .fetch_all(pool)
+ .await
}
pub(crate) async fn get_asset_spends(
pool: &Pool,
ident: i64,
query: &PagedQuery<'_>,
+ config: ResolvedDbSyncQueryConfig,
) -> Result, SqlxError> {
assert!(query.limit < i32::MAX as usize);
assert!(query.offset < i32::MAX as usize);
- sqlx::query_as!(
- AssetSpendRow,
+ let (address_join, address_column) = address_query_parts(config.address_mode);
+ let (input_join, input_bound) = match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => (
+ "JOIN tx_in ON tx_in.tx_in_id = spending_tx.id\n JOIN tx_out ON tx_out.tx_id = tx_in.tx_out_id AND tx_out.index = tx_in.tx_out_index",
+ "AND tx_in.id >= $10 AND tx_in.id <= $11",
+ ),
+ ResolvedDbSyncTxInputMode::Consumed => {
+ ("JOIN tx_out ON tx_out.consumed_by_tx_id = spending_tx.id", "")
+ },
+ };
+ let sql = format!(
r#"
SELECT
- spending_block.block_no AS "block_number!: _",
- spending_block.hash AS "block_hash: _",
- spending_block.time AS "block_timestamp: _",
- spending_tx.block_index AS "tx_index_in_block: _",
- ma_tx_out.quantity::BIGINT AS "quantity!",
- tx_out.address AS holder_address,
- tx.hash AS "utxo_tx_hash: _",
- tx_out.index AS "utxo_index: _",
- spending_tx.hash AS "spending_tx_hash: _"
+ spending_block.block_no AS block_number,
+ spending_block.hash AS block_hash,
+ spending_block.time AS block_timestamp,
+ spending_tx.block_index AS tx_index_in_block,
+ ma_tx_out.quantity::BIGINT AS quantity,
+ {address_column} AS holder_address,
+ tx.hash AS utxo_tx_hash,
+ tx_out.index AS utxo_index,
+ spending_tx.hash AS spending_tx_hash
FROM block AS spending_block
JOIN tx AS spending_tx ON spending_tx.block_id = spending_block.id
- JOIN tx_in ON tx_in.tx_in_id = spending_tx.id
- JOIN tx_out ON tx_out.tx_id = tx_in.tx_out_id
- AND tx_out.index = tx_in.tx_out_index
+ {input_join}
+ {address_join}
JOIN tx ON tx_out.tx_id = tx.id
JOIN ma_tx_out ON ma_tx_out.tx_out_id = tx_out.id
WHERE spending_block.block_no >= $2 AND spending_block.block_no <= $4
@@ -217,128 +249,155 @@ WHERE spending_block.block_no >= $2 AND spending_block.block_no <= $4
AND (spending_block.block_no > $2 OR (spending_block.block_no = $2 AND spending_tx.block_index >= $3))
AND (spending_block.block_no < $4 OR (spending_block.block_no = $4 AND spending_tx.block_index < $5))
AND spending_tx.id >= $8 AND spending_tx.id <=$9
- AND tx_in.id >= $10 AND tx_in.id <= $11
+ {input_bound}
ORDER BY spending_block.block_no, spending_tx.block_index, tx_out.index
LIMIT $6 OFFSET $7;
- "#,
- ident,
- query.start.block_number as i32,
- query.start.tx_index_in_block as i32,
- query.end.block_number as i32,
- query.end.tx_index_in_block as i32,
- query.limit as i32,
- query.offset as i32,
- query.low_bound.tx_id,
- query.high_bound.tx_id,
- query.low_bound.tx_in_id,
- query.high_bound.tx_in_id,
- )
- .fetch_all(pool)
- .await
-}
-
-async fn index_exists(pool: &Pool, index_name: &str) -> Result {
- sqlx::query("select * from pg_indexes where indexname = $1")
- .bind(index_name)
- .fetch_all(pool)
- .await
- .map(|rows| rows.len() == 1)
+ "#
+ );
+ let query_builder = sqlx::query_as::<_, AssetSpendRow>(&sql)
+ .bind(ident)
+ .bind(query.start.block_number as i32)
+ .bind(query.start.tx_index_in_block as i32)
+ .bind(query.end.block_number as i32)
+ .bind(query.end.tx_index_in_block as i32)
+ .bind(query.limit as i32)
+ .bind(query.offset as i32)
+ .bind(query.low_bound.tx_id)
+ .bind(query.high_bound.tx_id);
+ let query_builder = match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => {
+ query_builder.bind(query.low_bound.tx_in_id).bind(query.high_bound.tx_in_id)
+ },
+ ResolvedDbSyncTxInputMode::Consumed => query_builder,
+ };
+ query_builder.fetch_all(pool).await
}
-async fn create_index_if_not_exists(
- pool: &Pool,
- index_name: &str,
- sql: &str,
-) -> Result<(), sqlx::Error> {
- if index_exists(pool, index_name).await? {
- info!("Index '{index_name}' already exists");
- } else {
- info!("Creating index '{index_name}', this might take a while...");
- sqlx::query(sql).execute(pool).await?;
- info!("Index '{index_name}' has been created");
+fn address_query_parts(address_mode: ResolvedDbSyncAddressMode) -> (&'static str, &'static str) {
+ match address_mode {
+ ResolvedDbSyncAddressMode::Inline => ("", "tx_out.address"),
+ ResolvedDbSyncAddressMode::AddressTable => (
+ "JOIN address tx_out_address ON tx_out_address.id = tx_out.address_id",
+ "tx_out_address.address",
+ ),
}
- Ok(())
}
-/// Creates indexes that optimize the cNight observation queries.
-/// These are critical for genesis generation performance when scanning
-/// the full Cardano blockchain for registration/asset events.
-pub async fn create_cnight_observation_indexes(pool: &Pool) -> Result<(), sqlx::Error> {
- // For registrations & deregistrations: filter on tx_out.address
- create_index_if_not_exists(
- pool,
- "idx_tx_out_address",
- "CREATE INDEX IF NOT EXISTS idx_tx_out_address ON tx_out USING hash (address)",
- )
- .await?;
-
- // For asset creates & spends: filter on multi_asset(policy, name)
- create_index_if_not_exists(
- pool,
- "idx_multi_asset_policy_name",
- "CREATE INDEX IF NOT EXISTS idx_multi_asset_policy_name ON multi_asset(policy, name)",
- )
- .await?;
-
- // For ma_tx_out joins: composite index on (tx_out_id, ident) to efficiently join
- // from tx_out into ma_tx_out and resolve the multi_asset foreign key in a single lookup,
- // avoiding a full scan over ~1 billion rows.
- create_index_if_not_exists(
- pool,
- "idx_ma_tx_out_id_ident",
- "CREATE INDEX IF NOT EXISTS idx_ma_tx_out_id_ident ON ma_tx_out(tx_out_id, ident)",
- )
- .await?;
-
- // For block range scans
- create_index_if_not_exists(
- pool,
- "idx_block_block_no",
- "CREATE INDEX IF NOT EXISTS idx_block_block_no ON block(block_no)",
- )
- .await?;
+fn cnight_index_specs(config: ResolvedDbSyncQueryConfig) -> Vec {
+ let mut indexes = vec![
+ DbSyncIndexSpec {
+ name: "idx_ma_tx_out_ident",
+ relation: "ma_tx_out",
+ access_methods: &["btree"],
+ keys: &["ident"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_ident ON ma_tx_out(ident)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_multi_asset_policy_name",
+ relation: "multi_asset",
+ access_methods: &["btree"],
+ keys: &["policy", "name"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_multi_asset_policy_name ON multi_asset(policy, name)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_ma_tx_out_id_ident",
+ relation: "ma_tx_out",
+ access_methods: &["btree"],
+ keys: &["tx_out_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ma_tx_out_id_ident ON ma_tx_out(tx_out_id, ident)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_block_block_no",
+ relation: "block",
+ access_methods: &["btree"],
+ keys: &["block_no"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_block_block_no ON block(block_no)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_tx_block_id",
+ relation: "tx",
+ access_methods: &["btree"],
+ keys: &["block_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_block_id ON tx(block_id)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_tx_out_tx_id",
+ relation: "tx_out",
+ access_methods: &["btree"],
+ keys: &["tx_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_tx_id ON tx_out(tx_id)",
+ },
+ ];
- // For tx joins on block_id
- create_index_if_not_exists(
- pool,
- "idx_tx_block_id",
- "CREATE INDEX IF NOT EXISTS idx_tx_block_id ON tx(block_id)",
- )
- .await?;
-
- // For tx_out joins on tx_id
- create_index_if_not_exists(
- pool,
- "idx_tx_out_tx_id",
- "CREATE INDEX IF NOT EXISTS idx_tx_out_tx_id ON tx_out(tx_id)",
- )
- .await?;
+ match config.address_mode {
+ ResolvedDbSyncAddressMode::Inline => indexes.push(DbSyncIndexSpec {
+ name: "idx_tx_out_address",
+ relation: "tx_out",
+ access_methods: &["hash", "btree"],
+ keys: &["address"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_address ON tx_out USING hash(address)",
+ }),
+ ResolvedDbSyncAddressMode::AddressTable => indexes.extend([
+ DbSyncIndexSpec {
+ name: "idx_address_address",
+ relation: "address",
+ access_methods: &["hash", "btree"],
+ keys: &["address"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_address_address ON address USING hash(address)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_tx_out_address_id",
+ relation: "tx_out",
+ access_methods: &["btree"],
+ keys: &["address_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_address_id ON tx_out(address_id)",
+ },
+ ]),
+ }
- // For datum joins on data_hash
- create_index_if_not_exists(
- pool,
- "idx_tx_out_data_hash",
- "CREATE INDEX IF NOT EXISTS idx_tx_out_data_hash ON tx_out(data_hash)",
- )
- .await?;
+ match config.tx_input_mode {
+ ResolvedDbSyncTxInputMode::TxIn => indexes.extend([
+ DbSyncIndexSpec {
+ name: "idx_tx_in_tx_in_id",
+ relation: "tx_in",
+ access_methods: &["btree"],
+ keys: &["tx_in_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_in_tx_in_id ON tx_in(tx_in_id)",
+ },
+ DbSyncIndexSpec {
+ name: "idx_tx_in_tx_out_id_tx_out_index",
+ relation: "tx_in",
+ access_methods: &["btree"],
+ keys: &["tx_out_id", "tx_out_index"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_in_tx_out_id_tx_out_index ON tx_in(tx_out_id, tx_out_index)",
+ },
+ ]),
+ ResolvedDbSyncTxInputMode::Consumed => indexes.push(DbSyncIndexSpec {
+ name: "idx_tx_out_consumed_by_tx_id",
+ relation: "tx_out",
+ access_methods: &["btree"],
+ keys: &["consumed_by_tx_id"],
+ create_sql: "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tx_out_consumed_by_tx_id ON tx_out(consumed_by_tx_id)",
+ }),
+ }
- // For deregistration/spend joins on tx_in
- create_index_if_not_exists(
- pool,
- "idx_tx_in_tx_in_id",
- "CREATE INDEX IF NOT EXISTS idx_tx_in_tx_in_id ON tx_in(tx_in_id)",
- )
- .await?;
+ indexes
+}
- // For tx_in joins on (tx_out_id, tx_out_index)
- create_index_if_not_exists(
- pool,
- "idx_tx_in_tx_out_id_tx_out_index",
- "CREATE INDEX IF NOT EXISTS idx_tx_in_tx_out_id_tx_out_index ON tx_in(tx_out_id, tx_out_index)",
- )
- .await?;
+/// Applies or verifies all database optimizations used by cNIGHT genesis observation.
+pub async fn manage_cnight_observation_schema(
+ pool: &Pool,
+ config: ResolvedDbSyncQueryConfig,
+ mode: DbSyncSchemaMode,
+) -> Result<(), sqlx::Error> {
+ manage_indexes(pool, mode, &cnight_index_specs(config)).await?;
+ manage_cnight_observation_autovacuum_tuning(pool, config, mode).await
+}
- Ok(())
+/// Backward-compatible helper that applies cNIGHT indexes for the detected legacy layout.
+pub async fn create_cnight_observation_indexes(pool: &Pool) -> Result<(), sqlx::Error> {
+ let config = DbSyncQueryConfig::default().resolve(pool).await?;
+ manage_indexes(pool, DbSyncSchemaMode::Apply, &cnight_index_specs(config)).await
}
/// Lower autovacuum_analyze_scale_factor on the cardano-db-sync hot tables that
@@ -352,13 +411,53 @@ pub async fn create_cnight_observation_indexes(pool: &Pool) -> Result<
pub async fn apply_cnight_observation_autovacuum_tuning(
pool: &Pool,
) -> Result<(), sqlx::Error> {
- const TABLES: &[&str] = &["block", "tx", "tx_out", "tx_in", "ma_tx_out", "datum"];
- for table in TABLES {
- info!("Applying autovacuum tuning to '{table}'");
- let sql = format!(
- "ALTER TABLE {table} SET (autovacuum_analyze_scale_factor = 0.01, autovacuum_vacuum_scale_factor = 0.05)"
- );
- sqlx::query(&sql).execute(pool).await?;
+ let config = DbSyncQueryConfig::default().resolve(pool).await?;
+ manage_cnight_observation_autovacuum_tuning(pool, config, DbSyncSchemaMode::Apply).await
+}
+
+async fn manage_cnight_observation_autovacuum_tuning(
+ pool: &Pool,
+ config: ResolvedDbSyncQueryConfig,
+ mode: DbSyncSchemaMode,
+) -> Result<(), sqlx::Error> {
+ if mode == DbSyncSchemaMode::Skip {
+ warn!("Skipping db-sync autovacuum tuning and verification");
+ return Ok(());
+ }
+
+ let mut tables = vec!["block", "tx", "tx_out", "ma_tx_out", "datum"];
+ if config.tx_input_mode == ResolvedDbSyncTxInputMode::TxIn {
+ tables.push("tx_in");
+ }
+ if config.address_mode == ResolvedDbSyncAddressMode::AddressTable {
+ tables.push("address");
+ }
+
+ for table in tables {
+ if mode == DbSyncSchemaMode::Apply {
+ info!("Applying autovacuum tuning to '{table}'");
+ let sql = format!(
+ "ALTER TABLE {table} SET (autovacuum_analyze_scale_factor = 0.01, autovacuum_vacuum_scale_factor = 0.05)"
+ );
+ sqlx::query(&sql).execute(pool).await?;
+ continue;
+ }
+
+ let options = sqlx::query_scalar::<_, Option>>(
+ "SELECT reloptions FROM pg_catalog.pg_class WHERE oid = to_regclass($1)",
+ )
+ .bind(table)
+ .fetch_one(pool)
+ .await?
+ .unwrap_or_default();
+ let analyze_ok =
+ options.iter().any(|value| value == "autovacuum_analyze_scale_factor=0.01");
+ let vacuum_ok = options.iter().any(|value| value == "autovacuum_vacuum_scale_factor=0.05");
+ if !analyze_ok || !vacuum_ok {
+ warn!(
+ "Table '{table}' does not have Midnight's recommended autovacuum reloptions; cluster-level settings may still be sufficient"
+ );
+ }
}
Ok(())
}
@@ -394,26 +493,36 @@ WHERE hash = $1
pub async fn get_low_bounds(
pool: &Pool,
block_no: i64,
+ config: ResolvedDbSyncQueryConfig,
) -> Result