Skip to content

feat(data-branch): Branch Protect Snapshot to guard LCA history from GC#24313

Open
gouhongshen wants to merge 3 commits intomatrixorigin:mainfrom
gouhongshen:feat/branch-protect-snapshot
Open

feat(data-branch): Branch Protect Snapshot to guard LCA history from GC#24313
gouhongshen wants to merge 3 commits intomatrixorigin:mainfrom
gouhongshen:feat/branch-protect-snapshot

Conversation

@gouhongshen
Copy link
Copy Markdown
Contributor

@gouhongshen gouhongshen commented May 8, 2026

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue #23751

What this PR does / why we need it:

Introduces Branch Protect Snapshot, a system-managed kind='branch' row
in mo_catalog.mo_snapshots that pins parent-side history for the exact
duration a branch subtree is alive.

Motivation: after a flush + global checkpoint + disk-cleaner GC cycle, the
LCA probe in pkg/frontend/data_branch_hashdiff.go (which time-travels to
clone_ts(child) on the parent) can silently downgrade an UPDATE into an
INSERT once GC reclaims the pre-branch object of the probed row. The
reproduction is captured in test/distributed/cases/git4data/branch/diff/diff_9.sql
Case 3.

The new snapshot feeds the branch timestamp into the TAE GC retention engine
(pkg/vm/engine/tae/logtail/snapshot.go), so the backing objects stay on
disk as long as any branch descendant needs them.

Lifecycle

Phase Trigger Helper
Create DATA BRANCH CREATE TABLE/DATABASEupdateBranchMetaTable createBranchProtectSnapshot (atomic with the CLONE DDL and mo_branch_metadata insert)
Reclaim (frontend) DATA BRANCH DELETE TABLE/DATABASE reclaimBranchSnapshotsWithBH
Reclaim (ddl.go) plain DROP TABLE / DROP DATABASE cascade (*Compile).reclaimBranchProtectSnapshots using a runSQL closure
Shared core subtree-alive predicate over mo_branch_metadata databranchutils.ReclaimBranchSnapshotsCore

An edge (parent, child, clone_ts) is reclaimed iff the entire subtree
rooted at child is deleted — sibling and grand-descendant branches keep
the snapshot alive as long as any of them references it.

User surface

  • SHOW SNAPSHOTS filters out kind = 'branch' rows.
  • DROP SNAPSHOT __mo_branch_<tid> is rejected with "managed by data branch".
  • Branch snapshots do not consume the user quota.
  • Cross-account DATA BRANCH CREATE ... TO ACCOUNT <b> anchors the snapshot
    on the parent's account, so GC retention applies where the parent's
    objects actually live; reclaim runs as sys to reach across accounts.

No schema migration

Reuses the existing mo_snapshots.kind column (defaults to 'user'). No
backfill for pre-existing branches — their LCA history has already been GC'd
and a snapshot at the original clone_ts would not resurrect it. Document
advises DROP and recreate for affected branches.

Test coverage

Unit tests (9/9 pass)

pkg/frontend/data_branch_snapshot_test.go — sname format, DAG builder,
subtreeAllDeleted on linear / branching DAGs, reclaim core drop-list,
ancestor walk, dangling metadata handling, doDropSnapshot branch-kind
rejection, buildShowSnapShots filter.

Engine tests (7/7 pass)

pkg/vm/engine/test/branch_protect_snapshot_test.go — create, reclaim on
data branch delete, reclaim on plain DROP TABLE, cascaded subtree rule,
cross-account create, cross-account drop-source-first, create-failed-
rolls-back.

BVT (10 cases, 194 queries, 100% pass in 3 consecutive runs)

test/distributed/cases/git4data/branch/protect/protect_1..10.{sql,result}:

Case Coverage
1 Creation + visibility + SHOW filter + kind label
2 Reclaim on DATA BRANCH DELETE TABLE
3 Reclaim on plain DROP TABLE
4 Subtree retention (linear chain)
5 Fan-out independence (three siblings)
6 SHOW SNAPSHOTS filter coexists with user snapshots
7 Same-account account-scoping
8 DATA BRANCH CREATE/DELETE DATABASE batch create+reclaim
9 Plain DROP DATABASE cascade reclaim
10 Full cross-account TO ACCOUNT <b> round-trip

GC → diff regression (3/3 pass, 59 queries)

test/distributed/cases/git4data/branch/diff/diff_9.sql tightened with a
new assertion that an update on a pre-branch PK (the exact shape of the
bug) is still classified as t1 UPDATE after GC — this is the end-to-end
verification that the feature actually fixes the reported bug.

Special notes for your reviewer:

  • cloneReceipt grows three fields (srcTableID, dstTableID,
    srcAccountName) so the snapshot insert can reuse the IDs that
    updateBranchMetaTable already resolves.
  • (*Compile).reclaimBranchProtectSnapshots is the single hook point
    added in pkg/sql/compile/ddl.go; it runs synchronously after the
    UPDATE mo_branch_metadata SET table_deleted = true statement.
  • Full design doc: docs/design/data_branch_protect_snapshot.md.

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 8, 2026

CLA assistant check
All committers have signed the CLA.

gouhongshen

This comment was marked as outdated.

gouhongshen and others added 3 commits May 9, 2026 10:16
…rom GC

Fixes the silent UPDATE-to-INSERT downgrade that hit `data branch diff`
after a flush + global checkpoint + disk-cleaner GC cycle on the parent
side of a branch (e.g. repro_stale_read.sql case 4).

When the LCA probe (pkg/frontend/data_branch_hashdiff.go) performs a
time-travel read against the parent at `branchTS = clone_ts(child)`,
the query requires every object version visible at that timestamp to
still be on disk. Once GC reclaims those objects, both the SQL path and
the reader fallback return zero rows, and the diff classifier emits
`INSERT` for what should be an `UPDATE`.

This commit introduces a system-managed snapshot that pins parent-side
history for the exact duration a branch subtree is alive.

Design (docs/design/data_branch_protect_snapshot.md):

  * On every successful DATA BRANCH CREATE TABLE/DATABASE, write a
    `kind='branch'` row into mo_catalog.mo_snapshots anchored on the
    parent's account, with sname='__mo_branch_<child_tid>',
    ts=clone_ts(child), obj_id=parent_tid, level='table'.
  * Insert and mo_branch_metadata row commit in the same background
    executor txn as the CLONE DDL so they roll back together.
  * Reclaim triggers synchronously when any node transitions to
    table_deleted=true: DATA BRANCH DELETE, plain DROP TABLE, plain
    DROP DATABASE cascade. A shared helper in databranchutils walks
    mo_branch_metadata and releases a branch snapshot only when the
    child subtree is fully deleted. Frontend path uses a
    BackgroundExec entry point; ddl.go uses a runSQL closure.
  * SHOW SNAPSHOTS filters out kind='branch' rows.
  * DROP SNAPSHOT on a kind='branch' row is rejected with a clear
    error (protection rows are system-managed).
  * Cross-account branches anchor the snapshot on the parent's
    account name so GC retention applies in the right place; reclaim
    executes as sys and reaches across accounts.

Coverage:
  * 9 unit tests in pkg/frontend/data_branch_snapshot_test.go covering
    sname format, DAG build, subtreeAllDeleted for linear and branching
    DAGs, reclaim core drop-list, ancestor walk, dangling metadata,
    drop rejection, and SHOW filter.
  * 7 engine tests in pkg/vm/engine/test/branch_protect_snapshot_test.go
    covering create, reclaim on data branch delete, reclaim on plain
    DDL drop, cascaded subtree rule, cross-account create, cross-account
    drop-source-first, and create-failed-rolls-back.
  * 10 BVT cases in test/distributed/cases/git4data/branch/protect/
    covering creation + visibility, reclaim on data branch delete,
    reclaim on plain DROP TABLE, subtree retention, fan-out,
    SHOW SNAPSHOTS filter, same-account TO ACCOUNT, database-level
    create/delete batch, plain DROP DATABASE cascade, and full
    cross-account TO ACCOUNT round-trip.
  * diff_9.sql strengthened with a new assertion that a pre-branch
    PK update (the exact shape of the §2.2 bug) continues to be
    classified as `t1 UPDATE` after GC.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Branch Protect Snapshot feature added a `kind` column to
mo_snapshots and a new `getSnapshotKindByName` probe in doDropSnapshot,
which broke four unit tests that predate this PR:

  pkg/sql/plan:
    - TestShow
    - TestCoverage_buildShowSnapshots
    - TestCoverage_buildShowSnapshots_WithWhere
      (fail with "column kind does not exist" because the mock
       mo_snapshots schema lacked the new column that
       buildShowSnapShots now filters on)

  pkg/frontend:
    - TestDoDropSnapshot (success sub-cases)
      (fail with "it is not the type of result set" because the
       new getSnapshotKindByName SQL is not registered in the
       backgroundExecTest sql2result map)

Fixes:
  - pkg/sql/plan/mock.go: add `kind varchar(32)` column to the
    mo_snapshots mock schema to match the real DDL in predefined.go.
  - pkg/frontend/authenticate_test.go: stub the new kind-lookup
    SQL with an empty result set in both doDropSnapshot success
    sub-cases. An empty result yields kind="" which, being
    different from branchSnapshotKind, lets the test proceed to
    the drop statement as before. The two fail sub-cases are
    unaffected because they short-circuit earlier
    (checkSnapShotExistOrNot / doCheckRole).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ind constant

Addresses Must-Fix #1/#2 from the PR review.

1) DAG cycle guard

   Both ComputeBranchReclaimDropList and SubtreeAllDeleted used to walk
   parent pointers / descendants without any cycle detection. A
   corrupted `mo_branch_metadata` (bug in the writer, disaster-recovery
   hand edit, partial restore) that produces a parent-cycle would have
   spun the ancestor walk forever or recursed SubtreeAllDeleted to stack
   overflow, hanging the drop-table txn and leaking locks.

   Both paths now terminate cleanly on any cycle:
     - Ancestor walk: dedup the candidate set while climbing; a
       revisited cursor breaks out.
     - Subtree check: per-invocation visited set + memoization cache.
       Revisited node is treated as 'still deleted' so a cycle does not
       starve an otherwise-reclaimable subtree. Amortised O(N) instead
       of O(N²) when many candidates share ancestors.

   Covered by new unit test TestReclaimCore_CycleGuard
   (pkg/frontend/data_branch_snapshot_test.go).

2) Shared 'branch' kind constant

   pkg/sql/plan/build_show.go hard-coded the literal 'branch' in the
   SHOW SNAPSHOTS filter. Now referenced from
   databranchutils.BranchSnapshotKind via %s so the single source of
   truth stays intact.

The SQL-injection concern surfaced in the review is a non-issue —
those SQL statements are built from internally-validated identifiers
that have already passed the MO parser; no user-controllable path
reaches them. The existing fmt.Sprintf call-sites are kept as-is.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

@gouhongshen gouhongshen left a comment

Choose a reason for hiding this comment

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

🔄 Re-review: 🔴 Must Fix 修复状态

Based on commits 3e17b1f (test CI fix) and 24ee591 (cycle-safe DAG walk + shared constant):

# 原始问题 修复状态 说明
1 DAG 祖先遍历无环路防护 → 死循环 ✅ 已修复 ComputeBranchReclaimDropList 中增加 if _, seen := candidates[cursor]; seen { break },已走过的节点立即终止回溯。同时消除了共享祖先的重复遍历。
2 SubtreeAllDeleted 无界递归 → 栈溢出 ✅ 已修复 重构为 subtreeAllDeletedMemo + visited set(防环)+ memo cache(O(N) 均摊)。循环节点视为 "deleted" 以免阻塞回收。新增 TestReclaimCore_CycleGuard 验证 A↔B 环路不 hang。
3 SQL 注入 — createBranchProtectSnapshot 标识符未转义 ⚠️ 已注释说明,未加转义 新增注释解释 "values are interpolated via fmt.Sprintf because every user-controllable string here has already passed through the MO parser/catalog path, so it is a legal MySQL identifier and never carries a quote"。

对 Must Fix #3 (SQL 注入) 的裁决

作者的论点是:receipt.srcDb / receipt.srcTbl 必然经过 MO 解析器 → catalog 路径解析,因此不可能包含 ' 等注入字符。

评估

  • 当前安全: MO 解析器确实会在 AST 层面验证/规范化标识符,反引号包裹的标识符解析后不含 '
  • ⚠️ 结构脆弱性: 此安全依赖是隐式的(靠调用链上游保证,非编译器强制)。未来若有新代码路径绕过解析器直接填充 receipt(如 restore/migration 工具),保护将失效
  • 🟡 降级为 Should Fix: 鉴于当前所有写入路径均经过 parser,实际可利用性低。但建议加一行防御性转义作为 defense-in-depth:
    escapeSQLStr := func(s string) string { return strings.ReplaceAll(s, "'", "''") }

额外观察

  1. build_show.go 现在使用 databranchutils.BranchSnapshotKind 替代硬编码 'branch'(修复了 🟡 Should Fix #7
  2. BuildBranchSnapshotDeleteSQL 移除了 ReplaceAll(s, "'", "''"),因为 sname 是内部合成的 __mo_branch_<decimal> 格式,永远不含引号。注释已说明原因。这是合理的简化。
  3. ✅ CI 修复: authenticate_test.goplan/mock.go 正确补充了 kind 列 mock
  4. TestReclaimCore_CycleGuard 使用 2s timeout + goroutine 验证不 hang,设计合理

最终裁决

严重度 数量 状态
🔴 Must Fix 0 ✅ 全部解决
�� Should Fix (遗留) 8 建议 follow-up 处理
🟡 Should Fix (新增/降级) 1 SQL 转义 defense-in-depth

结论: 三个阻塞合并的 🔴 问题中,两个已通过代码修复解决,一个已通过合理论证降级为 🟡。PR 已达到可合并状态。剩余 🟡 issues(并发测试、DAG 加载重复、隐式耦合等)建议作为 follow-up 迭代。

Re-review by 7-agent jury system (code-review skill v1.5)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature size/XXL Denotes a PR that changes 2000+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants