Reclaiming Storage

Version control for data is not free the way it is for code. A pin is a native ref, and a native ref holds storage: an Icechunk tag keeps every chunk its snapshot references, a Neon branch keeps its history window, and an Iceberg tag blocks snapshot expiry. Each commit adds one pin per changed object, and each fork adds a branch. This page is the home of two things: plans, which preview store writes before they happen, and the three levers that keep the storage bill in check.

The rule underneath: tether’s VCS history is the reference set. A pin, branch or listing stays alive while some manifest in some commit names it. Drop the commit, and gc releases what it held.

Preview before you write: plans

Most commands that write to a store are a read-only plan followed by an apply (the list):

tether commit -m "Relabel plate1" --dry-run
plan: commit (3 write(s))
                 pin  zarr/imaging  [icechunk] tether.0a1b2c3d.deb4861eb388e5f6  native ref at snapshot_id=K3F9M2A1B7C8D4E5…
              record  features      [lance] pin=record: state branch=main version=7, no native ref
              record  raw/plate1    [file] Observed: recorded, not recoverable
          vcs-commit  jj commit: 'Relabel plate1'
                      db/metrics: unchanged

Nothing happened: no tag, no manifest write, no VCS commit. --json prints the same plan as data, and --plan FILE saves it for later:

tether commit -m "Relabel plate1" --plan /tmp/commit.json   # plan only
# review, get sign-off, ...
tether commit --from-plan /tmp/commit.json                   # apply it

Apply checks the world again first. If an object moved, or the manifests changed, the apply is refused with StalePlanError and nothing is written: plan again and look. gc and drop are dry runs by default, and need --no-dry-run (or --from-plan) to apply.

From Python the split is explicit (repo.plan_commit(...) then repo.apply_commit(plan), and so on), and Plan.to_json() and Plan.from_json() round-trip. The convenience forms (tether commit, Repo.new(), …) plan and apply under one lock, with the same checks.

What a plan checks before it applies

A plan records what it saw as typed preconditions (Plan.preconditions, visible in a saved plan’s JSON). Every apply_* runs them all before its first action, and the first mismatch stops the whole apply with StalePlanError. So a stale plan does nothing at all.

A saved plan is valid only for the tether version that made it: it records that version (tether_version), and any other tether refuses to load it (“this plan was made by tether X; this is tether Y: re-run the plan”), as it does every plan saved before plans recorded one (“made by an older tether”). After upgrading tether-vcs, plan again.

Each command requires some kinds, so a plan that lacks one is refused as stale (“lacks the … precondition(s) tether requires of it”). A saved plan also carries a digest over its version, actions, context and preconditions. An edited plan fails the digest, so a dropped action or a changed target cannot apply under checks that describe another plan. The digest detects edits; it does not authenticate, since whoever can write the file can recompute it.

Command Required preconditions Per object
commit workspace_id, workspace_bookmark, manifest_hash –
new workspace_id, manifest_hash each fork: ref_head (the branch exists) or ref_absent; each reuse: ref_head; each adopt: ref_present
restore workspace_id, workspace_bookmark, manifest_hash each fork: ref_head or ref_absent
promote workspace_id, workspace_bookmark, bookmark_head, manifest_hash each fast-forward or merge: base_state, and ref_head or pin_state
drop workspace_id, workspace_bookmark, bookmark_head, no_new_holders, vcs_head, history_digest –
gc, repair workspace_id, history_digest –

workspace_id binds a plan to the checkout that made it (--plan first persists the id in workspace.toml). workspace_bookmark binds it to the bookmark, as both the workspace file and the VCS see it: git’s HEAD branch, or under jj the bookmarks at @ (at @- when @ has edits). A bookmark_head that names no bookmark fails. Three plans bind to no checkout: import checks the manifest hash, upgrade the dataset’s version, and forget-workspace nothing.

history_digest covers every visible commit, because a bookmark committed in another workspace may reference a pin a gc plan would release. A promote’s base_state is the base branch’s head, and ref_head or pin_state the state its source names.

