Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions crates/backfill/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,27 @@ fn event_uuid(contract_id: &str, ledger_sequence: u64, event_index: u32) -> Uuid
Uuid::new_v5(&EVENT_NS, key.as_bytes())
}

pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), TridentError> {
/// Insert a backfilled event.
///
/// Duplicate handling uses two complementary strategies:
/// - **Primary**: `ON CONFLICT (ledger_sequence, id) DO NOTHING` deduplicates
/// replays, because `id` is a deterministic UUIDv5 of
/// `(contract_id, ledger_sequence, event_index)`. The conflict target must
/// include `ledger_sequence`: `soroban_events` is RANGE-partitioned on it
/// (migration 0017), so the partition key is part of every unique index.
/// - **Safety net**: `UNIQUE (transaction_hash, event_index, network)` at the
/// DB layer (migration 0025) catches any case where the same protocol event
/// would be inserted under a different derived `id`.
///
/// `network` must match the value used in `indexed_contracts` for this
/// deployment (e.g. `"mainnet"` or `"testnet"`); the natural-key constraint is
/// network-scoped because the same transaction hash can legitimately appear on
/// more than one network.
pub async fn insert_event(
pool: &PgPool,
event: &SorobanEvent,
network: &str,
) -> Result<(), TridentError> {
let id = event_uuid(&event.contract_id, event.ledger_sequence, event.event_index);
let event_type = match event.event_type {
trident_common::EventType::Contract => "contract",
Expand All @@ -26,8 +46,8 @@ pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), Tri
r#"
INSERT INTO soroban_events
(id, contract_id, ledger_sequence, ledger_timestamp, transaction_hash,
event_index, event_type, topics, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
event_index, event_type, topics, data, network)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (ledger_sequence, id) DO NOTHING
"#,
)
Expand All @@ -40,6 +60,7 @@ pub async fn insert_event(pool: &PgPool, event: &SorobanEvent) -> Result<(), Tri
.bind(event_type)
.bind(&topics)
.bind(&event.data)
.bind(network)
.execute(pool)
.await
.map_err(|e| TridentError::storage(anyhow::Error::new(e).context("insert_event")))?;
Expand Down
3 changes: 2 additions & 1 deletion crates/backfill/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let duplicates_skipped = duplicates_skipped.clone();
let pb = pb.clone();
let rpc_delay = args.rpc_delay_ms;
let network = args.network.clone();

let handle = tokio::spawn(async move {
while let Some((s, e)) = rx.lock().await.recv().await {
Expand Down Expand Up @@ -125,7 +126,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("DRY: event {:?}", ev);
}
} else {
match db::insert_event(&db, &ev).await {
match db::insert_event(&db, &ev, &network).await {
Ok(_) => {
events_indexed.fetch_add(1, Ordering::Relaxed);
}
Expand Down
72 changes: 72 additions & 0 deletions database/migrations/0025_soroban_events_natural_key.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- Migration 0025: enforce natural-key uniqueness on soroban_events
-- ---------------------------------------------------------------------------
-- Decision record
-- ---------------
-- The `id` column is a deterministic UUIDv5 derived from
-- (contract_id, ledger_sequence, event_index) — it is NOT a pure function of
-- the Stellar protocol's natural key (transaction_hash, event_index).
-- Therefore a separate unique constraint on the natural key is needed to:
-- 1. Guard against genuine duplicates that would produce different UUIDs
-- (e.g. same tx/index pair arriving via two different code paths with
-- different contract_id or ledger_sequence values due to a bug).
-- 2. Provide a constraint target that mirrors the Stellar protocol guarantee:
-- within a given network a (transaction_hash, event_index) pair
-- identifies exactly one event. (Scoped per-partition in practice — see
-- the note on ledger_sequence at the ALTER TABLE below.)
-- 3. Future-proof the schema against id-scheme changes without losing
-- the correctness guarantee.
--
-- The constraint is network-scoped because the same transaction hash CAN
-- appear on different networks in test/local environments, and because
-- the `network` column is the partition key candidate per issue #244.
--
-- The existing `ON CONFLICT (id) DO NOTHING` insert strategy is preserved
-- as-is; the natural-key constraint is an additional safety net at the DB
-- layer that catches any bugs in id derivation.
-- ---------------------------------------------------------------------------

-- Validate existing data before adding the constraint.
-- If any duplicates exist they will surface here and must be resolved before
-- deploying this migration. In a fresh database (CI, new deployments) this
-- is a no-op.
DO $$
DECLARE
dup_count BIGINT;
BEGIN
SELECT COUNT(*)
INTO dup_count
FROM (
SELECT transaction_hash, event_index, network
FROM soroban_events
GROUP BY transaction_hash, event_index, network
HAVING COUNT(*) > 1
) sub;

IF dup_count > 0 THEN
RAISE EXCEPTION
'Cannot add natural-key constraint: % duplicate (transaction_hash, event_index, network) group(s) found. '
'Resolve duplicates before applying this migration.',
dup_count;
END IF;
END $$;

-- Add the unique constraint.
--
-- ledger_sequence is part of the key only because it has to be: migration
-- 0017 made soroban_events RANGE-partitioned on that column, and PostgreSQL
-- requires every unique constraint on a partitioned table to include the
-- partition key. `UNIQUE (transaction_hash, event_index, network)` alone is
-- rejected outright — the same trade-off 0017 already documents for the
-- primary key, which became (ledger_sequence, id) for this reason.
--
-- What this costs: the constraint no longer catches the same
-- (transaction_hash, event_index, network) triple appearing under two
-- *different* ledger_sequence values. That would mean the indexer recorded
-- one protocol event against two different ledgers, which is a distinct bug
-- from the duplicate-insert case this migration exists to catch, and it is
-- still caught by the pre-flight check above on any existing data. Within a
-- ledger — where duplicates actually arise, from replays and overlapping
-- code paths — the guarantee is unchanged.
ALTER TABLE soroban_events
ADD CONSTRAINT uq_soroban_events_tx_index_network
UNIQUE (ledger_sequence, transaction_hash, event_index, network);
6 changes: 3 additions & 3 deletions database/schema.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-- Trident PostgreSQL Schema
-- Convenience full-schema snapshot for local/dev bootstrap and documentation.
-- The migration chain in ./migrations/ (0001-0013) is the source of truth and is
-- The migration chain in ./migrations/ (0001-0025) is the source of truth and is
-- what CI and production apply; this file must mirror the end state of that chain.
-- Keep in sync whenever a migration is added.

Expand Down Expand Up @@ -165,7 +165,7 @@ CREATE INDEX IF NOT EXISTS idx_parse_errors_occurred_at ON parse_errors (occurre
-- ---------------------------------------------------------------------------
-- event_outbox
-- Transactional outbox guaranteeing every committed event reaches the Redis
-- stream at least once (migration 0010).
-- stream at least once (migration 0011).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS event_outbox (
seq BIGSERIAL PRIMARY KEY,
Expand Down Expand Up @@ -241,7 +241,7 @@ CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_subscription_id ON webhook_del

-- ---------------------------------------------------------------------------
-- usage_rollup
-- Per-API-key daily usage rollup, aggregated from audit_log (migration 0010).
-- Per-API-key daily usage rollup, aggregated from audit_log (migration 0024).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS usage_rollup (
api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
Expand Down
Loading