Skip to content

Scheduler dedup: blockchain_actions table allows duplicate Pending rows on event replay #836

@nksazonov

Description

@nksazonov

Summary

scheduleStateEnforcement in nitronode/store/database/blockchain_action.go unconditionally inserts a new blockchain_actions row via db.Create, with no dedup on (state_id, action_type) or (state_id, blockchain_id, action_type). Same-event replay enqueues a duplicate Pending action.

Hazard

When an event handler that schedules an action (most notably HandleEscrowDepositInitiatedScheduleInitiateEscrowDeposit) receives an equal-version replay — which can happen on indexer replay or chain reorg replay — a second blockchain_actions row is inserted for the same (state_id, action_type).

The action worker (GetActions, same file) filters by status=Pending and picks up both rows. The second broadcast reverts on chain (the contract state-machine rejects re-initialization of the same state), but the Node records the duplicate as a failed blockchain_actions row that an operator must inspect.

Why this is exposed

The event-handler version guards in nitronode/event_handlers/service.go (helper guardEventVersionMonotonic) use strict < (not <=) and deliberately admit equal-version replays as a no-op, because legitimate indexer-replay and reorg-replay re-deliver identical (channelId, stateVersion) pairs. Most of the handler body is idempotent on that replay path (UpdateChannel, RefreshUserEnforcedBalance, UpdateStateSigsIfMissing). Scheduling is the one side effect that is not idempotent — hence this gap.

Proposed fix

  1. Add a unique partial index on (state_id, action_type) for status=Pending rows.
  2. Change scheduleStateEnforcement to use INSERT ... ON CONFLICT DO NOTHING (or the GORM equivalent: clause.OnConflict{DoNothing: true}).

This makes the schedule path fully idempotent across the replay chain — the complement to the existing < (not <=) version guard in the event handlers.

Files involved

  • nitronode/store/database/blockchain_action.go (~L102–113) — the unconditional db.Create.
  • nitronode/store/database/ — wherever the model migrations live, for the unique-index migration.

Operational impact today

Low — duplicate broadcasts are bounded (one per replay event), revert on chain (gas cost only, no state corruption), and surface as visible failed blockchain_actions rows. No user funds at risk. This is a hygiene issue, not a safety issue.

Labels

area/nitronode, type/tech-debt, priority/low

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions