Skip to content

Give the first Merkle tree block no lower bound - #173

Open
danolivo wants to merge 5 commits into
mainfrom
ace-222
Open

danolivo wants to merge 5 commits into
mainfrom
ace-222

Conversation

@danolivo

@danolivo danolivo commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Problem

A Merkle tree takes its block bounds from one node only: the reference node. The first block starts at that node's smallest key. Rows on other nodes with a smaller key were in no block. No leaf hash covered them, and mtree table-diff reported the nodes as equal.

Example: n1 has keys 100..1000, n2 has keys 1..1000. Rows 1..99 on n2 were never compared.

The tool handled rows inserted after the build because CDC moved the start of the first block down. Rows that already differed at build time were never found, and finding them is the tool's main use case.

Change

The first block (node_position = 0) now has no lower bound, just as the last block has no upper bound.

Leaf hashes, CDC dirty marking, merge row counts, and the diff row fetch all follow this rule. Every function that reads block ranges returns a nil start for the first block.
Merge row counts add the rows below the stored range_start with a separate subquery. An OR in the join condition would stop the index scan on the lower bound for every block.
The stored range_start of the first block is still a real key, because leaves are numbered by range_start. A split of the first block sets it to the table's smallest key. Leaves with the same range_start keep their old order, so the first leaf always stays at position 0.
CDC no longer moves range_start. That code also took the smallest key as MIN() over text, which is wrong for numeric keys.

MarkBlockDirty, GetBlockCountSimple and GetBlockCountComposite have no
callers. Remove them with their SQL templates and the BlockCountSimple and
BlockCountComposite types, so that later changes to block bounds do not
have to keep dead code in step.
The check "this bound is empty or all NULL" was written four times: as
sliceAllNil in db/queries, as allNil in the mtree package, as
rangeSliceAllNil and as an inline loop in table-diff. Keep one exported
copy, queries.AllNil, and use it everywhere. The callers that also tested
len() > 0 before the call no longer need to, because AllNil already treats
an empty slice as open.
GetBlockRowCount, GetBulkSplitPoints and the max-value queries now treat a
nil or all-NULL start or end as "no bound on this side". Before, the
composite branch of GetBlockRowCount and GetBulkSplitPoints added a
condition for an all-NULL bound, which compares the key with NULL and
selects no rows. GetMaxValSimple and GetMaxValComposite search the whole
table when they get no start key, and GetMaxValSimple returns nil when no
row matches, like the composite variant.

No caller passes an open start yet, so the behaviour does not change. The
next commit gives the first block of a Merkle tree no lower bound and needs
these helpers.
The block bounds come from one node only, the reference node. The first
block started at the smallest key of that node. Rows on other nodes with a
smaller key were in no block: no leaf hash covered them, and the diff
reported the nodes as equal. Rows inserted after the build were handled,
because CDC moved the start of the first block down, but rows that already
differed at build time were never found.

The first block (node_position 0) now has no lower bound, in the same way
as the last block has no upper bound:

- Leaf hashes, CDC dirty marking and the diff row fetch use no lower bound
  for this block. Every function that reads block ranges returns a nil
  start for it.
- Row counts for merges add the rows below the stored range_start with a
  separate subquery, so the join keeps an index scan on the lower bound.
- A split of the first block sets its stored range_start to the smallest
  key, so the block stays first when the leaves are numbered again by
  range_start.
- Leaves with the same range_start keep their old order when they are
  numbered again. A reference node with one row gives two such leaves, and
  without the tie-break the second one could take position 0.
- CDC no longer moves range_start. That code also took the smallest key
  as MIN() over text, which is wrong for numeric keys.
- The hash version is now 3, so mtree update computes all leaf hashes of
  an older tree again.
Add an "Open-ended first block handling" item next to the one for the last
block: why the first block has no lower bound, which code paths follow this
rule, and why its stored range_start still matters for the order of the
leaves.
@danolivo danolivo self-assigned this Oct 7, 2026
@danolivo danolivo added the bug Something isn't working label Oct 7, 2026
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c777712b-fcc5-45b5-884e-6d37ca42dac7
📥 Commits

Reviewing files that changed from the base of the PR and between 557892d and 42333f9.

📒 Files selected for processing (11)
  • db/queries/first_block_test.go
  • db/queries/queries.go
  • db/queries/queries_test.go
  • db/queries/templates.go
  • docs/CHANGELOG.md
  • docs/design/merkle.md
  • internal/consistency/diff/table_diff.go
  • internal/consistency/mtree/merkle.go
  • pkg/types/types.go
  • tests/integration/merkle_tree_test.go
  • tests/integration/mtree_first_block_test.go
💤 Files with no reviewable changes (1)
  • pkg/types/types.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Position zero now has no lower bound for leaf hashing, row counts, and diff operations. Query templates handle absent starts, and position resets preserve order when leaf starts tie. Hash metadata advances to version 3.

Changes

First-leaf bounds and Merkle diffs

Layer / File(s) Summary
Open-bound query contract
db/queries/queries.go, db/queries/templates.go, db/queries/queries_test.go, db/queries/first_block_test.go
Query results carry leaf positions and clear the start for position zero. Maximum-value lookups, row counts, and split-point queries handle absent bounds. Hash metadata advances to version 3.
First-leaf row ownership and resequencing
db/queries/templates.go, db/queries/first_block_test.go, db/queries/queries.go, pkg/types/types.go
Leaf zero matches and counts keys below its stored start. Counter updates no longer move that start. Position resets order tied starts by prior position. Block-count and dirty-marking query APIs and associated types are removed.
Hashing, splitting, and diff validation
internal/consistency/mtree/merkle.go, internal/consistency/diff/table_diff.go, tests/integration/mtree_first_block_test.go, tests/integration/merkle_tree_test.go, docs/CHANGELOG.md, docs/design/merkle.md
Build-time hashing opens the first start in a copied range. First-block splits reset the stored start to the table minimum. Diff boundary handling, integration tests, and documentation cover first-leaf bounds and tied starts.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 42333

The inspected paths preserve first-leaf coverage during splitting and hash upgrades. No issue requiring a fix before merge was established.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 42333

The normal upgrade path restores comparison coverage without expanding database permissions. However, running an older version against an upgraded tree can recreate incomplete hashes while leaving the tree marked as upgraded, potentially hiding differences after returning to the new version.

Retained concerns

  • Medium · reliability · inferred: Upgraded trees are not protected against older writers. A version-2 binary can recompute a dirty first leaf using its stored lower bound, clear its dirty flag, and leave hash_version at 3. Returning to the new binary does not trigger full recomputation, so differences below the stored start can remain hidden. This requires rollback or mixed-version access to the same persisted tree; such deployment was not established.
Security review details

Security Blast Radius

  • inferred — The supported exposure is comparison integrity for selected tables across participating nodes, including their persisted Merkle hashes. The rollback scenario requires an older process with existing tree-write authority; it does not establish cross-tenant access or privilege gain.

Trust Boundaries and Controls

  • observed — The inspected HTTP Merkle routes retain authentication and dispatch through task handlers. Open lower bounds alter row selection, not the route authentication or database connection authority.

Resilience and Maintainability Implications

  • observed — A normal bounded CDC drain waits for processing workers before returning. A busy replication slot permits best-effort continuation alongside another consumer, and counter updates identify leaves by mutable positions. These mechanisms predate this PR; safety under concurrent resequencing remains unresolved rather than a verified new failure.

Hardening Proposals

  • proposed — Define an explicit version-3 writer and rollback policy: prevent older writers from accessing upgraded trees, or require a controlled rebuild before returning to the new binary. Validate the upgrade–older-writer–reupgrade sequence, including clean hashes that carry a misleading version marker.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 8 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: the first Merkle tree block has no lower bound.
Description check ✅ Passed The description explains the problem and the changes made to first-block bounds, hashing, row counts, splitting, and CDC behavior.
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 38 functions across 8 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

A rabbit checks the leaf below,
Where smaller keys can safely go.
The hashes count each hidden row,
Tied starts keep the order they know.
I thump and watch the diffs now show.

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics -9 complexity · -10 duplication

Metric Results
Complexity -9
Duplication -10

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.

@danolivo
danolivo requested a review from mason-sharp October 8, 2026 11:08
@danolivo

danolivo commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

We will raise the hash_version once before the release.

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.

1 participant