Skip to content

feat(storage): fold objects stored before the shared store into it - #88

Merged
BryanFRD merged 2 commits into
mainfrom
feat/dedupe-existing
Aug 15, 2026
Merged

feat(storage): fold objects stored before the shared store into it#88
BryanFRD merged 2 commits into
mainfrom
feat/dedupe-existing

Conversation

@BryanFRD

Copy link
Copy Markdown
Contributor

Closes #87.

Deduplication arrived in 0.20.0. Objects written before it are plain files with a single link — they serve correctly and never collapse, so a server running since 0.17.x keeps paying full price for every pack two projects share. The homelab is that server: 11 827 objects, 3.46 GB, all written the old way.

POST /{org}/{repo}/objects/dedupe, and lfsx dedupe --repo <org/repo> [--dry-run] in front of it. Each object is either moved into .content and linked back, or linked to what is already there with its copy freed.

Bytes come back on the second repository, not the first. The first to run moves its objects into the shared store and frees nothing; the one that follows and holds the same pack is where the disk gives anything back. Worth knowing before reading a report that says reclaimed: 0 and concluding it did not work.

What it refuses to do

An object whose bytes do not hash to its own name never enters the shared store — everything that links there afterwards would inherit them. A shared entry that does not match its name is never adopted, so a repository holding a good copy keeps it. Both are counted as refused and named in the log rather than silently skipped.

The repository's file is never removed before its replacement exists: the link is created under a temporary name and renamed over the original, which is atomic. An interrupted run leaves either the old file or the new link, never a gap where a repository has no object.

Admin only. This rewrites every object in place, so it asks for the rights of someone who could delete them instead — a push token is not enough.

Re-running

An object already sharing its inode with the shared store is recognised and skipped, so a second run reports already_shared and does nothing. A migration that cannot be re-run is one nobody dares run at all, and that is a test rather than a claim.

On a platform without inode numbers there is no way to tell a link from a copy, so the portable path relinks every time — same result, repeated work. The server ships on Linux; this only affects tests elsewhere, and the test says so.

Tests

Six at the storage level: an old-style object is folded in and still reads; a second repository sharing the bytes drops the disk usage measurably; a re-run is a no-op; a corrupt shared entry is refused and the good local copy survives; an object that lies about its digest never reaches the shared store; a dry run touches nothing.

Two through the API: the endpoint folds a repository in and it still serves afterwards, and a push token is refused with the repository left untouched.

Copilot AI lite review requested due to automatic review settings August 15, 2026 15:33
@BryanFRD
BryanFRD enabled auto-merge (squash) August 15, 2026 15:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@BryanFRD

Copy link
Copy Markdown
Contributor Author

CI caught something my machine could not: a_second_repository_stops_paying_for_bytes_it_shares failed on Linux with 4096 -> 4096, and the assertion is guarded on cfg!(unix) because inode counting is what makes the saving measurable — so it never ran here.

It was not the test being wrong. usage() keeps a store-wide snapshot for sixty seconds, and the migration only invalidated the per-repository figure. Freeing gigabytes and then reporting the old total for another minute is exactly how an operator concludes the run did nothing — and the README I wrote in this PR points at lfsx_store_bytes as the place the saving shows up.

Fixed where it belongs rather than in the test: forget now drops both figures, so collection gets the same correction for free. It had the same staleness after freeing space.

@ferrfleet ferrfleet Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid change: atomic rename-over-original, digest verification on both sides before admitting/adopting, admin-gated, and the test suite actually exercises the interesting cases (corrupt shared entry, corrupt local file, re-run idempotency, dry-run no-op).

Nit: in adopt() (server/src/storage/dedupe.rs), self.link(content, &staged).await? propagates a raw NotFound error if the shared content vanishes between the fs::metadata check and the link call (e.g. a concurrent retain/gc on another repo drops the last other reference to that shared object). Unlike link_or_move in storage/mod.rs, which retries by restaging on NotFound, this just aborts the whole dedupe call with a 500 — no corruption, but the client gets an opaque error and loses the report for everything already processed in that run instead of the object being counted as refused and the walk continuing. Narrow race, not worth blocking on.

@BryanFRD
BryanFRD merged commit 8d977a7 into main Aug 15, 2026
16 checks passed
@BryanFRD
BryanFRD deleted the feat/dedupe-existing branch August 15, 2026 15:36
@github-actions

Copy link
Copy Markdown

SonarQube — aucune nouvelle issue

Comparaison entre le projet bac à sable de cette PR et la branche par défaut : SonarQube Community n'analyse pas les PR, ce delta est calculé côté CI. Détail

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.

Fold objects stored before .content into the shared store

2 participants