Skip to content

fix(branch): name every branch life by an incarnation-suffixed native ref - #579

Merged
aaltshuler merged 3 commits into
mainfrom
feat/branch-incarnation-paths
Aug 30, 2026
Merged

fix(branch): name every branch life by an incarnation-suffixed native ref#579
aaltshuler merged 3 commits into
mainfrom
feat/branch-incarnation-paths

Conversation

@aaltshuler

@aaltshuler aaltshuler commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

What & why

Closes #562. Deleting a branch and recreating it under the same name reused the same storage paths, so a warm handle's path-keyed session cache served the dead life's metadata to the new one (all columns in a record batch must have the same length, or stale rows), and a late-settling delete of the old fork could land on the new life's bytes.

Every branch life now owns a native Lance ref named {logical}.{ULID}, minted at branch_create. A recreated branch therefore never shares a tree/ path, a table fork, or a cache entry with its predecessor — the same trick already used for table incarnations. The Lance ref list of __manifest stays the only branch registry: no manifest rows, no CAS, no forward recovery. Resolving a logical name lists the refs and takes the single live incarnation; none is BranchNotFound, more than one is a typed conflict that names both.

  • Table forks copy the manifest branch's native name. Write paths, first-touch forks, EnsureIndices, merge target naming, sidecar table pins, and the orphan reconciler address forks by native name; gates, lineage, policy scopes, and the sidecar branch field stay logical.
  • The public name stays the only identity: a path segment ending in . + 26 Crockford characters is refused on every entry point, reads included, so native refs are never addressable or visible to Cedar. Ordinary dotted names (release.1.2) stay legal; the ancestor/descendant rule on logical names is unchanged.
  • A warm handle whose native ref vanished re-resolves through the listing (typed identifier mismatch, or BranchNotFound). Change-feed and Blob live-branch reads map a vanished named fork to their existing incarnation refusal; a surviving fork with a reclaimed version stays a retention gap.
  • branch delete acknowledges the manifest publication; the dead life's forks are reclaimed by cleanup. A delete that crashed between ref removal and tree removal leaves an inert tree on a dead path (follow-up: sweeper).
  • Existing branches keep their bare refs as legacy lives; no migration or format stamp. Manifest v6 and sidecar v9 unchanged.