An adopt moves nothing, so it is not bound to a head. ref_present holds while the adopted branch exists and no newer generation of it (.2, .3) has appeared. When the store could say that a --shared adopt builds on the bookmark’s pin, the plan also carries ref_descends: it holds while the branch still does, and fails if the store can no longer tell.

A race during apply is handled per action instead:

  • The heads a plan reviewed are what the stores move from: a ref that moved in between is refused with RefMovedError, atomically where the backend can (conditional moves).
  • A branch that moves after gc’s preflight is kept and reported, while the rest of the plan finishes.
  • commit fingerprints each object again against one snapshot.
  • promote lands the state the plan reviewed, not the source branch’s head at apply time, so a write that slips in between is not promoted.
  • A lazy fork on the first writable open resets an existing branch only onto the head new reviewed and agreed to reset.

Not every store write is a plan. pull pins what moved and commits it, undo deletes the branches an operation created, abandon --gc applies the gc plan it prints, add --create makes a store, and the first writable open forks the branch new decided on. None takes --dry-run, but new --eager --dry-run previews the forks, and abandon without --gc shows its plan without applying it.

Lever 1: do not pin what you will not need – --pin record

Forking needs a source, not a pin. Say you branch off to try something, and only the two heads matter afterwards, not recovering the exact split point next year. Then the tag at the split point is pure cost. Register the object with --pin record:

tether add features s3://bucket/features.lance --kind lance --pin record
tether add zarr/imaging s3://bucket/imaging --kind icechunk --pin record

With pin = "record" on any backend:

  • commit records the state (a snapshot id, version, commit hash or LSN) but creates no native ref. The plan shows record ... pin=record.
  • new forks straight from the recorded state: the snapshot id in Icechunk and Iceberg, (branch, version) in Lance, the commit in Dolt and git, and in Neon the LSN on the branch it was read from. It does so at once even when forks are lazy, since that branch is what keeps the state alive, unless you pass --defer-record (Lazy or eager).
  • verify checks that the state is still reachable, and open -r works while it is.

The trade: recoverability is now bounded by the system’s own retention. Icechunk’s snapshot lives until someone expires it, Iceberg’s until snapshot expiry runs, and Neon’s until the history window closes. verify --all-history turns each expiry into a MISSING report, so you know which old commits have become labels without data. Mix per object: keep --pin native on the archive-quality store and use record on scratch.

Lever 2: drop history you no longer want

Say an experiment produced five commits and only the last matters. Each of the four intermediate commits holds pins.

The one-step way is tether abandon. It drops the commits in the VCS and shows the gc plan for what that freed:

tether abandon krlwtmzy ptnwqsyu        # jj change ids, or git shas
abandoned 3c1e9f0a2b7d, 8d2f4a1b9c0e
plan: gc (2 write(s))
               unpin  zarr/imaging  [icechunk] tether.0a1b2c3d.7d1e0c9a2b34e5f6  no manifest in history references it
               unpin  db/metrics    [neon] tether.0a1b2c3d.b81f4c2d9e60a7c3  no manifest in history references it
(not applied; `tether gc --no-dry-run` releases these, or re-run with --gc)

Later commits keep their manifests exactly as they were: a manifest records a whole state, so removing an earlier one must not conflict with it, as a plain jj abandon or git rebase would. The abandon is the VCS’s to undo (jj undo, the reflog); bring the commits back before running gc, and nothing was lost.

The VCS’s own tools work too, followed by tether gc:

jj log -r 'main..@'
# ○  vqmxzkwl  Scores for A1, third try
# ○  ptnwqsyu  Scores for A1, second try
# ○  krlwtmzy  Scores for A1, first try
# ○  ntsyulyo  Relabel plate1 with model v3
# ◆  main

# Keep the split point and the result; fold the tries into the result.
jj squash --from krlwtmzy::ptnwqsyu --into vqmxzkwl

jj squash and jj abandon hide the old commits from the history gc walks. jj op log can restore them for a while, but after a gc a restored commit points at pins that are gone, and verify says so.

git log --oneline main..HEAD
# 9e1c3a0 Scores for A1, third try
# 5b77d21 Scores for A1, second try
# c0ffee1 Scores for A1, first try
# a1b2c3d Relabel plate1 with model v3

