Skip to content

layerfs rm/compact: retire Branches and reclaim unreachable Store history #411

Open

Description

@2233admin

Summary

A project's Store has no way to give space back. Every Branch, Commit and Layer stays, including committed build output, and the SDK has no call to retire a Branch (branch.md lists fork, list_commits, diff_commits, diff_branches, merge, publish). core/docs/architecture/84-global-sqlite-store.md §8 says so: "Safe garbage collection of unreachable history needs its own contract and is not implemented"; free pages are reclaimed, unreachable history is not. I found no open issue tracking it, so this is one, with what we see in real use.

Why it matters for agent work

The agent presets ask agents to commit after every command, "sources, lockfiles, node_modules, build output", and to merge generated paths newer-wins through /.lww. That is what makes forks warm, and it means each rebuild that changes target/ or node_modules adds its new bytes to history for good. Agents also make many short-lived Branches (one per task, plus scratch and verification Branches) that are never merged or published. Over weeks the only way to shrink a project is to start a new one.

Numbers from two real projects (LayerFS 5fb68dd, Linux, WSL2)

Project Tree Store
tdxcli-rs (Rust workspace, 485 crates) project.init of a tree with a built target/: 5.05 GB 1.70 GB after init; 3 agents' Commits after a one-line edit and incremental rebuild added 0.11 GB
KATANAview (pnpm monorepo; node_modules 1,004 MB, 57,931 files) 18 Commits, 8 Branches, 2 Layers; 28.5 GB if every Commit's snapshot were stored raw 722 MB on disk per the host's storage read (GET /layerfs/project/<p>/storage, DSH's Storage figures); the Store volume is 1.1 GB (du)

Dedup and compression keep each step small, which is the good news. The cost is that none of it can be reclaimed: 4 of those 8 KATANAview Branches are finished smoke, baseline and verification Branches, and a cargo clean, a toolchain bump or a lockfile change adds a new generation of target/ / node_modules that stays even after every Branch holding it is abandoned.

What we do today

Our integration rotates a project: export main's files, project.init a new project from them, rebind, delete the old Store volume. It works, but it loses all History and every unmerged Branch, so it is a last resort.

Question

Is there a planned shape for this contract? Two pieces would cover agent use:

  1. retiring a Branch (it leaves the namespace; its Commits become unreachable unless another Branch or a Layer holds them);
  2. reclaiming objects no Branch head, Layer or open Workspace reaches, explicitly and bounded like the existing free-page reclamation.

Happy to test a proposal on these projects and report Store sizes before and after.

Activity

  1. yifanxuaaa commented on Oct 22, 2026

    @yifanxuaaa
    Contributor

    Thanks for the detailed report and the numbers from real projects. You're right: there's no way today to retire a Branch or reclaim unreachable history, and the docs say so.

    This needs a proper design (reachability across Branches, Layers and open Workspaces, delta bases, immutable packs, and keeping the single-writer publish step short), and I need more time to think it through. I'll be releasing a roadmap soon that covers it. Your offer to test a proposal and report Store sizes before and after is very welcome; I'll follow up here when there is something to try.

  2. SSK015 commented on Oct 27, 2026

    @SSK015
    Contributor

    I'd like to take on the read-only reachability groundwork for this, aligned with A1 in #446.

    Proposed first step:

    • Map the current reference roots and object dependencies in a short design note, including how Branches, Layers and open Workspaces affect future reclamation.
    • Implement bounded, streaming enumeration of the objects reachable from a pinned ref, accounting for the dependencies required to reconstruct their content.
    • Add focused correctness tests for shared objects and delta dependencies.

    I'd keep logical reachability separate from physical pack reclamation. Branch retirement, object deletion and compaction can follow once the retention rules are agreed.

    Is anyone already working on A1 or this issue, and does this seem like a useful scope for me to take on? I'd be happy to align on the traversal boundary and API shape before implementing.

  3. changed the title [-]Store: no way to retire a Branch or reclaim unreachable history[/-] [+]layerfs rm/compact: retire Branches and reclaim unreachable Store history[/+] on Oct 27, 2026
  4. cadorn commented on Oct 27, 2026

    @cadorn

    DISCLOSURE: This submission was generated by AI under the approval of the human who owns the github account. If it is unwelcome due to the use of AI or there is any feedback, please comment. Any human comment will receive a human response where applicable. This submission is intended to enhance the project, not frustrate maintainers. Thank you for making your project available open source!

    Some requirements for the Branch delete, in case they are useful to the design:

    • A typed refusal while a Workspace is mounted on the Branch, or while the Branch is the base of another Branch.
    • The Branch's record removed at once, so the Branch no longer appears in branch.list.
    • Reclaiming the Commits, Layers and objects that no remaining Branch reaches, either at once or by a collection the caller can trigger and observe (for example store.compact), with a stated rule for what is reclaimed and when.
    • An SDK call (branch.delete(name)) alongside the CLI.

    Workflows that create many short-lived Branches, such as one per change or one per merge result, accumulate Branches and their history with no way to reclaim them, so a long-lived Store only grows. A delete with predictable reclamation bounds that growth.

  5. SSK015 commented on Oct 28, 2026

    @SSK015
    Contributor

    Design proposal: read-only snapshot object enumeration

    Following my earlier offer, here is a proposed first slice of A1 in #446 toward this issue. I'd appreciate confirmation of the scope and integration choices before implementation.

    Status: design proposal based on source review at 582bc786f. The validation below is planned.

    1. Proposed scope

    Start with an explicit SnapshotClosure: the unique logical objects needed for the current filesystem view of one resolved ref.

    • Input: an authorized Store/project context, a ref (main, Branch, Commit or Layer), and the Store's construction policy and inode scope/profile.
    • Resolve the ref once to its immutable node and filesystem root. Return that identity and context with the result.
    • Follow the canonical object references, emitting each ObjectId once, with its role and canonical length.

    This first slice would cover the selected filesystem view. Commit/Layer ancestry needs a separately defined HistoryClosure; branch retirement and physical reclamation need the Store-wide retention contract. For example, an object absent from Branch A's result may still be required by Branch B or an open Workspace.

    History ancestry and provenance also need separate treatment: following source_branch to that Branch's current head could pull later history into a fixed ref's result.

    2. Reuse the current readers and decoders

    The existing Branch snapshot operation resolves effective_root, scope and profile in a short, coherent history transaction.

    Content already derives direct references through its role-specific admission decoders. The walker should reuse these decoders. Directory names refer to inode serials, not directly to ObjectIds; file mappings, attributes and symlinks must still be covered.

    There is one interface gap: AuthenticatedObjects returns canonical bytes without ObjectRole. The outer canonical envelope's kind is not that role; the role is held in Storage's ObjectLocation. I propose a Storage-owned, role-aware authenticated read boundary, with Content retaining ownership of reference decoding. Its API shape remains to be agreed.

    Storage's existing reader reconstructs and authenticates delta-encoded and pooled objects. Delta bases, inode_value rows and co-resident pack records are physical recovery dependencies, not automatically logical children. This first result would therefore not be an encoded-pack transfer or deletion manifest. A physical dependency report would be a separate Storage capability.

    Likewise, qualify_root checks namespace topology but explicitly excludes file mappings, symlink targets and attribute trees. It can complement the walker; it cannot substitute for full object enumeration.

    3. Consistency and bounded traversal

    Call the resolved value ResolvedRef, rather than implying it is a GC retention lease. Release the short ref-resolution transaction before traversal. If the Branch advances, continue from the originally resolved root.

    Current immutable reads rely on insert-only object locations, immutable packs/units and immutable published inode values. The entire traversal is not one long SQLite snapshot. Future concurrent deletion or repacking will need an additional root-retention/read-coordination protocol.

    The traversal would:

    1. Seed an indexed frontier and seen set with the resolved filesystem root.
    2. Process small windows bounded by both record count and bytes.
    3. Read/authenticate each object, derive its direct references and enqueue unseen IDs.
    4. Emit descriptors with backpressure, releasing per-window decode state.
    5. Return Complete only after the frontier is exhausted and every read and output operation has succeeded.

    Paging alone does not bound an in-memory visited set. I propose caller-owned indexed temporary backing, subject to agreement on its implementation and ownership. Under the single-global-Store rule, I would not assume an auxiliary SQLite database is permitted; the current temp_store=MEMORY also cannot be treated as disk-backed state. Writing scratch records into the source Store would change the proposed read-only contract.

    Here, read-only means the source Store's persistent objects, history, indexes and format remain unchanged; the output and approved temporary traversal state may be written. Reads can still perform substantial decompression and reconstruction work.

    4. Completion and validation

    The result should state coverage=SingleSnapshot and dependencies=LogicalCanonical, alongside the source/resolved identities. Missing or invalid objects, provider errors, cancellation, output failure and capacity exhaustion leave the stream incomplete, with the original cause. There is no automatic retry, ref refresh or fallback. A consumer accepts the final manifest only after the successful terminal record.

    Complete would certify this traversal's coverage, not namespace topology, complete history or Store-wide GC safety. Live Workspace bases, pending merge roots, finished-but-unpublished Saves and uncertain publication outcomes require a later cross-process retention design. Some of these owners are currently process-local. The deletion guards and observable reclamation suggested above belong to that later contract.

    Planned tests:

    • Exact expected sets across all 13 roles, including empty trees, attributes and file mappings.
    • Shared objects, hardlinks and repeated chunks: unique object output while preserving reference multiplicity where reported.
    • Delta and pooled reconstruction, including missing/corrupt required dependencies; unrelated records sharing a pack do not become logical edges.
    • A Branch advancing after resolution, and a second Branch retaining objects absent from the selected snapshot.
    • Partial output, cancellation, integrity/capacity failures, and unchanged persistent source data.
    • Increasing graph sizes and slow consumers under fixed window/cache budgets, verifying that the frontier/seen state is actually backed rather than accumulated in RAM.

    Decisions to confirm

    1. Scope: is SnapshotClosure a useful first A1 deliverable, or must the first iteration include Commit/Layer ancestry?
    2. Interface: should the role-aware reader/walker start as internal Rust/Engine capability, with SDK/CLI exposure later?
    3. Backing: what temporary indexed backing is acceptable, and what capacity, ownership and cleanup rules should it follow?

    With those agreed, I would split the implementation into the read/reference-decoding boundary and its focused tests, then the bounded traversal and completion semantics. Full-history retention and rm/compact would build on a separately reviewed contract.

  6. jiangkoumo commented on Oct 28, 2026

    @jiangkoumo
    Contributor

    补两点 SSK015 的 SnapshotClosure 和 cadorn 的需求里还没覆盖到的,供 A1 设计参考。

    1. 根集合有一部分不在全局 Store 里

    从 Branch / Layer / Commit 出发的可达性只是根集合的一部分。下面几类引用现在没有登记在全局库里,回收时如果只看 history 表,会把还在用的对象判成不可达:

    • 挂载中的 Workspace 的 base root:Workspace 挂在 Branch 的某个 Commit 上之后,Branch head 还可能被别的 Workspace 推进(或被 force 覆盖)。旧的 base root 从任何 Branch 都不可达了,但这个 Workspace 的读还依赖它。
    • 进行中的 Save / Commit / merge:pack 和 object_location 已经写进去,但还没发布到任何 Branch。
    • 在途读:SDK 进程内对已提交根的读,以及内核还没返回的读。

    所以「Workspace 是否还在用」需要有全局可见的登记,回收要等这些引用都释放后才能动。

    2. macOS native(#427)的 NFS 导出要以「导出已释放」为准

    #427 里 Workspace 是通过 daemon 内置的 loopback NFSv3 导出、再用 mount_nfs 挂上的。我们在卸载挂死问题里看到:umount 返回之前或卡住期间,内核还可能拿着句柄继续发读请求。所以在 native 上,判断「这个 Workspace 不再引用它的 base root」应该以 daemon 确认导出已完全释放为准,不能只看卸载请求有没有发出。Linux FUSE 也有类似的窗口,只是表现不同。

    3. 我们可以帮忙测量

    我们这边有在 Linux 和 macOS native 上用 LayerFS 跑多 agent 开发的真实负载(每个任务一个 Branch,用完就扔),herdr 插件的 drop 现在也只能删指针、回收不了空间。等有可以试的版本,我们可以在两个平台上跑同样的负载,给出回收前后的 Store 大小(du 和 storage 接口两种读数),以及挂载中、Save 中途做回收的正确性测试。

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

    garbage collectionReclaiming disk space LayerFS leaves behindnew featureA new user-facing capability

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions