Skip to content

Commit 8bce136

Browse files
zaidshabbir25claude
andcommitted
fix(mtree): detect UPDATE-UPDATE conflicts missed by overlapping leaves (ACE-205)
mtree table-diff could report divergent nodes as identical: the same primary key holding different non-key data on two nodes produced matching Merkle-tree root hashes, so the diff printed "Merkle trees are identical" while table-diff correctly found the difference. This is the core UPDATE-UPDATE conflict that active-active clusters produce, silently reported as consistent. Root cause: leaf hashing used a closed ("<=") upper bound on block ranges, but a block's range_end is the EXCLUSIVE start of the next block -- the build offsets query pairs bounds with LEAD (each range_end is the following block's range_start) and splitBlocks sets range_end to a split point that GetBulkSplitPoints returns as the first row of the next chunk. The closed bound double-counted every boundary row into two adjacent leaves. When a table's rows collapsed into overlapping leaves that hashed identically (most visibly a single-row table, where the row lands in both [pk,pk] and [pk,inf)), the XOR-based parent hash cancelled the duplicate siblings to zero on every node, so divergent data yielded matching roots. Leaf hashing now uses an exclusive ("<") upper bound, matching the boundaries the build and split paths already produce, so each row belongs to exactly one leaf. Merkle trees built before this fix must be rebuilt (mtree build) to pick up the corrected layout. Adds an integration regression test (single-row and multi-row-boundary cases) asserting mtree diff agrees with table-diff. Verified against the full mtree integration suite plus db/queries and consistency unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6cc5374 commit 8bce136

3 files changed

Lines changed: 154 additions & 4 deletions

File tree

db/queries/queries.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -671,10 +671,17 @@ func BlockHashSQL(schema, table string, primaryKeyCols []string, mode string, in
671671
endPlaceholders[i] = fmt.Sprintf("$%d", paramIndex)
672672
paramIndex++
673673
}
674-
operator := "<="
675-
if mode == "TD_BLOCK_HASH" {
676-
operator = "<"
677-
}
674+
// The upper bound is always EXCLUSIVE. Block boundaries are the start of
675+
// the *next* block: the build offsets query pairs bounds with LEAD (each
676+
// range_end is the following block's range_start), and splitBlocks sets a
677+
// block's range_end to the split point, which GetBulkSplitPoints returns
678+
// as the first row of the next chunk. A closed "<=" upper bound therefore
679+
// double-counts every boundary row into two adjacent leaves. When a whole
680+
// table collapses into overlapping leaves that hash identically (e.g. a
681+
// single row landing in both [pk,pk] and [pk,∞)), the XOR-based parent
682+
// hash cancels the duplicate siblings to zero on every node, so divergent
683+
// data produces matching root hashes and the diff misses real conflicts.
684+
operator := "<"
678685
var upperExpr string
679686
if len(primaryKeyCols) == 1 {
680687
upperExpr = fmt.Sprintf("%s %s %s", pkComparisonExpression, operator, endPlaceholders[0])

docs/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ This release focuses primarily on Merkle tree functionality.
5454
means "re-run or raise", since drain progress is durable).
5555