git rebase -i a1b2c3d      # mark the two middle commits `fixup`/`drop`

gc walks rev-list --all, so anything unreachable from a ref is gone as far as tether is concerned. git’s own reflog and git gc are independent of that.

To drop a whole side branch, tether drop NAME does the VCS half and the store half in one step (Throwing it away). History is now three commits, and the pins that only the dropped commits named are orphans.

Lever 3: gc

tether verify --all-history --deep   # confirm what is still referenced actually resolves
tether gc                            # dry run: the plan
plan: gc (5 write(s))
               unpin  zarr/imaging  [icechunk] tether.0a1b2c3d.7d1e0c9a2b34e5f6  no manifest in history references it
               unpin  zarr/imaging  [icechunk] tether.0a1b2c3d.c48b2f6e1d07a9b0  no manifest in history references it
               unpin  db/metrics    [neon] tether.0a1b2c3d.b81f4c2d9e60a7c3  no manifest in history references it
               unpin  db/metrics    [neon] tether.0a1b2c3d.3e5a9d07c1f28b64  no manifest in history references it
      delete-listing                a1c9…e3f2.jsonl  unreferenced

Two tags in Icechunk, two branches in Neon, one content listing. A plain gc does three things. It releases the pins nothing references, forgets the working refs of objects tether remove took away (their branches stay), and deletes listings no manifest names. It never deletes a branch.

A pin counts as referenced when any of these names it, under any spelling of its store:

  • a manifest anywhere in VCS history, every side of a conflicted jj commit included;
  • the working tree of this or any other live checkout of the repository;
  • an operation still running, or interrupted, in any of them.

Every ref carries the dataset’s id (0a1b2c3d here), and gc only looks inside its own namespace. Had another dataset pinned the same Icechunk repository, the plan would say zarr/imaging: 2 pin(s) of other datasets left alone. Manifests of a backend tether no longer ships (lakefs) are skipped with a note, and their pins still count as references. Apply it:

tether gc --no-dry-run
unpinned 4 pin(s)
  icechunk: 0a1b2c3d.7d1e0c9a2b34e5f6
  icechunk: 0a1b2c3d.c48b2f6e1d07a9b0
  neon: 0a1b2c3d.b81f4c2d9e60a7c3
  neon: 0a1b2c3d.3e5a9d07c1f28b64
deleted 1 orphan listing(s)

Or save the plan, and apply it from a job with the right credentials:

tether gc --plan /tmp/gc.json
tether gc --from-plan /tmp/gc.json

When some actions fail, gc prints what it applied along with the failures, and exits 3 (1 when nothing was applied). tether undo of a gc restores the working refs it forgot and the listings it deleted. Released pins and deleted branches stay released. If you restore a commit that references a released pin (jj undo of the abandon), tether repair recreates the pin from the manifest’s state, while the system still has that snapshot. That window is the argument for running gc some time after a history edit.

Releasing a tag or branch makes the data eligible for reclamation; the system decides when. Icechunk needs repo.expire_snapshots(...) and then repo.garbage_collect(...), Iceberg expire_snapshots, and Lance cleanup_old_versions. Neon frees a branch’s history on its own schedule. Those are the system’s operations, not tether’s.

gc (and drop) refuse while jj reports a conflicted bookmark, or a conflict in .tether/ that no later commit resolved. A conflicted bookmark has several targets and counts as reaching nothing, and a conflicted manifest is several answers. Resolve it (jj bookmark set, jj resolve) first. A conflict elsewhere, or one a later commit resolved, is history like any other.

Pins this clone did not create

gc only knows the history this clone has. A teammate’s clone commits into the same stores, and until you fetch, its pins are in your store and in none of your commits. So an unreferenced pin is released only when this clone created it. Any other gets a keep-pin verdict:

            keep-pin  db/metrics    [neon] tether.0a1b2c3d.5e6f7a8b9c0d1e2f  no manifest in history references it; not created by this clone -- another clone's commits may name it (fetch them, or --release-foreign)

keep-pin writes nothing, and a plan holding only such lines is empty. Fetching the other clone’s bookmarks turns its pins into references. gc --release-foreign releases them regardless, for a clone you know is gone or a pin made by hand; the plan marks each one not created by this clone.

Which pins are this clone’s is recorded in tether-pinned.jsonl, beside the repository lock (.jj/repo/ or .git/):

  • commit, pull and repair add a pin when the store reports the ref as new. A commit that found the pin already there (another clone made it) does not claim it.
  • A pin this clone releases is taken off, so another clone creating it again under the same content-addressed id owns it.
  • The first gc seeds the index from every live checkout’s op log, from the progress records of the pins each operation created.
  • The index is not in history. A fresh clone, including a re-clone of your own dataset, starts without it, and its gc keeps every unreferenced pin as keep-pin.

Branches of bookmarks that are gone

Every bookmark gets a tether.ws.<dataset>.<bookmark> branch in each Forkable system it writes to. A bookmark deleted with jj bookmark delete, renamed, or left on a wiped machine leaves those branches behind, each holding whatever it wrote. A plain gc never deletes a branch, so a forgotten branch is never why data disappears.

gc --prune-bookmarks is the sweep. It finds the tether.ws.* branches in each system and keeps those of every bookmark the VCS still has, or a live checkout works on. It also keeps any --keep-bookmark NAME (a bookmark that exists only on another machine), and every branch a live checkout has as its working ref or pending fork. It judges the rest by their head: gone bookmarks’ branches, this bookmark’s branches that no current object uses, and legacy per-workspace branches from before bookmarks. This table is the one place the verdicts are defined; drop adds one rule of its own:

Branch head Default --force-prune
natively pinned by some commit (a tag holds it) delete-branch delete-branch
equal to the base branch’s head (nothing was ever written) delete-branch delete-branch
has writes no commit pinned keep-branch delete-branch FORCED (data lost)
a --pin record state (only this branch keeps it reachable) keep-branch delete-branch FORCED
on a backend whose branches are the storage (BRANCH_IS_STORAGE: Neon) keep-branch delete-branch FORCED
the store refuses to delete it (Neon: pins hang off it) keep-branch: cannot be deleted the same

Both sweeps stay inside this dataset’s namespace. Pins and branches whose name carries another dataset’s id are counted in the plan’s notes (N pin(s) of other datasets left alone) and never touched, --force-prune included.

jj bookmark delete relabel                        # the line of work is over
tether gc --prune-bookmarks                       # its branches, everywhere
tether gc --prune-bookmarks --keep-bookmark hpc   # a bookmark that lives only on the cluster
plan: gc (1 write(s))
       delete-branch  zarr/imaging  [icechunk] tether.ws.0a1b2c3d.relabel  bookmark relabel (gone); head is pinned
         keep-branch  features      [lance] tether.ws.0a1b2c3d.relabel  bookmark relabel (gone); kept: has unpinned writes (branch=tether.ws.… version=9) (--force-prune deletes)
         keep-branch  db/metrics    [neon] tether.ws.0a1b2c3d.relabel  bookmark relabel (gone); kept: branch is storage; deleting reclaims its data (--force-prune deletes)
                      keeping live bookmarks: hpc, main

Add --no-dry-run to apply. The kept branches are yours to decide. Pin what is on them (tether new -b NAME on a commit, then commit), or decide the data is disposable and pass --force-prune. The plan marks those FORCED although it has unpinned writes, so the loss is explicit first:

tether gc --prune-bookmarks --force-prune --no-dry-run

tether forget-workspace [ID] retires a checkout and touches no branch: branches belong to bookmarks, which stay live for gc while the VCS has them. tether remove deletes nothing either; --prune-bookmarks later judges the object’s branch. (Lance refuses to delete a branch a tag still points at; tether reports it and moves on.)

Stores tether created

WarningExperimental

Everything in this section (add --create, Repo.create, gc --delete-stores, --store) lives in tether.experimental.lifecycle. It is the one operation with no repair path, and has not yet run against an S3 prefix or a second clone with a real remote. The rules err toward keeping. The module’s docstring lists what must hold before it graduates.

Everything above releases refs inside stores that existed before tether saw them; tether never deletes such a store. A store made with tether add KEY LOCATOR --kind KIND --create is different (repo.add(..., create=True); repo.create(key, kind, locator) also opens it). tether made it, so tether may take it away once it is plainly garbage: an ephemeral environment creates what it needs, tests, and throws it all away. Two records outside history say what tether made:

  • An owner marker in the store, naming the dataset: Icechunk repository metadata, git config tether.owner, or a .tether-owner file. Manifests are untrusted input, so only the marker authorizes a delete.
  • tether-created.jsonl, beside the repository lock and never committed, since an abandoned fork takes with it the manifest that named the store.

gc --delete-stores considers these stores last. It is opt-in, because a deleted store has no repair and gc only knows the history this clone has fetched. A store is planned for delete-store only when all five hold:

  1. Its owner marker names this dataset. A marker naming another dataset is noted and left alone; a store with no marker is only dropped from the index (forget-store).
  2. No manifest in visible history, in this working tree, or in any live checkout’s working tree names it.
  3. Every ref of this dataset in it is one this clone can account for. A branch must belong to the bookmark the store was created on, to a live bookmark, or to one a new in a live checkout of this clone made. A pin must be one that a commit or repair of this clone recorded. Anything else may be another clone at work in the store, whose commits nothing in your history protects. The plan then touches nothing in that store, your own pins included (keep-store), and says so.
  4. Everything of tether’s in it is released by the same plan: its pins are unpinned, and its branches deleted under the --prune-bookmarks rules. Without --prune-bookmarks a branch blocks the store; with unpinned writes it needs --force-prune.
  5. The backend confirms nothing else remains: the base branch at its initial state, the marker, the refs the plan deletes, and no uncommitted files in a store with a working area (a git checkout). “Cannot tell” means keep.

Otherwise the plan carries a keep-store naming what is in the way. delete-store runs last, behind a store_empty precondition asked again with nothing ignored, and the backend checks owner and emptiness once more itself. A tag or branch that appeared in between stales the plan. undo gc reports a deleted store as irreversible.

tether gc --prune-bookmarks --delete-stores               # shows delete-store / keep-store
tether gc --prune-bookmarks --delete-stores --no-dry-run  # ...and applies it
tether gc --prune-bookmarks --delete-stores --force-prune # ...overriding rules 3 and 4

Fetch every bookmark before you run it: a bookmark another clone pushed is a reference gc can only count once it is in your history.

Two more indexes and one escape hatch round this out:

  • Stores this clone touched. Every store a checkout forks a branch in or pins is recorded in tether-touched.jsonl. gc --delete-stores releases this dataset’s dead refs there even when no manifest names the store any more, as an abandoned bookmark leaves it. So when another actor abandons refs in your created store, their next gc --delete-stores cleans them out (rule 3 binds them too), and yours then finishes. An entry goes (forget-touched) once nothing of the dataset’s is left.
  • A store whose creator is gone. Any clone can reclaim a created store by naming it: gc --delete-stores --store KIND=LOCATOR (repeatable; LOCATOR is the uri). The same five rules apply, with the marker as the authority, and a store with no marker is refused.
tether gc --prune-bookmarks --store icechunk=s3://bucket/probes/emb.icechunk --no-dry-run

Two consequences. A clone elsewhere has its own (empty) index, so it reclaims the stores you created only when named by locator. And a store created on the trunk and written through its base branch is kept forever by rule 5: create on a bookmark when you mean to throw the store away.

Recap

  • Plans: --dry-run shows store writes, --plan FILE saves them, and --from-plan FILE applies them and refuses if anything moved.
  • --pin record skips the native ref and forks from the recorded state: cheaper, and bound by retention.
  • Drop commits with tether abandon, tether drop, or the VCS’s own tools; what the VCS cannot see, tether does not protect.
  • gc releases orphaned pins this clone created, and never deletes a branch on its own. --prune-bookmarks deletes gone bookmarks’ branches when nothing on them would be lost, and --force-prune regardless.
  • gc --delete-stores deletes a store add --create made once nothing references it; the owner marker decides, never a manifest.