nautechsystems/nautilus_trader · critical
Duplicate fill event for position
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.
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 contains plaintext during rewrap
- Execution payload has no envelope during rewrap
- Execution payload rewrap left
- Account not found after cache update
- Account event does not exist for account
AI-assisted analysis of nautechsystems/nautilus_trader@18893faf8b (2026-09-08).
Data as JSON: /api/errors/860491a0b9a605aa.
Report an issue: GitHub.
Appendix: 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)