Repository navigation
layerfs rm/compact: retire Branches and reclaim unreachable Store history #411
Description
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:
- retiring a Branch (it leaves the namespace; its Commits become unreachable unless another Branch or a Layer holds them);
- 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
yifanxuaaa commentedon Oct 22, 2026
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.
SSK015 commentedon Oct 27, 2026
I'd like to take on the read-only reachability groundwork for this, aligned with A1 in #446.
Proposed first step:
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.
[-]Store: no way to retire a Branch or reclaim unreachable history[/-][+]layerfs rm/compact: retire Branches and reclaim unreachable Store history[/+]cadorn commentedon Oct 27, 2026
Some requirements for the Branch delete, in case they are useful to the design:
branch.list.store.compact), with a stated rule for what is reclaimed and when.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.
SSK015 commentedon Oct 28, 2026
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.main, Branch, Commit or Layer), and the Store's construction policy and inode scope/profile.ObjectIdonce, 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_branchto 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:
AuthenticatedObjectsreturns canonical bytes withoutObjectRole. The outer canonical envelope's kind is not that role; the role is held in Storage'sObjectLocation. 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_valuerows 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_rootchecks 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:
Completeonly 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=MEMORYalso 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=SingleSnapshotanddependencies=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.Completewould 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:
Decisions to confirm
SnapshotClosurea useful first A1 deliverable, or must the first iteration include Commit/Layer ancestry?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/compactwould build on a separately reviewed contract.jiangkoumo commentedon Oct 28, 2026
补两点 SSK015 的 SnapshotClosure 和 cadorn 的需求里还没覆盖到的,供 A1 设计参考。
1. 根集合有一部分不在全局 Store 里
从 Branch / Layer / Commit 出发的可达性只是根集合的一部分。下面几类引用现在没有登记在全局库里,回收时如果只看 history 表,会把还在用的对象判成不可达:
object_location已经写进去,但还没发布到任何 Branch。所以「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 中途做回收的正确性测试。