Skip to content

feat(db): Fix sync duplicate key error#269

Open
tolbrino wants to merge 11 commits intomainfrom
tb/202603-sync-issue
Open

feat(db): Fix sync duplicate key error#269
tolbrino wants to merge 11 commits intomainfrom
tb/202603-sync-issue

Conversation

@tolbrino
Copy link
Copy Markdown
Contributor

@tolbrino tolbrino commented Mar 17, 2026

Fixes #233

Skip over state entries which we already know. As already done in other code-paths in the code base.

@tolbrino tolbrino self-assigned this Mar 17, 2026
Copilot AI review requested due to automatic review settings March 17, 2026 11:58
@tolbrino tolbrino enabled auto-merge (squash) March 17, 2026 11:58
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes implement idempotent database insertions for account and channel state records using ON CONFLICT DO NOTHING directives to prevent duplicate key errors during restart scenarios. A minor version bump and two RustSec advisory entries are also added.

Changes

Cohort / File(s) Summary
Version and Security
db/core/Cargo.toml, .cargo/audit.toml
Version bump from 0.15.0 to 0.15.1; adds two RustSec advisory IDs (RUSTSEC-2026-0049, RUSTSEC-2026-0097) to audit ignore list.
Idempotent Account Insertion
db/core/src/accounts.rs
Converts account identity insertion to use ON CONFLICT (ChainKey, PacketKey) DO NOTHING with natural-key lookup on success; makes account state insertion idempotent via ON CONFLICT on temporal coordinates with no-op on RecordNotInserted; adds test verifying idempotent behavior.
Idempotent Channel Insertion
db/core/src/channels.rs
Converts channel state insertion to use ON CONFLICT on temporal coordinates; introduces is_new flag to distinguish first insert from conflict; gates downstream event emission to prevent duplicate StateChange::ChannelState events; re-queries existing rows on conflict; adds test validating idempotent behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(db): Fix sync duplicate key error' clearly describes the main change: fixing a sync-related duplicate key error issue.
Description check ✅ Passed The description references issue #233 and explains the fix aligns with other code-paths by skipping already-known state entries during sync.
Linked Issues check ✅ Passed The code changes implement idempotent upserts with ON CONFLICT DO NOTHING for both account and channel state insertions, preventing duplicate key errors on restart during sync.
Out of Scope Changes check ✅ Passed Version bump and audit ignore entries are necessary supporting changes; all modifications target the sync duplicate key issue with idempotent upsert patterns.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tb/202603-sync-issue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses duplicate key crashes during initial sync when a blokli instance is restarted, by making account/channel state inserts idempotent at the same (block, tx_index, log_index) position.

Changes:

  • Add ON CONFLICT DO NOTHING handling for channel_state inserts to avoid duplicate key crashes on replay.
  • Add ON CONFLICT DO NOTHING handling for account identity insert race and account_state inserts for idempotency.
  • Add regression tests asserting upsert_account / upsert_channel are idempotent for identical positions; bump blokli-db version.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

File Description
db/core/src/channels.rs Makes channel_state insertion idempotent on unique position and adds a test for double-insert behavior.
db/core/src/accounts.rs Makes account identity insertion and account_state insertion idempotent and adds a test for double-insert behavior.
db/core/Cargo.toml Bumps blokli-db version to 0.12.3.
Cargo.lock Updates lockfile for the blokli-db version bump.

Comment thread db/core/src/channels.rs Outdated
Comment thread db/core/src/channels.rs
Comment thread db/core/src/accounts.rs Outdated
Comment thread db/core/src/accounts.rs
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@db/core/src/channels.rs`:
- Around line 257-302: The idempotent Err(DbErr::RecordNotInserted) branch
currently fetches the existing row into the variable inserted and so the code
after the match still treats it as a new insert and publishes a
ChannelStateChange; change that branch to signal a no-op instead of populating
inserted (e.g., return early or set a skip flag/Option) so the subsequent
publish block is not executed. Target the match on ChannelState::insert(...) and
the Err(DbErr::RecordNotInserted) arm (which calls
channel_state::Entity::find()...one(tx)) and ensure the calling code that
publishes StateChange::ChannelState only runs when an actual new insert was
performed (the Ok(insert_result) path that looks up last_insert_id).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e7a79bbe-6197-439c-a968-e9effbd6753a

📥 Commits

Reviewing files that changed from the base of the PR and between f2c574f and b3abb8e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • db/core/Cargo.toml
  • db/core/src/accounts.rs
  • db/core/src/channels.rs

Comment thread db/core/src/channels.rs Outdated
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 88.97638% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.66%. Comparing base (a15bf0e) to head (bf176e7).

Files with missing lines Patch % Lines
db/core/src/accounts.rs 81.48% 10 Missing ⚠️
db/core/src/channels.rs 94.52% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #269      +/-   ##
==========================================
+ Coverage   74.58%   74.66%   +0.07%     
==========================================
  Files          92       92              
  Lines       18305    18414     +109     
==========================================
+ Hits        13652    13748      +96     
- Misses       4653     4666      +13     
Flag Coverage Δ
unit 74.66% <88.97%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
db/core/src/channels.rs 94.60% <94.52%> (+0.01%) ⬆️
db/core/src/accounts.rs 97.64% <81.48%> (-1.00%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions bot removed the docker label Apr 13, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses crashes during initial sync/restart by making account/channel state inserts idempotent (avoiding duplicate-key violations) and preventing duplicate downstream events when a previously-processed state is encountered again.

Changes:

  • Add ON CONFLICT DO NOTHING-style insert behavior for account, account_state, and channel_state writes to make sync processing restart-safe.
  • Gate channel state change event emission so events are only published for genuinely new channel_state inserts.
  • Add regression tests ensuring repeated upserts at the same (block, tx_index, log_index) position do not create duplicate history entries; bump blokli-db crate version.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.

File Description
db/core/src/channels.rs Make channel_state insertion idempotent and emit events only for new inserts; add idempotency test.
db/core/src/accounts.rs Make account identity/state insertion idempotent under races; add idempotency test.
db/core/Cargo.toml Bump blokli-db version to 0.14.1.
Cargo.lock Lockfile updated with broader dependency/version changes.

Comment thread db/core/src/channels.rs
Comment on lines +279 to +283
let model = channel_state::Entity::find()
.filter(channel_state::Column::ChannelId.eq(channel_id))
.filter(channel_state::Column::PublishedBlock.eq(block))
.filter(channel_state::Column::PublishedTxIndex.eq(tx_index))
.filter(channel_state::Column::PublishedLogIndex.eq(log_index))
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insert_channel_state_and_emit now does an extra SELECT after a successful insert to re-fetch the row by (channel_id, block, tx_index, log_index). During initial sync this path can run very frequently, so this adds an extra round-trip per new state. Consider using the insert result (e.g., last_insert_id / returning support) to avoid the follow-up query on the success path, and only fall back to a lookup for the conflict (RecordNotInserted) case.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 13, 2026 13:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings April 14, 2026 14:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Comment thread db/core/src/channels.rs
Comment on lines +279 to +284
let model = channel_state::Entity::find()
.filter(channel_state::Column::ChannelId.eq(channel_id))
.filter(channel_state::Column::PublishedBlock.eq(block))
.filter(channel_state::Column::PublishedTxIndex.eq(tx_index))
.filter(channel_state::Column::PublishedLogIndex.eq(log_index))
.one(tx)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insert_channel_state_and_emit duplicates the same channel_state::Entity::find() lookup (same filters and nearly identical error handling) in both the successful insert and conflict paths. Consider extracting the lookup into a small helper or performing it once after the insert attempt (using an is_new flag from the insert result) to avoid repeating the filter set and to keep future schema/query changes in one place.

Copilot uses AI. Check for mistakes.
Comment thread db/core/src/accounts.rs
Comment on lines +291 to +307
Ok(_insert_result) => {
// Look up by natural key instead of last_insert_id, which is
// unreliable with ON CONFLICT DO NOTHING (Postgres returns 0)
account::Entity::find()
.filter(account::Column::ChainKey.eq(chain_key.as_ref().to_vec()))
.filter(account::Column::PacketKey.eq(hex::encode(packet_key.as_ref())))
.one(tx.as_ref())
.await?
.ok_or_else(|| DbSqlError::LogicalError("Inserted account not found".to_string()))?
.id
}
Err(DbErr::RecordNotInserted) => {
// Race condition: account was inserted between find and insert
account::Entity::find()
.filter(account::Column::ChainKey.eq(chain_key.as_ref().to_vec()))
.filter(account::Column::PacketKey.eq(hex::encode(packet_key.as_ref())))
.one(tx.as_ref())
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In upsert_account, the Account::insert(...).on_conflict(...).exec(...) match has two branches (Ok(_) and Err(DbErr::RecordNotInserted)) that both run the same follow-up query by (chain_key, packet_key). This can be simplified by factoring the lookup out and sharing it (also consider precomputing packet_key_hex once) to reduce duplication and make the idempotency logic easier to maintain.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 17, 2026 09:05
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

Comment thread db/core/src/channels.rs
Comment on lines +1565 to +1573

// Insert channel state at (block=100, tx_index=5, log_index=3)
db.upsert_channel(None, ce, 100, 5, 3).await?;

// Insert again with the same position - should succeed (idempotent)
db.upsert_channel(None, ce, 100, 5, 3).await?;

// Verify only one state record exists
let history = db.get_channel_history(None, &ce.get_id()).await?;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChannelEntry is moved on the first db.upsert_channel(None, ce, ...) call (the method takes ChannelEntry by value), so the second call and the later ce.get_id() won't compile. Consider cloning/recreating ce for the second call and capturing channel_id = ce.get_id() before the first move (or change the test to build the entry twice).

Suggested change
// Insert channel state at (block=100, tx_index=5, log_index=3)
db.upsert_channel(None, ce, 100, 5, 3).await?;
// Insert again with the same position - should succeed (idempotent)
db.upsert_channel(None, ce, 100, 5, 3).await?;
// Verify only one state record exists
let history = db.get_channel_history(None, &ce.get_id()).await?;
let channel_id = ce.get_id();
// Insert channel state at (block=100, tx_index=5, log_index=3)
db.upsert_channel(None, ce, 100, 5, 3).await?;
// Insert again with the same position - should succeed (idempotent)
let ce = ChannelEntry::new(
addr_1,
addr_2,
HoprBalance::from(1000u32),
0_u32.into(),
ChannelStatus::Open,
1_u32.into(),
);
db.upsert_channel(None, ce, 100, 5, 3).await?;
// Verify only one state record exists
let history = db.get_channel_history(None, &channel_id).await?;

Copilot uses AI. Check for mistakes.
Comment thread db/core/src/channels.rs
Comment on lines +279 to +307
let model = channel_state::Entity::find()
.filter(channel_state::Column::ChannelId.eq(channel_id))
.filter(channel_state::Column::PublishedBlock.eq(block))
.filter(channel_state::Column::PublishedTxIndex.eq(tx_index))
.filter(channel_state::Column::PublishedLogIndex.eq(log_index))
.one(tx)
.await?
.ok_or_else(|| DbSqlError::LogicalError("Inserted channel_state not found".to_string()))?;
(model, true)
}
Err(DbErr::RecordNotInserted) => {
// Idempotent: record already exists from a previous incomplete sync
tracing::debug!(
channel_id,
block,
tx_index,
log_index,
"Channel state already exists, skipping insert"
);
let model = channel_state::Entity::find()
.filter(channel_state::Column::ChannelId.eq(channel_id))
.filter(channel_state::Column::PublishedBlock.eq(block))
.filter(channel_state::Column::PublishedTxIndex.eq(tx_index))
.filter(channel_state::Column::PublishedLogIndex.eq(log_index))
.one(tx)
.await?
.ok_or_else(|| {
DbSqlError::LogicalError("Existing channel_state not found after conflict".to_string())
})?;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel_state::Entity::find() lookup logic is duplicated in both the successful-insert and RecordNotInserted branches. To reduce maintenance risk (and keep error messages consistent), consider extracting this lookup into a small helper/closure that returns the model given (channel_id, block, tx_index, log_index).

Copilot uses AI. Check for mistakes.
Comment thread .cargo/audit.toml
Comment on lines +43 to +48
# Crate: rustls-webpki 0.103.9
# Review by: 2026-06-30
"RUSTSEC-2026-0049",

# RUSTSEC-2026-0097: Rand is unsound with a custom logger using `rand::rng()`
# Crate: rand 0.8.5, 0.8.6, 0.9.2, 0.10.0
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These newly ignored advisories don’t follow the existing documentation pattern in this file (most entries include at least "Transitive:" and a brief "Status:"/rationale). Please add the missing context (direct vs transitive, why it’s acceptable to ignore, and any mitigation) for RUSTSEC-2026-0049 and RUSTSEC-2026-0097 so the quarterly review has enough information.

Suggested change
# Crate: rustls-webpki 0.103.9
# Review by: 2026-06-30
"RUSTSEC-2026-0049",
# RUSTSEC-2026-0097: Rand is unsound with a custom logger using `rand::rng()`
# Crate: rand 0.8.5, 0.8.6, 0.9.2, 0.10.0
# Crate: rustls-webpki 0.103.9
# Transitive: TLS/certificate validation dependency, not intentionally consumed as a first-party API
# Status: Temporary ignore while waiting for upstream remediation/adoption. Mitigation: do not rely on CRL processing as the primary revocation control; continue standard certificate validation and operational certificate rotation.
# Review by: 2026-06-30
"RUSTSEC-2026-0049",
# RUSTSEC-2026-0097: Rand is unsound with a custom logger using `rand::rng()`
# Crate: rand 0.8.5, 0.8.6, 0.9.2, 0.10.0
# Transitive: RNG dependency used through the dependency tree rather than as a project-specific custom logging integration
# Status: Acceptable to ignore short term because the advisory requires a custom logger with `rand::rng()`. Mitigation: avoid custom logger integrations around `rand::rng()` and prefer existing non-custom RNG call paths until patched versions are adopted.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restart blokli while syncing creates a duplicate key error and the instance crashes

3 participants