Add unique constraint on onchain_subscription_id/onchain_escrow_id to prevent double-execution - #61
Merged
abayomicornelius merged 6 commits intoAug 16, 2026
Conversation
Pre-existing bug, unrelated to StellarSend#56 but blocking the entire migration chain from ever applying cleanly to a fresh database: migration 005 indexed a 'payments' table with from_address/to_address columns that never existed under those names. The actual table (001_initial.sql) is 'transactions', with source_account/destination_account. Fixed to match the real schema.
…id (StellarSend#56) Neither subscriptions.onchain_subscription_id nor escrows.onchain_escrow_id had a uniqueness constraint, despite both being used as the literal on-chain contract argument identifying which real on-chain entity a keeper (subscriptions) or client-signed action (escrows) targets. Two DB rows sharing the same onchain_subscription_id would both be selected by run_due_executions's FOR UPDATE SKIP LOCKED sweep, double-executing the same real subscription in a single keeper pass. Partial (not plain UNIQUE) since both columns are nullable — a row can be created before its on-chain id is known, and multiple NULLs must remain permitted. If either table already has pre-existing duplicate non-null values, this migration fails to apply rather than silently installing a constraint that doesn't hold, surfacing the conflict for manual review instead of masking it.
…iptionService::create (StellarSend#56) Previously a unique-violation on the new partial index would fall through the generic sqlx::Error -> AppError::Database mapping (500). Explicitly catches it via is_unique_violation (mirroring services::batch's existing pattern) and returns AppError::Conflict instead, per StellarSend#56's acceptance criteria.
…cutions selection risk (StellarSend#56) Three DB-integration tests (real Postgres, #[ignore]d per the existing reconciliation::db_tests convention): - creating a second subscription with an already-used onchain_subscription_id is rejected with Conflict. - NULL onchain_subscription_id remains unrestricted (multiple rows with no on-chain id yet). - informational: with the new unique index temporarily dropped inside a rolled-back transaction (reproducing the pre-fix schema), two rows sharing one onchain_subscription_id are both selected by run_due_executions's exact due-selection query — motivating why the constraint needs to exist at the schema level, not just detected keeper-side. Not a required fix in this issue, per its own acceptance criteria.
…e::create (StellarSend#56) Same fix as SubscriptionService::create: explicitly catches the new partial index's unique-violation via is_unique_violation and returns AppError::Conflict instead of falling through to a generic 500.
StellarSend#56) Two DB-integration tests (real Postgres, #[ignore]d, same convention as subscription::db_tests): creating a second escrow with an already-used onchain_escrow_id is rejected with Conflict, and NULL onchain_escrow_id remains unrestricted.
Contributor
|
All ci passed |
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #56
subscriptions.onchain_subscription_idandescrows.onchain_escrow_idhad no uniqueness constraint at either the DB or application layer, despite both being used as the literal on-chain contract argument identifying which real on-chain entity a keeper (subscriptions) or client-signed action (escrows) targets. If two DB rows ever shared the sameonchain_subscription_id,SubscriptionService::run_due_executions'sFOR UPDATE SKIP LOCKEDsweep would select both independently, invokingexecute_subscriptiontwice against the same real on-chain subscription in a single keeper pass.Changes
011_unique_onchain_ids.sql: partialUNIQUEindex onsubscriptions.onchain_subscription_idandescrows.onchain_escrow_id, eachWHERE ... IS NOT NULL(both columns are nullable — a row can be created before its on-chain id is known, and multipleNULLs must remain permitted). If either table already has pre-existing duplicate non-null values, the migration fails to apply rather than silently installing a constraint that doesn't hold — surfacing the conflict for manual review, per the issue's own guidance.SubscriptionService::create/EscrowService::create: now explicitly catch the unique-violation (via anis_unique_violationhelper mirroring the existing pattern inservices::batch) and returnAppError::Conflict(409), instead of falling through to a generic 500.#[ignore]d#[tokio::test]s requiring a real Postgres viaDATABASE_URL, same convention as the existingreconciliation::db_tests):onchain_subscription_id/onchain_escrow_idoncreateis rejected withConflict.NULLids remain unrestricted (multiple rows with no on-chain id yet).onchain_subscription_idare both selected byrun_due_executions's exact due-selection query — demonstrating why the constraint needs to exist at the schema level.Also fixed: migration 005 never applied cleanly (pre-existing, unrelated to #56)
While setting up a local Postgres to actually run and verify the new tests,
migrations/005_add_indexes.sqlturned out to reference apaymentstable withfrom_address/to_addresscolumns that never existed under those names — the real table (001_initial.sql) istransactions, withsource_account/destination_account. This blocked the entire migration chain from applying to any fresh database. Fixed to match the real schema (own commit, clearly separated from the #56 work).Test plan
cargo test --bin stellarsend -- --include-ignored(real Postgres) — 34/34 relevant tests passing, including all 7 new ones. (2 pre-existing, unrelatedreconciliation::db_testsfailures reproduce identically on a cleanupstream/maincheckout before any of these changes — a missing seed-user FK and an environment-dependent assertion — confirmed out of scope.)cargo clippy --all-targets— no new warnings (one pre-existing warning in untouchedescrow.rscode).cargo fmt --check— no new formatting issues introduced (pre-existing drift elsewhere in the repo, untouched).cargo-deny check advisories/cargo-deny check bans licenses sources, per.github/workflows/deny.yml) — both pass locally; no dependency changes in this PR (Cargo.toml/Cargo.lockuntouched).