Alternative considered and rejected on liability: a branch-registry row in the root __manifest (#578). Comparison in the RFC's Alternatives section.

Backing issue / RFC

Checklist

  • Change is focused (per-life branch identity in the native ref, plus the single resolution seam it requires)
  • Tests added/updated: branching, maintenance, failpoints, and lance_surface_guards owners extended; recovery, merge, change-feed, and cost fixtures converted to address forks by native name; the DST reborn-branch cache-poison repro (seed 9401 standalone + seed 10133 wide face) now runs as regression pins; DST cost golden regenerated
  • Public docs updated: user branching page (name rule, delete/recreate semantics), release note; developer guides (writes.md, recovery.md), Lance fence table, RFC registry
  • Reviewed against docs/dev/invariants.md — no hard invariant weakened, no deny-list item hit; invariant 11 holds (a branch-bound open lists the live refs: cost scales with branch count, not history)

Local verification

  • Canonical workspace graph (--features omnigraph-engine/failpoints,omnigraph-cluster/failpoints): green except the pre-existing legacy_name_is_confined_to_alias_and_compatibility_test, which trips on a stale .worktrees/blob-align-current copy on this machine
  • omnigraph-dst crate: 76/76 (cost golden regenerated: +1…+7 l.list/l.get per op class, the one ref listing per branch-bound open)
  • Both Clippy graphs, cargo fmt --all --check, check-docs.py, check-agents-md.sh, check-workflow-action-pins.py: clean

Notes for reviewers

  • Cost: a branch-bound open lists _refs/branches/ once (LIST + one GET per live branch). Branch delete resolves every surviving branch from that one listing, so its dependency probe stays at one open per branch; branch_control_cost and warm_read_cost contracts hold unchanged.
  • native_dataset_branch in dataset entries (HTTP snapshot responses) now carries the suffixed internal name; it was already a physical detail field.
  • Test helper graph_native_ref / native_ref_for / assert_native_branch_of resolve logical names for fixtures that forge or inspect per-table Lance state; helpers::open_dataset_head resolves transparently.
  • Follow-ups out of scope: a name-only ref listing to drop the per-ref GET; a sweeper for a crashed delete's manifest-level dead tree.

Greptile Summary

This PR assigns each branch lifetime an incarnation-suffixed native Lance ref while retaining logical names at public and policy boundaries.

  • Resolves logical branches through the manifest ref registry and rejects ambiguous incarnations.
  • Propagates native identities through table forks, writes, merges, maintenance, recovery, Blob reads, and change feeds.
  • Adds branch-rebirth regression coverage and updates lifecycle documentation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/omnigraph/src/db/manifest.rs Introduces logical-to-native branch resolution and incarnation-suffixed manifest branch creation.
crates/omnigraph/src/table_store.rs Preserves storage-error classification while distinguishing a vanished native fork from reclaimed retained history.
crates/omnigraph/src/blob.rs Rechecks native-fork authority before mapping a failed live Blob read to an incarnation refusal.
crates/omnigraph/src/branch_names.rs Defines native branch-name parsing, reserved public-name validation, and duplicate-incarnation conflict handling.
crates/omnigraph/src/db/omnigraph/optimize.rs Updates cleanup and orphan reconciliation to operate on incarnation-specific native fork names.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    L[Logical branch name] --> R[Resolve manifest ref registry]
    R -->|Exactly one live incarnation| N[Native ref: logical.ULID]
    R -->|No incarnation| M[BranchNotFound]
    R -->|Multiple incarnations| C[Manifest conflict]
    N --> T[Incarnation-scoped table forks and storage paths]
    T --> B[Reads, writes, merge, cleanup, Blob and change feed]
Loading

Reviews (3): Last reviewed commit: "test(forbidden): register the vanished-f..." | Re-trigger Greptile

Comment thread crates/omnigraph/src/table_store.rs Outdated
… ref

Closes #562. A deleted and recreated branch reused the same storage paths,
so a warm handle's path-keyed session cache served the dead life's
metadata to the new one (`all columns in a record batch must have the
same length`, or stale rows), and a late-settling delete of the old fork
could land on the new life's bytes.

Every branch life now owns a native Lance ref named `{logical}.{ULID}`,
minted at `branch_create`. The Lance ref list of `__manifest` stays the
only branch registry: resolving a logical name lists the refs and takes
the single live incarnation (none is `BranchNotFound`; more than one is
a typed conflict). Table forks copy the manifest branch's native name;
write paths, first-touch forks, EnsureIndices, merge target naming,
sidecar table pins, and the orphan reconciler address forks by native
name, while gates, lineage, policy scopes, and sidecar `branch` stay
logical. Bare refs remain legacy lives with no migration.

The public name stays the only identity: an incarnation-shaped path
segment is refused on every entry point, reads included. A warm handle
whose native ref vanished re-resolves through the listing; change-feed
and Blob live-branch reads map a vanished named fork to their existing
incarnation refusal. Branch delete acknowledges the manifest
publication; the dead life's forks are reclaimed by `cleanup`.

Tests: branching, maintenance, failpoints, and surface-guard owners
extended; recovery, merge, change-feed, and cost fixtures converted to
address forks by native name; the DST reborn-branch cache-poison repro
(seed 9401 standalone, seed 10133 wide face) now runs as regression
pins; DST cost golden regenerated for the one ref listing per
branch-bound open.

Docs: RFC 0042, Lance fence table, write/recovery guides, user branching
page, release note.
A recheck that failed for any reason mapped to the incarnation refusal,
hiding transient, permission, and dataset-open errors behind a branch
lifecycle conclusion. Change-feed and Blob live-branch reads now prove
the fork's absence from the table's ref listing and let every other
failure keep its own class.
@aaltshuler
aaltshuler force-pushed the feat/branch-incarnation-paths branch from eedf1dd to 146d919 Compare August 30, 2026 19:50
The rebase onto main's stricter gateway/chokepoint registries left
TableStore::named_fork_is_absent and the Blob recheck's read-only
.dataset() access unregistered.
@aaltshuler
aaltshuler merged commit 15f0d34 into main Aug 30, 2026
21 checks passed
@aaltshuler
aaltshuler deleted the feat/branch-incarnation-paths branch August 30, 2026 19:55
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.

bug: branch delete + recreate on a live handle corrupts the reborn branch's first write (stale cached state)

1 participant