diff --git a/Cargo.lock b/Cargo.lock index bc31b047..bceec54e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11715,7 +11715,9 @@ dependencies = [ name = "pallet-prover-db-indexer" version = "0.1.0" dependencies = [ + "commitment-sql", "parity-scale-codec", + "parking_lot 0.12.3", "polkadot-sdk 0.9.0", "prost 0.13.5", "prost-build 0.13.5", diff --git a/pallets/prover_db_indexer/Cargo.toml b/pallets/prover_db_indexer/Cargo.toml index 64fcff73..1a899b76 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,12 @@ url = { workspace = true } [build-dependencies] prost-build = "0.13" +[dev-dependencies] +# Only direct test deps. `polkadot-sdk` already comes through `[dependencies]` +# with `std` enabled in test builds, so we don't re-declare it here, and the +# mock+tests don't reference any of pallet-tables / pallet-indexing / etc. +parking_lot = "0.12" + [features] default = ["std"] std = [ @@ -35,6 +42,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..ae70b3b9 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,10 @@ pub mod pallet { use codec::Encode; use polkadot_sdk::frame_support::pallet_prelude::*; + use polkadot_sdk::frame_system::pallet_prelude::*; + 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; use sxt_core::prover_db_indexer::{ key_for_event, key_for_high_water, @@ -78,6 +140,8 @@ pub mod pallet { EventCapture, }; + use crate::ConsumerError; + #[pallet::pallet] pub struct Pallet(_); @@ -86,6 +150,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 +183,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 +225,167 @@ 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> { + // 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::