5656
### Fixed
57+
- **`mtree table-diff` could report divergent nodes as identical (silent
58+
false-negative).** Merkle-tree leaf hashing used a closed (`<=`) upper bound
59+
on block ranges, but a block's `range_end` is the *exclusive* start of the
60+
next block, so every boundary row was hashed into two adjacent leaves. When a
61+
table's rows collapsed into overlapping leaves that hashed identically (most
62+
visibly a single-row table, where the row lands in both `[pk, pk]` and
63+
`[pk, ∞)`), the XOR-based parent hash cancelled the duplicate siblings to zero
64+
on every node — so the same primary key holding *different* non-key data
65+
produced matching root hashes and the diff reported "Merkle trees are
66+
identical" while `table-diff` correctly found the difference. This is the
67+
core UPDATE-UPDATE conflict active-active clusters produce. Leaf hashing now
68+
uses an exclusive (`<`) upper bound, matching the boundaries the build and
69+
split paths already produce, so each row belongs to exactly one leaf.
70+
**Merkle trees built before this fix must be rebuilt (`mtree build`) to pick
71+
up the corrected layout.**
5772
- **A bounded CDC drain could silently under-report divergence.** The drain
5873
treated a 1-second idle timeout as "caught up", so a node with a large
5974
backlog could leave its Merkle tree stale and the diff would report the
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// ///////////////////////////////////////////////////////////////////////////
2+
//
3+
// # ACE - Active Consistency Engine
4+
//
5+
// Copyright (C) 2023 - 2026, pgEdge (https://www.pgedge.com/)
6+
//
7+
// This software is released under the PostgreSQL License:
8+
// https://opensource.org/license/postgresql
9+
//
10+
// ///////////////////////////////////////////////////////////////////////////
11+
12+
package integration
13+
14+
import (
15+
"context"
16+
"fmt"
17+
"testing"
18+
19+
"github.com/jackc/pgx/v5/pgxpool"
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
// Regression: same PK exists on both nodes with different non-key data at BUILD
24+
// time (an UPDATE-UPDATE conflict). mtree diff must report it, matching plain
25+
// table-diff.
26+
//
27+
// The divergence exists before the tree is built -- unlike
28+
// testMerkleTreeDiffModifiedRows, which builds on identical data and mutates
29+
// afterward, so it never exercised the degenerate build-time range layout.
30+
//
31+
// The bug: a block's range_end is the EXCLUSIVE start of the next block, but
32+
// leaf hashing used a closed "<=" upper bound, double-counting boundary rows
33+
// into two adjacent leaves. A single-row table collapses into two identical
34+
// overlapping leaves ([pk,pk] and [pk,inf)); the XOR-based parent hash cancels
35+
// the duplicate siblings to zero on every node, so divergent data yielded
36+
// matching root hashes and the diff reported "trees identical". The single-row
37+
// case below reproduced it; the multi-row case guards the boundary.
38+
func TestMtreeDiff_UpdateUpdateConflictAtBuildTime(t *testing.T) {
39+
t.Run("SingleRow", func(t *testing.T) {
40+
runMtreeUpdateConflictCase(t, "mtree_uu_conflict_1", []int{1})
41+
})
42+
t.Run("MultiRowBoundary", func(t *testing.T) {
43+
// The divergent row (id=3) is the middle block boundary under BlockSize=2.
44+
runMtreeUpdateConflictCase(t, "mtree_uu_conflict_n", []int{1, 2, 3, 4, 5})
45+
})
46+
}
47+
48+
// runMtreeUpdateConflictCase seeds ids on both nodes, diverging the LAST id's
49+
// non-key column, then asserts both table-diff and mtree diff report exactly
50+
// one divergent row.
51+
func runMtreeUpdateConflictCase(t *testing.T, tableName string, ids []int) {
52+
ctx := context.Background()
53+
qualifiedTable := fmt.Sprintf("%s.%s", testSchema, tableName)
54+
nodes := []string{serviceN1, serviceN2}
55+
divergentID := ids[len(ids)-1]
56+
57+
for i, pool := range []*pgxpool.Pool{pgCluster.Node1Pool, pgCluster.Node2Pool} {
58+
nodeName := pgCluster.ClusterNodes[i]["Name"].(string)
59+
_, err := pool.Exec(ctx, fmt.Sprintf( // nosemgrep
60+
"CREATE TABLE IF NOT EXISTS %s (id INT PRIMARY KEY, name VARCHAR)", qualifiedTable))
61+
require.NoError(t, err, "create table on %s", nodeName)
62+
_, err = pool.Exec(ctx, fmt.Sprintf( // nosemgrep
63+
"SELECT spock.repset_add_table('default', '%s')", qualifiedTable))
64+
require.NoError(t, err, "add to repset on %s", nodeName)
65+
}
66+
t.Cleanup(func() {
67+
for _, pool := range []*pgxpool.Pool{pgCluster.Node1Pool, pgCluster.Node2Pool} {
68+
pool.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", qualifiedTable)) // nosemgrep
69+
}
70+
})
71+
72+
// Seed identical rows on both nodes, then diverge divergentID's non-key
73+
// column. repair_mode(true) keeps these writes from replicating.
74+
seed := func(pool *pgxpool.Pool, divergentName string) {
75+
tx, err := pool.Begin(ctx)
76+
require.NoError(t, err)
77+
_, err = tx.Exec(ctx, "SELECT spock.repair_mode(true)")
78+
require.NoError(t, err)
79+
for _, id := range ids {
80+
name := fmt.Sprintf("name-%d", id)
81+
if id == divergentID {
82+
name = divergentName
83+
}
84+
_, err = tx.Exec(ctx, fmt.Sprintf( // nosemgrep
85+
"INSERT INTO %s (id, name) VALUES ($1, $2)", qualifiedTable), id, name)
86+
require.NoError(t, err)
87+
}
88+
_, err = tx.Exec(ctx, "SELECT spock.repair_mode(false)")
89+
require.NoError(t, err)
90+
require.NoError(t, tx.Commit(ctx))
91+
}
92+
seed(pgCluster.Node1Pool, "zaid")
93+
seed(pgCluster.Node2Pool, "shabbir")
94+
95+
// Baseline: plain table-diff must find the divergence.
96+
tdTask := newTestTableDiffTask(t, qualifiedTable, nodes)
97+
require.NoError(t, tdTask.RunChecks(false))
98+
require.NoError(t, tdTask.ExecuteTask())
99+
require.Equal(t, 1, sumDiffRows(tdTask.DiffResult.Summary.DiffRowsCount),
100+
"table-diff must find the id=%d conflict", divergentID)
101+
102+
// mtree diff on the same build-time divergence must ALSO find it.
103+
mtreeTask := newTestMerkleTreeTask(t, qualifiedTable, nodes)
104+
mtreeTask.BlockSize = 2
105+
mtreeTask.OverrideBlockSize = true
106+
require.NoError(t, mtreeTask.RunChecks(false))
107+
require.NoError(t, mtreeTask.MtreeInit())
108+
t.Cleanup(func() { _ = mtreeTask.MtreeTeardown() })
109+
require.NoError(t, mtreeTask.BuildMtree())
110+
111+
diffTask := newTestMerkleTreeTask(t, qualifiedTable, nodes)
112+
diffTask.Mode = "diff"
113+
diffTask.Output = "json"
114+
diffTask.BlockSize = 2
115+
diffTask.OverrideBlockSize = true
116+
require.NoError(t, diffTask.RunChecks(false))
117+
require.NoError(t, diffTask.DiffMtree())
118+
require.Equal(t, 1, sumDiffRows(diffTask.DiffResult.Summary.DiffRowsCount),
119+
"mtree diff must ALSO find the id=%d conflict", divergentID)
120+
}
121+
122+
func sumDiffRows(counts map[string]int) int {
123+
total := 0
124+
for _, c := range counts {
125+
total += c
126+
}
127+
return total
128+
}

0 commit comments

Comments
 (0)