nautechsystems/nautilus_trader · critical
Duplicate fill event for position {position_id}: {}
Error message
Duplicate fill event for position {position_id}: {} What it means
This error is thrown by `load_position` in the SQL cache layer when rebuilding a `Position` from persisted fill events. After constructing the position from the first fill, each remaining fill's `trade_id` is checked against `position.trade_ids()`; if a fill with a trade_id already applied to the position appears again, the load aborts. It indicates the persisted position event stream contains duplicate fills, so the position cannot be reconstructed deterministically.
Source
Thrown at crates/infrastructure/src/sql/queries.rs:960
.map_err(|e| anyhow::anyhow!("Failed to decode position replay state: {e}"));
}
let fills = Self::load_position_events(pool, position_id).await?;
let Some((first_fill, remaining_fills)) = fills.split_first() else {
return Ok(None);
};
let Some(instrument) = Self::load_instrument(pool, &first_fill.instrument_id).await? else {
log::error!(
"Instrument not found for position {position_id}: {}",
first_fill.instrument_id
);
return Ok(None);
};
let mut position = Position::new(&instrument, first_fill.clone());
for fill in remaining_fills {
if position.trade_ids().contains(&fill.trade_id) {
anyhow::bail!(
"Duplicate fill event for position {position_id}: {}",
fill.trade_id
);
}
position.apply(fill);
}
Ok(Some(position))
}
/// Loads and replays all `Position` entries via the provided `pool`.
///
/// # Errors
///
/// Returns an error if loading position IDs or replaying any position fails.
pub async fn load_positions(pool: &PgPool) -> anyhow::Result<Vec<Position>> {
let position_ids: Vec<PositionId> = sqlx::query(
r#"View on GitHub (pinned to 18893faf8b)
Solutions
- Inspect the persisted position events for the given position_id and delete the duplicate fill rows (dedupe on trade_id).
- Find the writer that inserted the fill twice (usually a retry after timeout without idempotency) and make the INSERT idempotent, e.g. a UNIQUE constraint on (account_id, instrument_id, trade_id) with ON CONFLICT DO NOTHING.
- If the duplicate came from an adapter re-emitting fills, guard upstream where fills are persisted (skip events whose trade_id already exists).
Example fix
// before: blindly inserting each fill event
sqlx::query("INSERT INTO position_events (...) VALUES (...)")
.execute(pool).await?;
// after: make persistence idempotent on trade_id
sqlx::query(
"INSERT INTO position_events (...) VALUES (...) ON CONFLICT (trade_id) DO NOTHING",
)
.execute(pool).await?; Defensive patterns
Strategy: try-catch
Try / catch
match PositionQueries::load_position(&pool, position_id).await {
Ok(Some(pos)) => pos,
Ok(None) => return /* position absent */,
Err(e) if e.to_string().contains("Duplicate fill event") => {
// quarantine the position rows and re-ingest from source of truth
log::error!("corrupt position stream: {e}");
/* re-ingest */
}
Err(e) => return Err(e),
} Prevention
- Add a UNIQUE constraint on trade_id in persisted fill/position events and use ON CONFLICT DO NOTHING.
- Make all event-persistence writes idempotent so retries cannot double-insert.
- Monitor for duplicate trade_ids when copying or migrating cache data.
When it happens
Trigger: Calling `PositionQueries::load_position(pool, position_id)` where the rows fetched for that position (beyond the first fill) contain a `trade_id` already present in the position's trade_ids — i.e. the `position` events table has a duplicate fill/`position_opened`/`position_modified` event with the same trade_id.
Common situations: Double-writing the same fill event to Postgres (e.g. a retried INSERT without deduplication, or two writers persisting the same trade), a partially-failed batch insert that was re-run, or a corrupted/manual copy of rows between environments.
Understand the failure class
Background: Database query failed: Internal Server Error 500s wrapping SQL, Prisma, and connection failures — what to check first — this error's family across 16 libraries.
Related errors
- Execution payload rewrap left {remaining} row(s)
- Execution payload {} has no envelope during rewrap
- Execution payload {} contains plaintext during rewrap
- Replacement hash {transaction_hash} conflicts with another i
- Included wrap transaction {tx_hash} has invalid block number
AI-assisted analysis of nautechsystems/nautilus_trader@18893faf8b (2026-09-08).
Data as JSON: /api/errors/860491a0b9a605aa.
Report an issue: GitHub.