Skip to content

feat(EN-1550): idempotent mirror ingest on v2LogId#1587

Merged
gfyrag merged 7 commits into
release/v3.0from
feat/en-1550-mirror-v2logid-idempotency
Jul 14, 2026
Merged

feat(EN-1550): idempotent mirror ingest on v2LogId#1587
gfyrag merged 7 commits into
release/v3.0from
feat/en-1550-mirror-v2logid-idempotency

Conversation

@gfyrag

@gfyrag gfyrag commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What

Makes mirror ingest idempotent on v2LogId at the FSM apply path, so replaying an already-applied source log is a deterministic no-op. Closes the "tampered/rolled-back MirrorCursor → worker re-emits already-applied logs → processMirrorIngest force-adds postings twice → doubled balances" concern raised on #1581.

How — the guard

  • New per-ledger field LedgerBoundaries.last_mirror_v2_log_id (field 11, fixed64): the highest source v2LogId already applied to this mirror ledger.
  • In processMirrorIngest (internal/domain/processing/processor_mirror.go), before any mutation (no ledger re-touch, no cache write), an entry whose v2LogId <= last_mirror_v2_log_id returns a deterministic no-op (nil, nil).
  • On a forward ingest, last_mirror_v2_log_id is advanced to the entry's v2LogId — set once on the wrapping MirrorLogEntry, so it covers every inner kind (CreatedTransaction, SavedMetadata, DeletedMetadata, RevertedTransaction, FillGap).
  • ProcessOrders (internal/domain/processing/processor.go) now treats a (nil, nil) processor return as a no-log outcome: it consumes no sequence id, appends no degenerate Log{Payload:nil}, records no CreatedLog, and does not absorb into the sink. The per-order slot stays nil and is skipped by WriteSet.Merge exactly like an idempotency-replay ReferenceSequence.

Invariant safety (FSM hot path)

  • Deterministic: the guard is a pure function of applied per-ledger state (last_mirror_v2_log_id) and the order (v2LogId). No time, no randomness, no map iteration.
  • No new Pebble read / no new preload or coverage key: the field lives inside LedgerBoundaries, already preloaded (SubAttrBoundary) and coverage-gated on every mirror-ingest order (Worker.extractMirrorNeeds) and already mutated on this path.
  • Expected soft-skip, not fail-loud: replay is an expected stale-ingest case, so return nil, nil is correct per invariant Add Postgres storage #7's expected-vs-impossible distinction (not assert.Unreachable, which is reserved for impossible-by-design branches).
  • 1-based / monotonic: v2 log ids are 1-based and strictly increasing per source (adapter/v2/translator.go: TranslateBatch), so <= 0 never wrongly skips the first ingest (default 0 → first real id >= 1 applies).

Checker

last_mirror_v2_log_id needs no new tamper-vector checker pass: it is derivable from the audited MirrorIngest orders (the max applied v2LogId — the same quantity the still-owed MirrorCursor equality re-derives) and only gates future application. It is not independently business-authoritative, so it rides on the (pending) mirror-cursor coverage. Documented in docs/technical/architecture/audit-vs-technical-state.md (Mirror Cursor section).

Out of scope

The ahead direction (a cursor beyond the true source head) is worker-side source-head recovery and is explicitly out of scope here — only the behind/replay direction is closed.

Tests

internal/domain/processing:

  • Replaying the same v2LogId (== high-water mark) is a no-op: no volume writes (balances not doubled), no ledger re-touch, no boundary Put, (nil, nil) returned.
  • A lower/older v2LogId after a higher one is skipped.
  • A forward ingest advances last_mirror_v2_log_id.
  • Batch-level (ProcessOrders): a replay emits no log slot, consumes no sequence id, and never reaches the sink.

Verified: go build ./..., go test ./internal/domain/processing/... ./internal/infra/state/... ./internal/proto/... -count=1 all green; golangci-lint clean on the touched package.

Relates to #1581.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1871ac01-8e93-4da1-90bf-82134fd1d3ff

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/en-1550-mirror-v2logid-idempotency

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.

@NumaryBot

NumaryBot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

🛑 Changes requested — automated review

All previously raised blockers and major issues (contiguous-prefix enforcement, checker bidirectional equality, zero v2LogId rejection, nil-slot idempotency, absent boundary row detection) have been addressed in successive commits and confirmed resolved in the PR discussion. The one remaining open thread (Thread 11) concerns a false-positive in the checker for legitimately deleted mirror ledgers: after the absent-boundary-row fix, compareMirrorV2LogID will flag ledgers whose Boundary row was intentionally removed by WriteSet.Absorb on DeleteLedger, since their audited max is still non-zero. This is a functional correctness issue in the checker fix but was raised after the last confirmed resolution. No new actionable issues were identified by the independent review of the current diff beyond what is already tracked.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 2 new inline findings.

Summary: #1587 (comment)

Comment thread misc/proto/raft_cmd.proto
Comment thread internal/domain/processing/processor_mirror.go Outdated

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two High-severity blockers remain on head 7ea7abd56694dc7bb883fa27716b5f7573801e78:

  • The new persisted high-water mark gates future FSM application but is not checker-verified against the audit chain, violating the repository projection invariant: #1587 (comment)
  • Existing mirror ledgers decode the new field as zero and receive no migration/backfill, so a rolled-back cursor can still replay and double-apply historical postings until a newer ingest advances it: #1587 (comment)

I am not duplicating the existing inline comments. Targeted processing tests pass under the Nix toolchain; these are correctness and upgrade-safety blockers.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.47619% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.00%. Comparing base (4b9c2ed) to head (30330d3).
⚠️ Report is 3 commits behind head on release/v3.0.

Files with missing lines Patch % Lines
internal/application/check/checker.go 66.03% 12 Missing and 6 partials ⚠️
internal/domain/errors.go 30.76% 9 Missing ⚠️
internal/infra/attributes/baseline.go 0.00% 1 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (65.47%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@               Coverage Diff                @@
##           release/v3.0    #1587      +/-   ##
================================================
+ Coverage         74.97%   75.00%   +0.02%     
================================================
  Files               429      429              
  Lines             44942    45037      +95     
================================================
+ Hits              33696    33780      +84     
- Misses             8305     8309       +4     
- Partials           2941     2948       +7     
Flag Coverage Δ
e2e 75.00% <65.47%> (+0.02%) ⬆️
scenario 75.00% <65.47%> (+0.02%) ⬆️
unit 75.00% <65.47%> (+0.02%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 1 new inline finding.

Summary: #1587 (comment)

Comment thread internal/domain/processing/processor_mirror.go
@gfyrag gfyrag requested a review from flemzord July 14, 2026 10:37
@gfyrag

gfyrag commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

I reviewed the current head (7f67d017e625784f9ed98299a5d1fa84513bb445) in detail. I would not merge this as-is.

GitHub will not let me submit a formal “request changes” review on my own PR identity, so I am posting the blocking review as a PR comment.

Findings:

  1. Blocker: LastMirrorV2LogId is advanced as a max, not as a contiguous prefix. I confirmed NumaryBot's finding. processMirrorIngest accepts any v2LogId > LastMirrorV2LogId and then writes that value back to boundaries. If MirrorCursor is ahead of the actually applied prefix, the worker fetches from that cursor and TranslateBatch only gap-fills from cursor+1, not from the FSM boundary. Applying log 10 after only 1..5 were actually applied records LastMirrorV2LogId=10; after the cursor is repaired, 6..9 are now <= LastMirrorV2LogId and are silently skipped. The marker must represent a contiguous applied prefix: reject v2LogId != last+1, or emit/apply explicit FillGap orders for every missing id before advancing it.

  2. Blocker: the checker accepts stored < auditedMax, which leaves the rollback/double-apply case open. compareMirrorV2LogID only reports stored > auditedMax, and TestCompareMirrorV2LogID_AtOrBelowNotFlagged explicitly blesses stored=3, auditedMax=7. But the FSM skips only v2LogId <= stored; if both MirrorCursor and LastMirrorV2LogId are rolled back, already-audited logs above the stored value are treated as fresh and force-applied again. Since we are not doing compatibility/backfill handling here, the checker should verify the projection as the audit-derived value, not just a one-sided upper bound. Otherwise invariant feat(CI): Add GoReleaser #8 is not actually satisfied for this persisted projection.

  3. Major: the archive baseline floor used by compareMirrorV2LogID is currently ineffective. The new docs/comment say foldBaselineBoundaries seeds maxMirrorV2LogID from the archived baseline, but CreateBaselineSnapshot only writes Volume, Metadata, and Transaction attributes into the baseline DB. It does not write Boundary rows. With archived mirror ingests and a baseline present, foldBaselineBoundaries will not see the archived last_mirror_v2_log_id, so the live audit max is undercounted and a legitimate stored high-water mark can be reported as MIRROR_V2LOGID_AHEAD. Either include boundaries in the baseline snapshot or change this checker pass so archived history does not produce false positives.

I also checked the generated protobuf files. I did not find an issue there: raft_cmd_dethash.pb.go delegates LedgerBoundaries deterministic marshaling to MarshalVT(), and the new field is present in the vtproto output.

Verification run locally:

GOROOT= go test ./internal/domain/processing ./internal/application/check -run 'Mirror|V2Log|ProcessOrders' -count=1

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 2 new inline findings.

Summary: #1587 (comment)

Comment thread internal/domain/processing/processor_mirror.go
Comment thread internal/application/check/checker.go
@gfyrag

gfyrag commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Update — all three review findings are now addressed (head `d360d6fef`), following the product decision that we do not support backward-compat with existing mirror clusters (no backfill; a pre-field cluster re-mirrors):

  1. Contiguity (was the blocker): LastMirrorV2LogId is now a true contiguous applied prefix. processMirrorIngest is 3-way — <= last idempotent no-op, == last+1 apply+advance, > last+1 loud reject (ErrMirrorV2LogIDGap, deterministic Describable → BusinessError + failure audit, no state mutated; cursor discarded so the ledger wedges loudly with worker backoff). No more silent under-ingestion on a corrected-back cursor.
  2. Checker equality: compareMirrorV2LogID now verifies stored == max(audited v2LogId) and flags both directions (CHECK_STORE_ERROR_TYPE_MIRROR_V2LOGID_MISMATCH) — full invariant-feat(CI): Add GoReleaser #8 verification, no <= leniency.
  3. Baseline floor (confirmed bug): writeBaselineAttributes now includes Boundary, so the archived last_mirror_v2_log_id is folded into the audited max — no false positive for archived-ingest ledgers (real end-to-end test added).

Independently re-reviewed at d360d6fef: gap path mutates nothing and is deterministic across replicas; both corruption directions covered (behind→no-op, ahead→loud reject); proto enums appended sequentially (wire-compat, dethash unaffected); build + processing/check/attributes/backup/proto tests green. Ready for another look, @flemzord.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 2 new inline findings.

Summary: #1587 (comment)

Comment thread internal/domain/processing/processor_mirror.go Outdated
Comment thread internal/application/check/checker.go

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Current head dd8bff930ebcb026639ac00f856541e8fe09fe12 still has one High/Critical-blocking issue: malformed mirror ingest orders with v2LogId == 0 bypass the replay/gap guards and do not advance LastMirrorV2LogId, so they can be replayed and applied repeatedly. I replied on the existing inline thread instead of opening a duplicate: #1587 (comment)

The previous contiguity, checker equality, and baseline-boundary blockers look addressed. The remaining missing-boundary checker point is minor and not an approval blocker under the current High/Critical threshold. Targeted tests pass under Nix: GOROOT= go test ./internal/domain/processing ./internal/application/check ./internal/infra/attributes -run 'Mirror|V2Log|Baseline|ProcessOrders' -count=1.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 3 new inline findings.

Summary: #1587 (comment)

Comment thread internal/domain/processing/processor_mirror.go
Comment thread internal/domain/processing/processor.go
Comment thread internal/application/check/checker.go

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 1 new inline finding.

Summary: #1587 (comment)

Comment thread internal/application/check/checker.go

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 1 new inline finding.

Summary: #1587 (comment)

Comment thread internal/application/check/checker.go

@flemzord flemzord left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved on current head 30330d3fdb8d67437c681ad1734f09af42106926.

Re-reviewed the follow-up fixes: the zero v2LogId path now rejects before mutation, the mirror high-water mark is checked as audit equality, absent boundary rows are detected for active mirror ledgers, and legitimately deleted mirror ledgers are excluded from the absent-row branch via deletedInReplay. I resolved the remaining duplicate thread after confirming TestCompareMirrorV2LogID_DeletedMirrorLedgerNotFlagged covers that case.

Targeted verification passed under Nix: GOROOT= go test ./internal/domain/processing ./internal/application/check ./internal/infra/attributes -run 'Mirror|V2Log|Baseline|ProcessOrders|DeletedMirror|AbsentRow|ZeroV2LogId' -count=1.

@gfyrag gfyrag merged commit ea3dfba into release/v3.0 Jul 14, 2026
12 of 14 checks passed
@gfyrag gfyrag deleted the feat/en-1550-mirror-v2logid-idempotency branch July 14, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants