Skip to content

[2.0.x] fix(stores): stop re-processed blocks from duplicating metadata, assets, script and MIR rows (backport of #1266) - #1345

Open
satran004 wants to merge 5 commits into
release/2.0.xfrom
backport/2.0.x/1266-deterministic-ids-reprocessed-blocks
Open

satran004 wants to merge 5 commits into
release/2.0.xfrom
backport/2.0.x/1266-deterministic-ids-reprocessed-blocks

Conversation

@satran004

Copy link
Copy Markdown
Member

Backport of #1266 to release/2.0.x. Tracked in #1339.

What it fixes: see #1266. The backport changes the same code in the same way.

How it was made: The 2 commits of #1266, plus one adaptation commit:

Checks: Tests pass: common 72, assets 8, metadata 3, script 19, mir 7. applications:all compiles. Merging this after the #1313 and #1330 backports conflicts in StartService, the same as on main. The resolved file is in test/recovery-combined-2.0.x.

Fault-injection run: the same scenarios as #1328 are running on release/2.0.x with all the backports. The results will be posted here and in #1339.

🤖 Generated with Claude Code

satran004 and others added 3 commits November 7, 2026 14:59
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
(cherry picked from commit 85f4f94)
… 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
(cherry picked from commit ec078a7)
…branch

The tx_index column of transaction_metadata was added on main by 6ab6fae
and is not on release/2.0.x, so the upsert from #1266 does not write it.
The jOOQ module list keeps the release/2.0.x entries and adds assets,
metadata and mir, which #1266 needs (main also has epoch-aggr there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@satran004
satran004 requested a review from a team as a code owner November 7, 2026 07:32
@satran004 satran004 added fault-injection Found or verified by the auto-recovery fault-injection test 2.0.x Fix or backport for the release/2.0.x stable line labels Nov 7, 2026
public static UUID nameUUID(String... parts) {
MessageDigest sha1;
try {
sha1 = MessageDigest.getInstance("SHA-1");

Copy link
Copy Markdown

Initial automated review · Aster

Hi @satran004, thanks for the PR! 🙏 This is an automatic first check by an AI reviewer. It does not replace maintainer review and may be wrong.

Reviewed commit: 202e6f54a323ed147f312d6bcdaf12f255bdd101

Issues found:

  • stores/mir/src/main/java/com/bloxbean/cardano/yaci/store/mir/storage/impl/MIRStorageImpl.java:30 — A MIR certificate can contain a key credential and a script credential with the same hash bytes, but this ID makes one reward overwrite the other. Could we use the reward address in the ID?
  • stores/script/src/main/java/com/bloxbean/cardano/yaci/store/script/storage/impl/TxScriptStorageImpl.java:77 — Parallel indexing runs this batch outside the event transaction, so a MySQL failure midway can leave some redeemer rows committed. Could we keep the transaction that the old repository write provided?

Not tested: I reviewed the code and CI without running the build, tests, or database replay scenarios.

@satran004

Copy link
Copy Markdown
Member Author

Fault-injection result on release/2.0.x

The same scenarios as #1328 ran on plain release/2.0.x and on release/2.0.x with all 8 backports (#1340–#1347). With the backports:

  • all 104 scenarios recovered on their own, with no manual restart (plain 2.0.x: 6 stuck);
  • no duplicate rows (plain 2.0.x: 31 scenarios);
  • only the 2 blocks that B04 skips on purpose are missing (plain 2.0.x: 9 blocks).

Full results: #1339.

This PR in the run: plain 2.0.x wrote duplicate rows in 31 scenarios. By the end it had +169 assets, +217 transaction_metadata and about +7,500 transaction_scripts rows. With this fix: 0 duplicates, and those tables match the reference.

Copy link
Copy Markdown

reviewer1 — review of 202e6f54a323ed147f312d6bcdaf12f255bdd101

Changes requested. The length-prefixed deterministic IDs, raw asset-unit key, and release-specific omission of metadata tx_index look appropriate. I confirmed two remaining issues from the earlier review:

  1. [P2] Include the credential kind in the MIR identity. MIRStorageImpl.java:30 uses (txHash, certIndex, credentialHash). The incoming MIR map distinguishes a key credential from a script credential, but MIRProcessor stores only getHash() in credential; the distinction survives in the reward address. Two entries in the same certificate with identical hash bytes and different credential kinds therefore get the same ID and the second upsert overwrites the first, including its amount/address. Use the reward address, or an explicit credential-kind component, while keeping the null-credential pot-transfer identity distinct. Extend the test with key/script credentials sharing the hash and assert two rows remain after replay.

  2. [P2] Preserve a transaction around the script writer's batch. TxScriptStorageImpl.java:77 replaces transactional repository.saveAll() with dsl.batch(...).execute(), without a transaction boundary on this method. In parallel mode, ScriptRedeemerDatumProcessor.handleScriptTransactionEventForBlockCacheList() invokes it through executor/parallel-stream workers, which do not inherit the parent Spring transaction. In JDBC autocommit mode, a batch failure can leave earlier statements committed, especially on MySQL. Add @Transactional to the proxied writer (or an explicit transaction), and test an injected failure partway through a multi-row batch outside the test framework's enclosing transaction. The current @DataJpaTest tests mask this missing production boundary.

Merge/upgrade notes: preserve the combined branch's rollbackPointReprocessed(fetchRange && processedBlockNumber == null) adaptation when merging with #1343; the standalone fetchRange expression would delete rows for the cursor block that #1343 skips. Also document that deterministic IDs prevent new duplicates but do not deduplicate historical random-ID rows.

The published zero-duplicate result is strong evidence for the tested Postgres workload. It does not test the MIR identity collision or MySQL transaction boundary. Review method: exact-head source, schema/API adaptation, caller/threading, and test inspection; no database suite or VM rerun.

satran004 and others added 2 commits November 8, 2026 19:20
… 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
(cherry picked from commit 75c33ff)
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
(cherry picked from commit 652e1d8)
@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

Thanks, reviewer1. Handled as follows:

  1. MIR id: a script credential adds a "script" part; key credentials keep the old id. New test with a key and a script credential sharing a hash.
  2. Script batch: @Transactional on TxScriptStorageImpl.saveAll. The test runs outside a test transaction.

Commits: 1dc4fba, 6aefd7b here, 75c33ff, 652e1d8 on #1266.

The fixes were made in #1266 first and cherry-picked here. Each finding, its verdict and its test are in the reply on #1339. The fault suite is running again on the updated combined build.

@satran004

Copy link
Copy Markdown
Member Author

Verdict: APPROVED

reviewer1 — round 2. Reviewed 6aefd7b4b1726b7ef46cb4ab1239479d9ad9cf85, compared with 202e6f54a323ed147f312d6bcdaf12f255bdd101.

Both original findings are resolved.

Finding Status Reason
F1 [P2] Resolved MIR script credentials get a distinct ID component; existing key-credential IDs stay stable. The equal-hash key/script test preserves both rows.
F2 [P2] Resolved The script batch writer now has its own transaction; the failure test runs outside the test transaction and verifies no partial rows.

I accept no change for the Sonar weak-hash note: these are deterministic public-data identifiers, not authentication or secret protection. The upgrade note correctly says old random-ID duplicates still need deduplication.

I also checked the combined rollbackPointReprocessed(fetchRange && processedBlockNumber == null) resolution: a processed cursor block that will be skipped keeps its rows.

Validation: seven module suites on isolated combined commit 121ee4599fcdc41226239b9195ce45daa47cfc9d: 366 passed, 1 skipped, 0 failures/errors (core: 219 passed, 1 skipped). This is combined-build validation, not a separate run on every backport.

Exact command
JAVA_HOME=/Users/satya/.sdkman/candidates/java/21.0.3-tem ./gradlew :components:core:test :components:common:test :stores:assets:test :stores:metadata:test :stores:script:test :stores:mir:test :stores:staking:test --no-daemon --console=plain

No implementation changes made by this review. The updated VM fault-suite results are still pending. This verdict covers the reviewed head only.

@satran004

Copy link
Copy Markdown
Member Author

Fault-injection rerun on 2.0.x with all eight backports at their approved heads (test/recovery-combined-2.0.x bbac1a25a): 104 of 104 scenarios recovered, with no duplicate rows and no unexpected missing blocks. The full report is on #1339: #1339 (comment)

For this PR:

  • Unpatched 2.0.x wrote duplicate rows in 78 of 104 scenarios, in assets, transaction_metadata and transaction_scripts. At the end, assets differed in 23 slots and metadata in 13.
  • Now there are no duplicate rows in any scenario, and assets, transaction_metadata and transaction_scripts match the reference apart from the two blocks B04 skips on purpose.

The unpatched baseline is release/2.0.x (b2x), 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

2.0.x Fix or backport for the release/2.0.x stable line 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.

3 participants