Skip to content

fix(mtree): resolve phantom mismatches and heal stale leaves in table…#135

Merged
mason-sharp merged 1 commit into
mainfrom
fix/ACE-200/phantom-mismatch
Jul 2, 2026
Merged

fix(mtree): resolve phantom mismatches and heal stale leaves in table…#135
mason-sharp merged 1 commit into
mainfrom
fix/ACE-200/phantom-mismatch

Conversation

@danolivo

@danolivo danolivo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

…-diff

After an insert replicated cleanly to a subscriber, mtree table-diff logged "Trees differ ... Found 1 mismatched blocks" yet concluded TABLES MATCH, and mtree update never cleared the stale leaf -- only a full mtree build did. Reproduced on a 2-node spock cluster; two drain defects combined to cause it:

  • The bounded drain's target was pg_current_wal_flush_lsn(). Spock applies transactions with asynchronous commit, so a row could be visible to readers while its commit record was still beyond the flush pointer: the drain stopped "caught up" without it and the subscriber's tree went stale behind data the user could already SELECT. The target is now pg_current_wal_insert_lsn(), which covers every visible commit; the walsender reaches it within one WAL-writer flush cycle.

  • All three stop points (data-path, keepalive prompt-stop, idle-timeout) could fire between a transaction's data messages and its commit, discarding the buffered changes while checkpointing at or past the commit -- skipping the transaction permanently, which is why only a rebuild recovered. A bounded drain now never stops while a transaction buffer is open (inTx).

DiffMtree also degrades honestly and self-heals: mismatched blocks whose row comparison finds no differences are reported as stale tree hashes rather than left contradicting the TABLES MATCH verdict, and those leaves are rehashed from live data (mark dirty -> rehash -> rebuild parents -> clear flags) so the phantom cannot recur on subsequent diffs whatever made the tree stale. Healing recomputes honest hashes, so it can never mask real divergence; if roots still differ afterwards the diff warns that leaf ranges have diverged and recommends a rebuild.

Integration-tested: a deterministic stale-leaf test asserts the diff reports zero row differences, heals the tree, and runs clean on the next pass; the end-to-end replicated-insert scenario no longer reproduces the phantom in either the append or in-range variant.

@danolivo danolivo added the bug Something isn't working label Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@danolivo, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad5d4cd0-f2d7-45d7-a9b7-09ce5b9f3316

📥 Commits

Reviewing files that changed from the base of the PR and between 73523f1 and 11836af.

📒 Files selected for processing (8)
  • db/queries/queries.go
  • db/queries/templates.go
  • docs/commands/mtree/mtree-table-diff.md
  • docs/design/merkle.md
  • internal/consistency/mtree/merkle.go
  • internal/infra/cdc/listen.go
  • pkg/types/types.go
  • tests/integration/mtree_stale_leaf_test.go
📝 Walkthrough

Walkthrough

Adds a MarkLeavesDirtyByPositions SQL helper/template used by a new Merkle diff healing path that detects stale-hash false-positive mismatches, refreshes affected leaves, and rebuilds parent nodes, with documentation and an integration test. Separately updates CDC bounded drain logic to compute target LSN from WAL insert position and avoid stopping mid-transaction.

Changes

Merkle tree stale leaf healing

Layer / File(s) Summary
MarkLeavesDirtyByPositions query and template
db/queries/queries.go, db/queries/templates.go
Adds an UPDATE-based SQL template and a query function that mark specified leaf node positions dirty and refresh last_modified.
DiffMtree healing loop and refreshStaleLeaves
internal/consistency/mtree/merkle.go, docs/commands/mtree/mtree-table-diff.md
Tracks per-pair mismatched leaf positions via pairDiffWork, adds a post-comparison loop that detects zero-row-diff mismatches and heals them via refreshStaleLeaves (mark dirty, recompute hashes, rebuild parents, clear dirty, re-check roots), and documents the stale-hash mismatch scenario.
Integration test for stale leaf healing
tests/integration/mtree_stale_leaf_test.go
Adds TestMerkleTreeStaleLeafHeals, which builds trees, corrupts N2's leaf/internal hashes, runs DiffMtree twice, and asserts the phantom mismatch heals and does not recur.

CDC bounded drain transaction safety

Layer / File(s) Summary
Transaction-aware drain target and stop conditions
internal/infra/cdc/listen.go
Replaces getCurrentWalFlushLSN with getDrainTargetLSN (based on pg_current_wal_insert_lsn()), adds inTx tracking via BeginMessage/CommitMessage, and gates all bounded-drain stop conditions on !inTx to avoid stopping mid-transaction.

Poem

A rabbit hops through hashes stale, 🐇
Marks the dirty leaves, retells the tale,
Roots realign, no need to rebuild,
While CDC waits till transactions yield.
Two fixes, one burrow, both dug with care—
Consistency restored, no phantoms there!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly references the main change: fixing phantom mtree mismatches and stale leaf healing.
Description check ✅ Passed The description directly explains the mtree diff fix, bounded drain changes, and stale-leaf healing behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ACE-200/phantom-mismatch

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.

