Skip to content

Branched databases: blob store sharing, allocator, and GC safety #644

Description

@kriszyp

Summary

Wire blob handling for branched databases (#642, #643) using a hard-link clone of the blob tree, taken alongside the RocksDB checkpoint. This is the decided design, replacing an earlier shared-store-plus-high-water-mark approach this issue previously described — that approach is not being built.

Design

A branch materializes as a checkpoint of the base's RocksDB store (resources/branchDatabase.ts, materializeBranch). RocksDB's createCheckpoint covers only the SST/WAL files; it does not touch the blob directories that live alongside the database (getRootBlobPathsForDB, resources/blob.ts:2074). This issue adds the missing half: when a branch is materialized, recursively hard-link the base's blob tree into the branch's own blob root.

The mechanism already exists and is proven: dataLayer/blobBackup.ts's linkOrCopy/copyTree (lines 58, 133) already walk a blob tree and hard-link each file, falling back to a real copy on EXDEV/EMLINK/EPERM/ENOTSUP (cross-filesystem, link-count exhaustion, or a filesystem that forbids hard links) — reuse it rather than re-implement it. resources/databases.ts:1299 already notes this same same-filesystem constraint for the RocksDB checkpoint itself, so a branch already degrades to a full byte copy off-volume; the blob clone should follow the same rule for the same reason.

Once cloned, everything else falls out of ordinary filesystem semantics — no allocator sharing, no high-water mark, no gated deletion logic:

  • Blob directory: the branch gets its own blob root (<branch's blob path>, resolved the same way getBlobPathsForDatabaseName resolves any other database's), populated by the hard-link clone at branch-creation time. It is a real, independent directory — just one whose existing files share inodes with the base's.
  • ID allocator: the branch gets its own allocator, exactly like any other database — no sharing with the base or with other branches. getNextFileId (resources/blob.ts:2179) already seeds by scanning the directory for the highest existing filename-derived ID; since the branch's directory is a full clone (same filenames as the base had at checkpoint time), it naturally seeds above every ID that existed at checkpoint, and its own directory is separate from every other branch's, so two branches allocating new IDs concurrently cannot collide with each other or with the base. No new allocator-sharing code is needed at all.
  • Deletion / GC: the OS inode refcount is the reference count. When a branch overwrites or deletes a blob (deleteBlobsInObject, resources/blob.ts:2419), it unlinks its own directory entry; the base's link (and any other branch's) is untouched, and the underlying data is freed by the OS only once every link to it is gone. cleanupOrphans (resources/blob.ts:2792) needs no branch-awareness: it already only removes a directory entry for a blob the database's own records no longer reference, and unlinking that entry is exactly the "release my reference" operation — safe to run on the base while branches are alive, and safe to run on a branch independently.

This is materially simpler than the shared-allocator design this issue previously specified: there is no high-water mark to compute or carry on ApplicationScope, no gating logic in the delete path, and no suspend/resume protocol for orphan GC. The OS already implements the exact refcounting behavior that design built by hand.

Requirements

1. Clone the blob tree at branch materialization

In materializeBranch (resources/branchDatabase.ts), alongside createCheckpoint, hard-link-clone each of the base's blob roots into the corresponding branch blob root, using linkOrCopy/copyTree from dataLayer/blobBackup.ts (promote them to an exported, reusable utility rather than duplicating the walk).

2. Resolve the branch's own blob root

The branch's blob path resolver must key off the branch's own store identity (the same branchStoreName-derived identity openBranchDatabase already uses for the RocksDB store), not the base's — a branch is a real, independent database as far as blob storage is concerned. This is likely already correct by construction (getRootBlobPathsForDB keys off store.databaseName, and a branch's store carries its own identity) — verify rather than assume.

3. No allocator, HWM, or GC changes

None needed, per the design above. If verification finds a case where it is needed (e.g. the same-filesystem fallback degrading to a full copy changes filenames or timestamps in a way getNextFileId's scan cannot handle), that is the thing to fix — not a reason to build the shared-allocator/HWM/suspend-GC machinery this issue previously specified.

4. Replication

Unchanged: out of scope for the branch itself (branches are excluded from replication, per #643). Base replication continues normally; branch-allocated blobs never leave the local instance.

Acceptance criteria

  • Branch can read every blob that existed in the base at checkpoint time (via the hard-linked clone, not the base's directory).
  • A branch write that creates a new blob produces a file with an ID unique within the branch's own directory — no shared-directory collision to reason about, because the branch's directory is its own.
  • A branch write that overwrites/deletes a blob reference removes only the branch's directory entry; the base's copy (and any other branch's) is unaffected, verified both via the base still reading the original content and the underlying file still existing on disk under the base's root.
  • cleanupOrphans run on the base while a branch is alive does not remove any blob the branch still references (true by construction once directories are independent — verify, don't just assert).
  • cleanupOrphans run on a branch reclaims blobs that branch no longer references, independently of the base's or any other branch's GC state.
  • Off-volume / no-hard-link-support fallback: forcing linkOrCopy onto a path that cannot hard-link (mock EXDEV) still produces a fully readable branch blob tree via full copy.

Verification

Extend the integration test from #643 (integrationTests/components/branched-database.test.ts):

  1. Base data contains a row whose value includes a blob (e.g. 16 KB payload, above the inline threshold).
  2. Two branched apps appA, appB boot from the same base.
  3. Both apps read the row; both successfully stream the blob content, and the underlying files are distinct inodes with the same content on disk (or the same inode, if hard-linked — either is correct; the point is no shared mutable directory).
  4. appA overwrites the row with a new blob — assert the base's original blob file still exists and is unchanged on disk, and appB's read of the same row is unaffected.
  5. appB overwrites the same original row with a different new blob — assert appA's independent overwrite is unaffected, and the base is unaffected by either.
  6. Run cleanupOrphans against the base while both branches are alive — assert nothing either branch references is removed.
  7. Close appA and run cleanupOrphans against appA's own branch — assert appA's no-longer-referenced blob is reclaimed, independent of appB and the base.

Key files

  • resources/branchDatabase.ts — materializeBranch: add the blob-tree clone alongside createCheckpoint
  • dataLayer/blobBackup.ts — linkOrCopy (line 58), copyTree (line 133): promote to a reusable exported utility
  • resources/blob.ts — getRootBlobPathsForDB (2074), getBlobPathsForDatabaseName (2100), getNextFileId (2179), deleteBlobsInObject (2419), cleanupOrphans (2792): verify each behaves correctly against a branch's own independent, hard-linked directory — no changes expected, but verify rather than assume
  • resources/databases.ts:1299 — existing note on the same same-filesystem hard-link constraint, for the checkpoint itself

🤖 Updated by Claude on behalf of Kris — replaces the shared-allocator/high-water-mark design below with the hard-link clone design that was actually decided (2026-08-30). Original issue body preserved in the first comment for reference.

Activity

  1. added
    enhancementNew feature or request
    area:componentsComponents / applications subsystem
    area:storageStorage engine, LMDB/RocksDB, compaction
    on May 20, 2026
  2. added theissue type on Aug 7, 2026
  3. kriszyp commented on Aug 30, 2026

    @kriszyp
    MemberAuthor

    Superseded — the issue body above described a shared-store-plus-high-water-mark design that was not what got decided. Replacing it with the hard-link clone design (recursive hard-link of the base's blob tree at branch materialization; OS inode refcount does the reference counting; each branch gets its own independent directory and allocator, no HWM or GC gating needed).

    Original body, preserved for reference:


    Summary

    Wire blob handling for branched databases (#642). Records in a branch's checkpointed RocksDB reference blob file IDs that resolve under the base's blob directory, so branches must share the base's blob store and ID allocator — and must avoid corrupting the base by deleting blobs the base still references.

    Background

    Today (per `core/resources/blob.ts`):

    • Blobs live at `$HARPER_HOME/blobs//` (or under `STORAGE_BLOBPATHS`), keyed by a 64-bit `fileId`. Records reference blobs as an `(storageIndex, fileId)` pair — no path embedded. (`blob.ts:681–699`, `:1080–1136`)
    • The allocator is a per-database monotonic counter held in a shared `BigInt64Array` registered as `'blob-file-id'` on the root store; bumped atomically (`blob.ts:775–815`).
    • Blobs are immutable on disk; deleted only when a record updates (sweep via `deleteBlobsInObject`, `blob.ts:614`) or by orphan GC (`cleanupOrphans`, `blob.ts:1218–1337`).
    • Replication sends blob content, not IDs; receiver assigns a fresh local ID. IDs are not cluster-stable. (`replication/replicationConnection.ts:1828–1920`)

    A naive checkpoint-based branch would have its own `blobs//` directory and its own allocator, breaking every existing blob reference in the cloned records.

    Requirements

    1. Shared blob directory

    The branch's blob path resolver must key off the base dbName, not the branch's namespaced name. `STORAGE_BLOBPATHS` multi-path handling carries through unchanged. The branch reads and writes into the same directory tree as the base.

    2. Shared ID allocator

    The branch's blob writer must increment the base's `'blob-file-id'` shared `BigInt64Array` (registered on the base's root store), not a branch-local one. Atomics already handle concurrent allocation; the only change is sourcing the SAB from the base. This guarantees that base writes, branch-A writes, and branch-B writes never assign the same ID into the shared directory.

    3. High-water mark (HWM)

    At branch creation, record the base allocator's current value as `hwm` on the `ApplicationScope.branches` entry. Any blob with `fileId ≤ hwm` is base-origin and existed at checkpoint time. Any `fileId > hwm` referenced by a branch record was allocated by that branch.

    4. Gated deletion in branches

    When a branch sweeps an old blob during a record update (`deleteBlobsInObject`, `blob.ts:614`, and any other sweep call sites on the branch write path):

    • If `fileId ≤ hwm` — no-op. The base (or other branches) may still reference it.
    • If `fileId > hwm` — normal delete is safe; only the branch can reference IDs it allocated.

    5. Orphan-GC safety

    `cleanupOrphans` (`blob.ts:1218–1337`) reaps any blob not referenced by the database's records. Branch-allocated blobs (ID > hwm) are by definition not referenced by base records, so a base GC pass would delete them out from under the branch.

    Simplest correct behavior: suspend base orphan GC while any branch of that base is alive. Track live branches via a small registry on the base's root store. On branch teardown the branch's blobs become true orphans and the next GC pass sweeps them.

    A future optimization could union the referenced-set across base + all live branches instead of suspending GC, but is not required for the QA use case.

    6. Replication

    Out of scope for the branch itself (branches are excluded from replication per the umbrella). Base replication continues normally. Branch-allocated blobs never leave the local instance.

    Acceptance criteria

    • Branch can read all blobs that existed in the base at checkpoint time.
    • Branch write that creates a new blob produces a file with a unique ID across base + all live branches (no collisions in the shared directory).
    • Branch update that overwrites a blob reference: file with `fileId ≤ hwm` is not removed from disk; file with `fileId > hwm` originating from this branch is removed.
    • While at least one branch of base `X` is alive, `cleanupOrphans` on `X` is suspended (or otherwise made branch-aware).
    • After all branches of `X` close, the next `cleanupOrphans` pass reclaims their leftover blobs.
    • Replication: branch writes do not propagate to peers (covered by umbrella).

    Verification

    Add to the integration test from #643:

    1. Base `data` contains a row whose value includes a blob (e.g. 16 KB payload, above the inline threshold).
    2. Two branched apps `appA`, `appB` boot.
    3. Both apps read the row; both successfully stream the blob content.
    4. `appA` overwrites the row with a new blob — assert the original base blob file still exists on disk; the new blob has `fileId > hwm_A` and lives in the shared directory.
    5. `appB` reads the original row again — still sees the original blob, unchanged.
    6. The base reads the original row — still sees the original blob, unchanged.
    7. Trigger base `cleanupOrphans` while `appA` is alive — assert it is suspended / does not delete `appA`'s new blob.
    8. Close `appA`; run `cleanupOrphans` — assert `appA`'s leftover blob is reclaimed.

    Key files

    • `core/resources/blob.ts` lines 614, 681–699, 775–815, 1080–1136, 1218–1337
    • `core/components/ApplicationScope.ts` — extend `branches` entry: `{ branchName: string; hwm: bigint }`
    • `core/components/Application.ts` — capture HWM at branch creation

    🤖 Filed by Claude on behalf of Kris

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:componentsComponents / applications subsystemarea:storageStorage engine, LMDB/RocksDB, compactionenhancementNew feature or request

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions