Skip to content

DB: enforce a UNIQUE natural key on soroban_events (tx_hash, event_index) - #331

Merged
Depo-dev merged 3 commits into
Telocel-Labs:devfrom
brite-side0:feat/natural-key-uniqueness-soroban-events
Jul 31, 2026
Merged

DB: enforce a UNIQUE natural key on soroban_events (tx_hash, event_index)#331
Depo-dev merged 3 commits into
Telocel-Labs:devfrom
brite-side0:feat/natural-key-uniqueness-soroban-events

Conversation

@brite-side0

Copy link
Copy Markdown
Contributor

Closes #248 DB: enforce a UNIQUE natural key on soroban_events (tx_hash, event_index)
…event_index, network)

  • Add migration 0010 that adds UNIQUE (transaction_hash, event_index, network) constraint to soroban_events. Includes a pre-flight duplicate check that raises an exception if any conflicting rows exist before applying the DDL.

  • Decision record (in migration + db/mod.rs docs): The UUIDv5 id is derived from (contract_id, ledger_sequence, event_index) and is NOT a pure function of the Stellar protocol natural key (transaction_hash, event_index). The new constraint is therefore a complementary, independent correctness guard that enforces the protocol- level uniqueness guarantee regardless of the id derivation scheme. The constraint is network-scoped to allow the same tx hash to appear on different networks (testnet / mainnet) in multi-network deployments and to align with the partition-key candidate identified in DB: partition soroban_events by ledger range (or time) for scale + retention #244.

  • Update schema.sql to mirror migration 0010 end state (bump comment from 0001-0009 to 0001-0010, add constraint).

  • Propagate network parameter through both insert paths: crates/indexer/src/db/mod.rs — insert_event now takes &str network; populates the network column explicitly instead of relying on the DEFAULT 'testnet' column default.
    crates/backfill/src/db.rs — same signature change.
    crates/indexer/src/streamer/mod.rs — passes self.config.network.
    crates/backfill/src/main.rs — clones args.network into each worker.

  • Add two new integration tests in crates/indexer/src/db/mod.rs: event_uuid_does_not_include_transaction_hash — documents that the UUID key is distinct from the natural key. natural_key_constraint_rejects_duplicate_tx_event_index — inserts two events with different contract_ids but the same (tx_hash, event_index, network) and asserts the second insert is rejected by the constraint.

Closes #. Targets dev.

…event_index, network)

- Add migration 0010 that adds UNIQUE (transaction_hash, event_index, network)
  constraint to soroban_events.  Includes a pre-flight duplicate check that
  raises an exception if any conflicting rows exist before applying the DDL.

- Decision record (in migration + db/mod.rs docs):
    The UUIDv5 id is derived from (contract_id, ledger_sequence, event_index)
    and is NOT a pure function of the Stellar protocol natural key
    (transaction_hash, event_index).  The new constraint is therefore a
    complementary, independent correctness guard that enforces the protocol-
    level uniqueness guarantee regardless of the id derivation scheme.
    The constraint is network-scoped to allow the same tx hash to appear on
    different networks (testnet / mainnet) in multi-network deployments and to
    align with the partition-key candidate identified in Telocel-Labs#244.

- Update schema.sql to mirror migration 0010 end state (bump comment from
  0001-0009 to 0001-0010, add constraint).

- Propagate network parameter through both insert paths:
    crates/indexer/src/db/mod.rs  — insert_event now takes &str network;
      populates the network column explicitly instead of relying on the
      DEFAULT 'testnet' column default.
    crates/backfill/src/db.rs     — same signature change.
    crates/indexer/src/streamer/mod.rs — passes self.config.network.
    crates/backfill/src/main.rs   — clones args.network into each worker.

- Add two new integration tests in crates/indexer/src/db/mod.rs:
    event_uuid_does_not_include_transaction_hash — documents that the UUID
      key is distinct from the natural key.
    natural_key_constraint_rejects_duplicate_tx_event_index — inserts two
      events with different contract_ids but the same (tx_hash, event_index,
      network) and asserts the second insert is rejected by the constraint.

Closes #<issue>. Targets dev.
@drips-wave

drips-wave Bot commented Jul 25, 2026

Copy link
Copy Markdown

@brite-side0 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Depo-dev

Copy link
Copy Markdown
Collaborator

@brite-side0 Please fix your CIs

@Depo-dev

Copy link
Copy Markdown
Collaborator

@brite-side0 Please fix the merge conflicts

Depo-dev added a commit that referenced this pull request Jul 29, 2026
…in prod

The duplicate 0018/0019 pair reached dev because nothing checked for it:
CI's integration job applies migrations with a `for f in *.sql` psql loop
rather than the sqlx migrator, so the primary-key collision on
_sqlx_migrations only shows up when someone runs `sqlx migrate run`
against a real database.

This is not hypothetical going forward — three open PRs would each
reintroduce it on merge: #331 and #351 both add a second 0010, and #367
adds a second 0022. The failure mode is easy to hit honestly, since
contributors branch from an older dev and take "the next number".