@codacy-production

codacy-production Bot commented Jul 2, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 2 medium

Results:
2 new issues

Category Results
Complexity 2 medium

View in Codacy

🟢 Metrics 8 complexity · 2 duplication

Metric Results
Complexity 8
Duplication 2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/integration/mtree_stale_leaf_test.go (1)

58-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log a warning instead of silently discarding MtreeTeardown() errors.

Based on learnings, cleanup callbacks in this test suite should log unexpected failures with t.Logf("Warning: ...") rather than silently discard them; MtreeTeardown() is not simple idempotent DDL like DROP TABLE IF EXISTS, so a failure here could leave a dangling publication/slot that affects later tests.

♻️ Proposed fix
-	t.Cleanup(func() { _ = task.MtreeTeardown() })
+	t.Cleanup(func() {
+		if err := task.MtreeTeardown(); err != nil {
+			t.Logf("Warning: MtreeTeardown failed: %v", err)
+		}
+	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/mtree_stale_leaf_test.go` at line 58, The cleanup in
mtree_stale_leaf_test should not silently ignore MtreeTeardown() failures;
update the t.Cleanup callback to capture the returned error from
task.MtreeTeardown() and log a warning with t.Logf("Warning: ...") when it
fails. Keep the change localized to the mtree_stale_leaf_test cleanup around
MtreeTeardown so unexpected teardown problems are surfaced during test runs
instead of being discarded.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/integration/mtree_stale_leaf_test.go`:
- Line 58: The cleanup in mtree_stale_leaf_test should not silently ignore
MtreeTeardown() failures; update the t.Cleanup callback to capture the returned
error from task.MtreeTeardown() and log a warning with t.Logf("Warning: ...")
when it fails. Keep the change localized to the mtree_stale_leaf_test cleanup
around MtreeTeardown so unexpected teardown problems are surfaced during test
runs instead of being discarded.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: debef0db-5972-407d-aa3d-bdc88f2ca88e

📥 Commits

Reviewing files that changed from the base of the PR and between f085feb and 73523f1.

📒 Files selected for processing (6)
  • db/queries/queries.go
  • db/queries/templates.go
  • docs/commands/mtree/mtree-table-diff.md
  • internal/consistency/mtree/merkle.go
  • internal/infra/cdc/listen.go
  • tests/integration/mtree_stale_leaf_test.go

…-diff

After an insert replicated cleanly to a subscriber, mtree table-diff logged
"Trees differ ... Found 1 mismatched blocks" yet concluded TABLES MATCH, and
mtree update never cleared the stale leaf -- only a full mtree build did.
Reproduced on a 2-node spock cluster; two drain defects combined to cause it:

- The bounded drain's target was pg_current_wal_flush_lsn(). Spock applies
  transactions with asynchronous commit, so a row could be visible to readers
  while its commit record was still beyond the flush pointer: the drain
  stopped "caught up" without it and the subscriber's tree went stale behind
  data the user could already SELECT. The drain target (drainTargetLSN) is
  now captured by nudging a trivial committing transaction and reading the
  WAL insert position in the same statement: every commit visible at call
  time sits at or below the target, and the nudge's own commit record lands
  just above it, so the WAL writer flushes past the target within one
  wal_writer_delay in every synchronous_commit configuration -- the target
  is reachable by the walsender by construction.

- All three stop points (data-path, keepalive prompt-stop, idle-timeout)
  could fire between a transaction's data messages and its commit, discarding
  the buffered changes while checkpointing at or past the commit -- skipping
  the transaction permanently, which is why only a rebuild recovered. A
  bounded drain now never stops while a transaction buffer is open (inTx).

DiffMtree also degrades honestly and self-heals: when every mismatched block
between a node pair holds no row differences, they are reported as stale tree
hashes rather than left contradicting the TABLES MATCH verdict, and exactly
those leaves are rehashed from live data (mark dirty -> rehash -> rebuild
parents -> clear flags) so the phantom cannot recur on subsequent diffs.
Healing recomputes honest hashes, so it can never mask real divergence; if
roots still differ afterwards the diff warns that leaf ranges have diverged
and recommends a rebuild. A zero diff count only proves staleness when the
comparison actually completed, so pairs that lost comparison work items to
errors are skipped with a re-run hint, --until runs never heal (their
comparison deliberately excludes rows past the cutoff), and blocks dirtied
concurrently by CDC keep their dirty state and counters for the next update.
Refreshes are recorded per pair in the summary's stale_blocks_refreshed so
reports distinguish a clean diff from a healed one.

Integration-tested: a deterministic stale-leaf test asserts the diff reports
zero row differences, heals the corrupted leaf back to its build-time hash,
surfaces the refresh in the summary, clears its dirty flags, and runs clean
on the next pass; the end-to-end replicated-insert scenario no longer
reproduces the phantom in either the append or in-range variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@danolivo danolivo force-pushed the fix/ACE-200/phantom-mismatch branch from 73523f1 to 11836af Compare July 2, 2026 11:19
@danolivo

danolivo commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mason-sharp mason-sharp merged commit fe9abba into main Jul 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants