Skip to content

fix(stores): stop re-processed blocks from duplicating metadata, assets, script and MIR rows - #1266

Open
satran004 wants to merge 4 commits into
mainfrom
fix/deterministic-ids-for-reprocessed-blocks
Open

satran004 wants to merge 4 commits into
mainfrom
fix/deterministic-ids-for-reprocessed-blocks

Conversation

@satran004

Copy link
Copy Markdown
Member

Problem

transaction_metadata, assets, transaction_scripts and mir use a random UUID primary key and are saved with JPA saveAll. When a block is processed a second time, its rows get new ids and are inserted again. This happens on restart: the app rolls back to the cursor block, and on the BlockFetch path it re-fetches from that block inclusive. Tables keyed by natural keys simply overwrite. These four gain one duplicate per row of the block. On a mainnet DB, transaction_metadata had 76 rows for 38 (tx_hash, label) at one restart slot and 176 for 88 at another.

Fix

  1. The rollback point is deleted when it will be processed again (cherry-picked from the snapshot branch). The startup RollbackEvent now says whether the cursor block will be re-fetched (rollbackPointReprocessed). When it will, the four stores delete from that slot inclusive. This also covers the first restart after upgrading, when the cursor block's rows still have random ids.
  2. Deterministic ids plus upsert. The id is a name-based UUID (v5) of the row's natural key, and rows are written with a jOOQ batch upsert on the existing id primary key, so a re-processed row overwrites itself:
    • transaction_metadata: tx_hash, label
    • assets: tx_hash, unit (asset_name is a decoded, sanitized name and can repeat)
    • transaction_scripts: tx_hash, purpose, redeemer_index (one row per redeemer)
    • mir: tx_hash, cert_index, credential (null for pot-to-pot transfers)

No schema change or migration. Existing rows keep their random ids and new rows get deterministic ones, so existing databases need no resync. On MySQL the id is bound as binary(16), as Hibernate writes it. metadata and mir are added to the jOOQ codegen modules.

Rows already duplicated by earlier restarts are not cleaned up. That needs a one-off dedupe per table.

Tests

  • UUIDUtilTest: deterministic, version 5 with the IETF variant, length-prefixed parts don't collide, null is distinct.
  • One storage test per store: saving the same rows twice leaves one row per key with updated values. The assets test uses two units sharing one asset_name; the mir test uses a null credential.
  • Rollback processor tests for the inclusive delete, and StartServiceTest for the reprocessed flag.
  • The store tests run on H2. The upsert SQL was rendered for PostgreSQL (ON CONFLICT (id) DO UPDATE), MySQL (ON DUPLICATE KEY UPDATE) and H2 (MERGE), but it has not yet been run against a real PostgreSQL or MySQL.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JKakmKURLn4uc33hgrz2hF

satran004 and others added 2 commits October 17, 2026 08:16
On startup StartService rolls back to the cursor block and, when it
uses BlockFetch (far from tip, or a sync-stop slot), fetches the range
from that block inclusive, so the cursor block is processed again.
Rollback handlers delete only slot > rollbackTo, so the block's rows
stay and are written a second time. Tables keyed by natural keys are
overwritten; tables keyed by a random UUID gain a duplicate row per
restart. On a mainnet DB transaction_metadata had 76 rows for 38
(tx_hash, label) at one restart slot and 176 for 88 at another.

Affected (random UUID id, JPA saveAll, deleteBySlotGreaterThan):
transaction_metadata, assets, transaction_scripts, mir. Other per-block
tables use natural keys or upserts; reward_rest/unclaimed_reward_rest
are rewritten per epoch after a delete by (earned_epoch, type).

The startup RollbackEvent now says whether the rollback point will be
processed again (rollbackPointReprocessed: true only for the BlockFetch
path; ChainSync starts after the intersection, so near tip the cursor
block is not refetched and its rows must be kept). The four stores
delete from that slot inclusive in that case. No schema change, and
all other stores keep their behaviour.

Rows already duplicated by earlier restarts are not cleaned up; that
needs a one-off dedupe per table, keeping one row per natural key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKakmKURLn4uc33hgrz2hF
… rows

transaction_metadata, assets, transaction_scripts and mir used a random
UUID primary key and JPA saveAll, so every time a block was processed
again (restart from the cursor block, re-fetch) its rows were inserted
again with new ids and duplicated.

The id is now a name based UUID (v5) of the row's natural key, and rows
are written with a jOOQ batch upsert on the existing id primary key:
- transaction_metadata: tx_hash, label
- assets: tx_hash, unit (policy + hex asset name; asset_name is a
  decoded, sanitized name and can repeat)
- transaction_scripts: tx_hash, purpose, redeemer_index (one row per
  redeemer)
- mir: tx_hash, cert_index, credential (null for pot to pot transfers)