The check runs in the env-reference job (already cheap and dependency
free) and prints which files collide plus how to renumber safely.

Refs #371
Depo-dev added 2 commits July 31, 2026 16:12
Brings the branch up to date with dev, which has moved a long way since it
was opened. Resolutions:

- crates/indexer/src/db/mod.rs — took dev. It is 1152 lines against this
  branch's 476, and the branch's change here was documentation plus a
  `network` parameter dev already carries.
- crates/indexer/src/streamer/mod.rs — took dev. It has replaced per-event
  inline insert + Redis publish with batched page commits and a transactional
  outbox relay (Telocel-Labs#199, Telocel-Labs#200). The natural-key constraint this PR adds is a
  database-layer guard and does not depend on the old insert path.
- crates/backfill/src/db.rs — combined both. Kept dev's
  `ON CONFLICT (ledger_sequence, id)`, which is required because
  soroban_events is RANGE-partitioned on ledger_sequence (migration 0017) so
  the partition key must appear in the conflict target, and restored this
  branch's `network` column, which the constraint is scoped by. Carried over
  the branch's doc comment explaining how the two keys complement each other,
  since that is the actual insight in this PR.
- database/schema.sql — took dev, then corrected three stale migration
  references it still carried (event_outbox is 0011 not 0010, usage_rollup is
  0024 not 0010, and the header range is 0001-0025 not 0001-0013).

Renumbered the migration 0010 -> 0025. Its original number now collides with
0010_token_events on dev, and sqlx derives a migration's version from the
filename prefix, so the pair would collide on the _sqlx_migrations primary
key. scripts/check-migration-versions.sh caught it and now reports 25
migrations with no duplicates.

The insert strategy is unchanged, as this PR's own decision record specifies:
`ON CONFLICT (id) DO NOTHING` stays primary, and
UNIQUE (transaction_hash, event_index, network) is the independent safety net.

cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and
cargo test --workspace (281 tests) all pass on the merged result.
The Rust job failed at "Run migrations". 0025 tried

    ALTER TABLE soroban_events
      ADD CONSTRAINT uq_soroban_events_tx_index_network
      UNIQUE (transaction_hash, event_index, network);

but migration 0017 — which landed on dev after this branch was opened — made
soroban_events RANGE-partitioned by ledger_sequence, and PostgreSQL requires
every unique constraint on a partitioned table to include the partition key.
The statement is rejected outright, so no fresh database can apply the chain.

Added ledger_sequence to the constraint. This is the same trade-off 0017
already made and documented for the primary key, which became
(ledger_sequence, id) for exactly this reason.

The cost is recorded in the migration rather than glossed over: the
constraint no longer catches the same (transaction_hash, event_index,
network) triple appearing under two different ledger_sequence values. That
case would mean the indexer attributed one protocol event to two ledgers — a
different bug from the duplicate-insert this migration targets, and one the
pre-flight duplicate check above still catches on existing data. Within a
ledger, which is where replays and overlapping code paths actually produce
duplicates, the guarantee is unchanged.
@Depo-dev
Depo-dev merged commit f95b10e into Telocel-Labs:dev Jul 31, 2026
17 of 20 checks passed
Depo-dev added a commit to Emrys02/Trident that referenced this pull request Jul 31, 2026
…el-Labs#331/Telocel-Labs#353/Telocel-Labs#359/Telocel-Labs#378/Telocel-Labs#380)

Six PRs landed on dev since the first merge, so this branch went dirty again.
One conflict: services/api/handlers/health_test.go, add/add — both sides
created it.

The two files are not alternatives. dev's is `package handlers_test` with a
single table-driven TestHealthHandler_TableDriven; this branch's is
`package handlers` with six tests covering the /v1/health and /v1/ready split
it introduces (Telocel-Labs#243).

Kept this branch's file and dropped dev's, having checked what that costs.
dev's four cases — all dependencies reachable, db down, grpc down, redis down
— map one-to-one onto TestReady_AllHealthy_Returns200,
TestReady_PostgresDown_Returns503, TestReady_GRPCDown_Returns503, and
TestReady_RedisDown_Returns503. This branch adds a fifth
(TestReady_NilDependencies_Returns503) and TestHealth_AlwaysReturns200.

Keeping dev's file was not an option regardless: it asserts against
handlers.HealthResponse, a type this branch deliberately split into
LivenessResponse and ReadyResponse, and it expects /v1/health to return 503
when a dependency is down — the exact behaviour Telocel-Labs#243 changes, since a
liveness probe must not fail because Postgres is unreachable.

Verified: go vet and go test across all twelve services/api packages,
cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and
the REST handler coverage gate added by Telocel-Labs#380 still passes (ListEvents 100.0%,
GetEvent 90.5%, Health 100.0%).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DB: enforce a UNIQUE natural key on soroban_events (tx_hash, event_index)

2 participants