From 42b5bb24f737558562f9fec45e61817ee58bd4a7 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 20 May 2026 22:28:29 +0530 Subject: [PATCH 1/2] feat(prover-db-indexer): add OCW consumer that drains and forwards events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the HTTP+protobuf client from the previous PR; wires it into a Substrate offchain worker that: - Acquires an OCW storage lock (deadline configurable via `Config::OcwLockDeadlineMs`) so overlapping rounds don't double-submit. - Reads the indexer URL from offchain local storage (key in `sxt-core`) and skips silently if unset, so non-indexer nodes pay nothing. - Asks the server for its last checkpoint sequence number — the server is the sole source of truth, no local cursor. - Walks blocks `cursor+1..=tip` capped by `Config::MaxBlocksPerInvocation`, reads the per-block high-water-mark, probes `key_for_event(block, 0..=hwm)`, forwards each present payload via the HTTP client, and deletes the consumed offchain entries before checkpointing. Other notable bits: - `ConsumerError` Snafu enum keeps the consumer's error stack typed end-to-end; the offchain_worker hook formats the error with its Display impl. - Tests use a `TestOffchainExt` + recording-HTTP transport in the mock to exercise the full forward path, multi-block cursors, partial blocks, lock contention, and resumption from a server checkpoint. - `ROW_NUMBER_COLUMN_NAME` is the indexer's dedup-key column; lifted to `pub` in `commitment-sql` so the consumer can pass it through to `create_table` without duplicating the constant. - `MaxBlocksPerInvocation` (100) and `OcwLockDeadlineMs` (120_000) are exposed as `#[pallet::constant]` items so other chains can override them and so they show up in metadata for polkadot.js. --- Cargo.lock | 9 + pallets/prover_db_indexer/Cargo.toml | 18 + pallets/prover_db_indexer/src/lib.rs | 277 +++++++++++- pallets/prover_db_indexer/src/mock.rs | 50 +++ .../src/offchain_consumer.rs | 49 +++ pallets/prover_db_indexer/src/tests.rs | 399 ++++++++++++++++++ proof-of-sql/commitment-sql/src/lib.rs | 2 +- .../commitment-sql/src/row_number_column.rs | 6 +- runtime/src/lib.rs | 2 + 9 files changed, 800 insertions(+), 12 deletions(-) create mode 100644 pallets/prover_db_indexer/src/mock.rs create mode 100644 pallets/prover_db_indexer/src/offchain_consumer.rs create mode 100644 pallets/prover_db_indexer/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index bc31b047..e2b84f9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11715,8 +11715,17 @@ dependencies = [ name = "pallet-prover-db-indexer" version = "0.1.0" dependencies = [ + "commitment-sql", + "pallet-commitments", + "pallet-indexing", + "pallet-permissions", + "pallet-system-tables", + "pallet-tables", + "pallet-zkpay", "parity-scale-codec", + "parking_lot 0.12.3", "polkadot-sdk 0.9.0", + "proof-of-sql-commitment-map", "prost 0.13.5", "prost-build 0.13.5", "scale-info", diff --git a/pallets/prover_db_indexer/Cargo.toml b/pallets/prover_db_indexer/Cargo.toml index 64fcff73..6bfbe938 100644 --- a/pallets/prover_db_indexer/Cargo.toml +++ b/pallets/prover_db_indexer/Cargo.toml @@ -21,6 +21,7 @@ polkadot-sdk = { workspace = true, features = [ ]} sxt-core.workspace = true +commitment-sql.workspace = true prost = { version = "0.13", default-features = false, features = ["prost-derive"] } snafu = { workspace = true } url = { workspace = true } @@ -28,6 +29,22 @@ url = { workspace = true } [build-dependencies] prost-build = "0.13" +[dev-dependencies] +polkadot-sdk = { workspace = true, features = [ + "sp-core", "sp-io", "sp-runtime", "sp-staking", + "pallet-balances", "pallet-staking", "pallet-staking-reward-curve", + "pallet-session", "pallet-timestamp", + "frame-election-provider-support", +]} +pallet-permissions = { workspace = true, features = ["std"] } +pallet-commitments = { workspace = true, features = ["std"] } +pallet-tables = { workspace = true, features = ["std"] } +pallet-indexing = { workspace = true, features = ["std"] } +pallet-system-tables = { workspace = true, features = ["std"] } +pallet-zkpay = { workspace = true, features = ["std"] } +proof-of-sql-commitment-map.workspace = true +parking_lot = "0.12" + [features] default = ["std"] std = [ @@ -35,6 +52,7 @@ std = [ "codec/std", "scale-info/std", "sxt-core/std", + "commitment-sql/std", "prost/std", "snafu/std", "url/std", diff --git a/pallets/prover_db_indexer/src/lib.rs b/pallets/prover_db_indexer/src/lib.rs index 462dce45..f6e2eb05 100644 --- a/pallets/prover_db_indexer/src/lib.rs +++ b/pallets/prover_db_indexer/src/lib.rs @@ -18,11 +18,13 @@ //! captures. //! //! **Consumer** (`offchain_worker`, fires at chain tip only): -//! For each block in `cursor+1..=current`, reads the high-water-mark. -//! If absent, the block had zero captures and is skipped. Otherwise -//! probes `key_for_event(block, 0..=hwm)`, forwards each present -//! entry via HTTP+protobuf, checkpoints on the server, deletes -//! consumed entries, advances cursor. +//! Asks the indexer server for its last checkpoint sequence number, +//! walks blocks `cursor+1..=current` (capped at +//! [`MAX_BLOCKS_PER_INVOCATION`]). For each block, reads the +//! high-water-mark; if absent, the block had zero captures and is +//! skipped. Otherwise probes `key_for_event(block, 0..=hwm)`, +//! forwards each present payload via HTTP+protobuf, checkpoints on +//! the server, and deletes consumed entries from the offchain DB. //! //! ## Why extrinsic-time capture, not `on_finalize`? //! @@ -41,11 +43,14 @@ extern crate alloc; -// Wire-level HTTP+protobuf client for the indexer service. Unused until -// the OCW consumer (next PR) calls into it; allow dead_code so the build -// stays clean in the meantime. -#[allow(dead_code)] +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + mod http_client; +mod offchain_consumer; /// Generated protobuf types for the indexer HTTP adapter wire format. #[allow(missing_docs, clippy::missing_docs_in_private_items)] @@ -59,6 +64,59 @@ pub use pallet::*; /// keep working. pub use sxt_core::prover_db_indexer::PROVER_DB_URL_KEY; +/// Offchain DB key for the lock that serializes OCW consumer rounds. +const OCW_LOCK_KEY: &[u8] = b"prover_db_indexer/ocw_lock"; + +/// Typed errors from the OCW consumer round. Each variant carries the +/// underlying `http_client::Error` (when applicable) so the operator's +/// log line names both the failing operation and the wire-level reason. +/// +/// `source` fields aren't named `source` because `url::ParseError` +/// doesn't implement `core::error::Error` in this crate's no_std build, +/// which would break Snafu's source-chain wiring. `http_client::Error` +/// could use the wired name, but we keep the convention uniform so +/// every variant looks the same. +#[derive(Debug, snafu::Snafu)] +pub enum ConsumerError { + /// The configured indexer URL was not valid UTF-8. + #[snafu(display("indexer URL was not valid UTF-8"))] + InvalidUrlEncoding, + /// The configured indexer URL couldn't be parsed. + #[snafu(display("indexer URL is not a valid URL: {error}"))] + InvalidUrl { + /// Underlying parse error from the `url` crate. + error: url::ParseError, + }, + /// The runtime's `BlockNumber` didn't fit in `u64` (defensive — in + /// practice `BlockNumber` is `u32` so this is unreachable). + #[snafu(display("block number does not fit in u64"))] + BlockNumberOverflow, + /// `create_table` HTTP call failed. + #[snafu(display("create_table failed: {error}"))] + CreateTable { + /// Underlying error from the HTTP client. + error: http_client::Error, + }, + /// `drop_table` HTTP call failed. + #[snafu(display("drop_table failed: {error}"))] + DropTable { + /// Underlying error from the HTTP client. + error: http_client::Error, + }, + /// `put_batches` HTTP call failed. + #[snafu(display("put_batches failed: {error}"))] + PutBatches { + /// Underlying error from the HTTP client. + error: http_client::Error, + }, + /// `checkpoint` HTTP call failed. + #[snafu(display("checkpoint failed: {error}"))] + Checkpoint { + /// Underlying error from the HTTP client. + error: http_client::Error, + }, +} + #[polkadot_sdk::frame_support::pallet] #[allow( missing_docs, @@ -71,6 +129,7 @@ pub mod pallet { use codec::Encode; use polkadot_sdk::frame_support::pallet_prelude::*; + use polkadot_sdk::frame_system::pallet_prelude::*; use sxt_core::prover_db_indexer::{ key_for_event, key_for_high_water, @@ -78,6 +137,8 @@ pub mod pallet { EventCapture, }; + use crate::ConsumerError; + #[pallet::pallet] pub struct Pallet(_); @@ -86,6 +147,22 @@ pub mod pallet { /// The runtime's overarching event type. type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Maximum number of blocks the OCW will walk in a single + /// invocation. Controls catch-up speed: with 6s block time and + /// the default of 100, catch-up is ~100x realtime. Lower values + /// keep the per-round cost bounded; higher values close a wider + /// gap faster after downtime. + #[pallet::constant] + type MaxBlocksPerInvocation: Get; + + /// How long (in milliseconds) a held OCW lock stays valid before + /// being treated as abandoned (e.g. node crashed mid-round). Must + /// comfortably cover one full consumer round under normal + /// conditions while still letting the chain recover quickly from + /// a crashed validator. + #[pallet::constant] + type OcwLockDeadlineMs: Get; } #[pallet::event] @@ -103,6 +180,21 @@ pub mod pallet { }, } + #[pallet::hooks] + impl Hooks> for Pallet { + // Fires at chain tip only (not during sync). Drains the offchain + // DB queue, forwards to the HTTP server, deletes consumed entries. + fn offchain_worker(block_number: BlockNumberFor) { + if let Err(e) = Self::run_consumer(block_number) { + polkadot_sdk::sp_tracing::error!( + target: "prover_db_indexer", + "offchain indexer error: {}", + e, + ); + } + } + } + impl EventCapture for Pallet { fn capture_events(events: Vec>) { if events.is_empty() { @@ -130,4 +222,171 @@ pub mod pallet { polkadot_sdk::sp_io::offchain_index::set(&key_for_high_water(block), &ext_idx.encode()); } } + + impl Pallet { + // ═══════════════════════════════════════════════════════════════ + // CONSUMER: drain offchain DB → HTTP → delete (called from OCW) + // ═══════════════════════════════════════════════════════════════ + + fn run_consumer(block_number: BlockNumberFor) -> Result<(), ConsumerError> { + use polkadot_sdk::sp_runtime::offchain::storage::StorageValueRef; + use polkadot_sdk::sp_runtime::offchain::storage_lock::{StorageLock, Time}; + use polkadot_sdk::sp_runtime::offchain::Duration; + + // Serialize concurrent OCW invocations. Substrate spawns + // `offchain_worker` for every imported block, and rounds can + // overlap if one runs longer than block time. Without a lock, + // both rounds would read the same server checkpoint and + // submit duplicate `put_batches`/`create_table`/`drop_table` + // calls; the second one's `checkpoint()` would then fail with + // `failed_precondition`. Holding this lock for the full round + // makes overlapping invocations no-ops. + let mut lock = StorageLock::