No schema change or migration: existing rows keep their random ids and
new rows get deterministic ids. metadata and mir are added to the jOOQ
codegen modules. On MySQL the id is bound as binary(16), as Hibernate
writes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKakmKURLn4uc33hgrz2hF
public static UUID nameUUID(String... parts) {
MessageDigest sha1;
try {
sha1 = MessageDigest.getInstance("SHA-1");
@satran004

Copy link
Copy Markdown
Member Author

Merge note for #1313 and #1266 (found while combining the #1328 PRs for the re-test)

Merged together as they are, those stores would lose the cursor block's rows on every restart in range mode. The combined branch resolves it in StartService like this:

.rollbackPointReprocessed(fetchRange && processedBlockNumber == null)

processedBlockNumber is the start block number, which #1313 passes to BlockFetchService only when the start point was really processed. Whichever of the two PRs merges second needs this change.

@satran004

Copy link
Copy Markdown
Member Author

Merge note for #1330 and #1266: StartService now gets the tip with tipFinderService.findTip(timeout) instead of getTip().block() (#1330). StartServiceTest, added in #1266, stubs getTip(). Whichever of the two PRs merges second needs this one-line change in StartServiceTest:

when(tipFinderService.findTip(anyLong())).thenReturn(new Tip(new Point(slot, "tip_hash"), 10_000_100L));

It is already applied on the combined test branch test/recovery-combined (commit 8fef4cb).

@satran004

Copy link
Copy Markdown
Member Author

Fault-injection re-test result

We ran the same 104 fault scenarios again on preprod (benchmark-vm): 43 during the initial sync and 61 at the tip. This time the build had all the fix PRs together: main + #1330, #1331, #1313, #1332, #1271, #1333, #1266 (branch test/recovery-combined).

  • All 104 scenarios recovered on their own. None needed a manual restart. In the first run, 7 got stuck.
  • No duplicate rows.
  • Missing blocks: only the 2 that B04 skips on purpose. B04 sends an undecodable block with continue-on-parse-error=true.
  • address_utxo_unspent is exact: 0 missing and 0 extra rows, compared with address_utxo minus tx_input.

Details and the full list of findings: #1328

This PR in the test

satran004 and others added 2 commits November 8, 2026 19:06
… the MIR id

The mir id was a name based UUID of (tx_hash, cert_index, credential). The
credential column holds only the hash, and the credential kind (key or
script) survives only in the reward address. A MIR certificate whose map
has a key credential and a script credential with the same hash bytes
would give both rows the same id, and the second upsert would overwrite
the first.

A script credential now adds a "script" part to the id. Key credentials
and pot to pot transfers (null credential) keep the
(tx_hash, cert_index, credential) id, so ids for the common case do not
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQUCWhJpLCQR5Asv7nDb
TxScriptStorageImpl.saveAll used JPA saveAll, which runs in its own
transaction when there is none. The jOOQ batch upsert that replaced it had
no transaction boundary. In parallel mode ScriptRedeemerDatumProcessor
calls saveAll from executor and parallel stream workers, which do not
inherit the event listener's transaction, so on autocommit a failure in
the middle of the batch left the earlier rows committed.

saveAll is @transactional again, like the other store writers
(TransactionStorageImpl, UtxoStorageImpl, ...). The test runs outside the
test transaction, through the Spring bean, and checks that a batch whose
second row fails writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQUCWhJpLCQR5Asv7nDb
@satran004
satran004 requested a review from a team as a code owner November 8, 2026 11:19
@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
D Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@satran004

Copy link
Copy Markdown
Member Author

Updated after an external review of the 2.0.x backport #1345: 75c33ff, 652e1d8. Each finding, its verdict and its test are in the reply on #1339.

@satran004

Copy link
Copy Markdown
Member Author

Verdict: APPROVED

reviewer1 — original-PR review at 652e1d8208fc2d670b95d827420ca08868e04831.

F1 and F2 from #1345 are resolved here: MIR IDs distinguish key/script credentials, and the script batch owns a transaction. I checked the main-specific metadata writer: it retains tx_index on insert and replay.

Keep rollbackPointReprocessed(fetchRange && processedBlockNumber == null) when merging with #1313. The SHA-1 UUID note remains accepted for these public-data identifiers; the Sonar gate still needs disposition. Old random-ID duplicates still require deduplication.

The numbered findings refer to the counterpart review of #1345. No additional blocking finding remains.

Validation on combined main: core 244 passed, 1 skipped at d9b1d37282351992da8f45a85e0e3dbe62e5e1e2; eight other relevant suites 197 passed at f0579f9a5f42ae98694d8f35cd0c998295cc1c20, with zero failures/errors in these successful runs. Exact commands, integration resolutions and scope. These are combined-build results, not a separate standalone run of this PR.

@satran004
satran004 requested a review from huylevt November 8, 2026 23:59
@satran004

Copy link
Copy Markdown
Member Author

Fault-injection rerun on main with all seven fix PRs at their approved heads (test/recovery-combined d9b1d3728): 104 of 104 scenarios recovered, with no duplicate rows and no unexpected missing blocks. The full report is on #1328: #1328 (comment)

For this PR:

  • Unpatched main flagged duplicate rows in 77 of 104 scenarios: scripts in 77, assets in 54, metadata in 6.
  • Now there are no duplicate rows in any scenario. assets and transaction_metadata match the reference, and transaction_scripts differs only where it depends on the block B04 skips on purpose.

The baseline is unpatched main at 6836c4dd6, run on the same matrix.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.0.0-beta6 Planned for 3.0.0-beta6 fault-injection Found or verified by the auto-recovery fault-injection test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants