Story: Migrate shared ccache/build storage to btrfs for reflink-safe hardlink-free caching
Table of Contents
- Goal
- SSD inventory (2026-07-28, as user
marco, not root) - CORRECTION: the inventory above was incomplete (2026-07-28)
- Moimba project (2026-07-28)
- Filesystem decision: btrfs vs XFS
- Target architecture
- Migration plan (btrfs, exact commands)
- 0. Preconditions and tooling (DONE 2026-07-28)
- 0b. SSH agent (done 2026-07-28)
- 1. Stop everything using the partition (DONE 2026-07-28)
- 2. Preserve only what cannot be regenerated
- 3. Repartition and reformat (POINT OF NO RETURN) – DONE 2026-07-28
- 4. Subvolumes, mount, fstab (DONE 2026-07-28)
- 5. Reconfigure ccache for reflink, not hard link (DONE 2026-07-28)
- 6. Recreate the fleet
- Actual result 2026-07-28
- 6b. Recreate Moimba
- 7. Deduplicate
- 8. Verify
- 8b. Validation results (2026-07-28)
- 8c. Final state (2026-07-29)
- 9. Ongoing maintenance
- SSD inventory (2026-07-28, as user
- Status
- Acceptance
- Tasks
- Decisions
- D1: btrfs over XFS (2026-07-28)
- D2: Rebuild rather than restore (2026-07-28)
- D3: Bare repo with nine equal worktrees (2026-07-28)
- D4: Dedicated swap partition, 8G (2026-07-28)
- D5: PostgreSQL recreated, not dumped and restored (2026-07-28)
- D6: Discover repositories, never enumerate them (2026-07-28)
- D7: Layout and naming (2026-07-28)
- Out of scope
This page documents a story in Sprint 24. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
Goal
Reformat the shared SSD (/mnt/development, currently a single 220G ext4
partition at 83% full, 36G free) to btrfs, and reconfigure ccache to use
btrfs reflinks (file_clone=true, hard_link=false) instead of hard
links, so concurrent worktree builds can no longer corrupt each other's
cached object files via a shared inode – the failure class root-caused
in the CIR rename PR's review round (heap corruption crash in
ores.refdata.service during Barclays provisioning, traced via GDB to a
stale/corrupted party_repository.cpp.o that a ccache-disabled rebuild
proved didn't match a fresh compile of the same source). ccache's own
manual explicitly warns against hard_link=true combined with a
cache_dir shared by multiple concurrent build trees, for exactly this
reason; ext4 has no reflink support at all, so file_clone silently
falls back to plain (non-deduplicated) copies without a filesystem
change.
Everything on this partition is destroyed by the reformat and must be
recreated: every worktree's checkout, build output, and the ccache
store itself, plus the shared PostgreSQL 18 cluster
(/mnt/development/postgres/18/main) that backs every worktree's
database.
SSD inventory (2026-07-28, as user marco, not root)
/mnt/development (/dev/sda1, ext4, 220G total, 173G used, 36G free):
| Path | Size | Notes |
|---|---|---|
Development/OreStudio/ores_dev_swift_curie |
19G | worktree |
Development/OreStudio/ores_dev_prime_origin |
17G | worktree |
Development/OreStudio/ores_dev_brave_hopper |
17G | worktree |
Development/OreStudio/ores_dev_bright_faraday |
14G | worktree |
Development/OreStudio/ores_dev_jolly_knuth |
13G | worktree |
Development/OreStudio/ores_dev_solid_dirac |
12G | worktree |
Development/OreStudio/ores_dev_merry_newton |
12G | worktree (this session) |
Development/OreStudio/ores_dev_eager_maxwell |
12G | worktree |
Development/OreStudio/ores_dev_clever_dijkstra |
12G | worktree |
Development/OreStudio/Hedgr.remote |
4.8G | external/reference repo |
Development/OreStudio/Engine.remote |
2.2G | external/reference repo (ORE upstream) |
Development/OreStudio/Papers, log |
35M, 120K | negligible |
.cache/ccache |
9.4G | the shared cache this story fixes |
.cache/sccache |
74M | second, mostly-unused compiler cache |
swapfile |
16G | ACTIVE SWAP, pri=100, 3.1G in use – see Step 1 |
postgres/18/main |
unknown (denied) | owned by postgres user, du needs root |
lost+found, models |
16K, 4K | negligible |
PostgreSQL logical size (via pg_database_size, sums to less than the
physical data directory will show): 11 GB across all databases –
ores_dev_local1..4 (test/scratch dev DBs, ~9.3G combined) plus one
~200-450MB database per active worktree.
Superseded by D2/D5: the physical size of postgres/18/main was left
unmeasured (needs root). It no longer gates anything – the cluster is
destroyed and recreated from scratch rather than copied or dumped, so
neither its physical nor its logical size matters to this migration.
CORRECTION: the inventory above was incomplete (2026-07-28)
The table above enumerates only Development/OreStudio/*. It misses an
entire second project. Actual top level:
| Path | Size |
|---|---|
Development/OreStudio |
130G |
Development/Moimba |
12G – absent from the original inventory |
Executing the plan as originally written would have destroyed Moimba without a single warning, because Step 2's gate looped over nine hardcoded OreStudio worktree names. See D6.
Moimba project (2026-07-28)
Three .NET products sharing the same clone+worktree pattern as
OreStudio. All remotes are under github.com:Moimba/.
| Path | Size | Kind | Remote |
|---|---|---|---|
Karga.local1 |
3.5G | worktree of Karga.remote |
Moimba/Karga |
Corimba.remote |
3.1G | clone (hosts the worktrees) | Moimba/Corimba |
Baobab.local1 |
2.1G | worktree of Baobab.remote |
Moimba/Baobab |
Corimba.plan32 |
871M | worktree of Corimba.remote |
Moimba/Corimba |
Corimba.plan30 |
845M | worktree of Corimba.remote |
Moimba/Corimba |
Corimba.plan31 |
657M | worktree of Corimba.remote |
Moimba/Corimba |
KargaFormacao |
395M | see below | -- |
Karga.remote |
94M | clone (hosts Karga.local1) |
Moimba/Karga |
Baobab.remote |
58M | clone (hosts Baobab.local1) |
Moimba/Baobab |
Same topology caveat as OreStudio (D3): Corimba.remote,
Karga.remote and Baobab.remote each host the object store for their
worktrees, so those three directories are load-bearing.
Superseded by D7: Corimba is re-rooted on a bare corimba.git with
equal worktrees, matching OreStudio. Karga and Baobab are not recreated
on the SSD at all – their work is pushed and they can return whenever
wanted.
KargaFormacao – the one thing with no remote backing
KargaFormacao/Karga is a full .NET source tree (Karga.sln,
Karga.Core, Karga.WebApi, Karga.Blazor, Karga.Mobile, Karga.Cli)
carrying its own .git. It was missed by the first repo sweep because
that used find -maxdepth 4 and this sits at depth 5 – see D6.
Its remote is HTTPS (https://github.com/Moimba/Karga.git), not SSH,
so it does not authenticate via the agent configured in Step 0b. It held
six uncommitted files of real work (TallySheet3 domain, entity,
repository and endpoint classes). Resolved 2026-07-28:
- Committed and pushed to
wip/kargaformacao-tallysheet3ongit@github.com:Moimba/Karga.git(pushed over SSH via an explicit push URL; the repo's HTTPS remote config was left unchanged). Verified on the remote ate66226b44. - The whole 395M tree is also copied to
/home/marco/Development/KargaFormacao, byte-identical.
Trap: /home/marco/Development/Moimba is a symlink
It points at /mnt/development/Development/Moimba. Anything copied
"into home's Moimba" lands back on the partition being destroyed. Backup
copies must go to a sibling path – /home/marco/Development/KargaFormacao,
not /home/marco/Development/Moimba/KargaFormacao.
The symlink will dangle after the reformat until Moimba is recreated.
Filesystem decision: btrfs vs XFS
Both candidates support reflinks (FICLONE), so both fix the
correctness bug equally well. XFS is the better filesystem for this
workload in almost every other respect – faster parallel small-file
metadata throughput (allocation groups suit 9 concurrent builds),
honest df accounting, no chunk-allocation ENOSPC surprises, no
balance maintenance, and it is the boring well-trodden choice for
PostgreSQL. If the disk were at 40% full, XFS would win.
It loses on one point, and that point is decisive here. From
man ccache (4.13.6), on file_clone:
Files stored by cloning cannot be compressed, so the cache size will likely be significantly larger if this option is enabled.
and under compression:
Compression will be disabled if file cloning (the file_clone option) or hard linking (the hard_link option) is enabled.
So the fix this story exists to apply turns off ccache's own zstd
compression. The current 9.4G cache is compressed; stored
uncompressed, the same entries want several times that. On XFS that is
paid in full out of 36G of free space. btrfs gives it back at the
filesystem layer via compress=zstd, transparently, and still
reflinks (btrfs clones compressed extents natively).
Measured on this host, 2026-07-28:
| Measurement | Value |
|---|---|
zstd -3 ratio on debug .o files (25-file sample, 21.8MB) |
6.0x |
Share of a worktree that is build/output |
~95% (16G of ~17G) |
vcpkg_installed per worktree |
1.2G, near-identical across all 9 (~11G total) |
Shared git object store (prime_origin/.git) |
1.8G, already shared |
At even 3x average across ~150G of mixed build output (binaries and
archives compress less than raw .o), transparent compression reclaims
on the order of 100G. That is the difference between "this migration
does not fit on the disk" and "this migration leaves room to spare".
Decision: btrfs, with compress=zstd:3. Not because it is the better
filesystem – it is not, for this workload – but because compression is
the only option available that creates disk space rather than consuming
it, and the ccache change consumes a lot.
The COW-overhead concern raised against btrfs does not apply as feared:
btrfs pre-allocates nothing for COW, and an overwrite only pins the old
extent if a snapshot or reflink still references it. With no snapshots
taken, there is no standing COW overhead. The real btrfs space costs are
DUP metadata (removed below via -m single), chunk-allocation slack
(managed by periodic balance), and rewrite fragmentation (which
matters for PostgreSQL, handled below via nodatacow) – none of which
is "COW data we are not using".
Target architecture
Rebuild, not restore. All data on the SSD is destroyed and recreated
from remotes; nothing is copied to /dev/sdb (2TB ST2000DM008,
rotational – a bulk copy there would be slow and is not wanted). The
opportunity is to recreate the layout cleaner and with more sharing than
it has today.
Disk layout (/dev/sda, ADATA SU650, 223.6G, non-rotational)
Today the SSD is a single ext4 partition plus a 16G swapfile inside
it (pri=100, 3.1G in use). btrfs swapfiles carry real constraints:
they must be nodatacow, cannot be compressed, and forbid snapshots of
the containing subvolume. A dedicated swap partition avoids all of it,
so the SSD is repartitioned:
| Partition | Size | Type | Purpose |
|---|---|---|---|
/dev/sda1 |
~215G | btrfs | /mnt/development, label development |
/dev/sda2 |
8G | swap | fast swap, pri=100 |
8G rather than the current 16G: the host has 31G RAM and is using only
3.1G of swap, and /dev/sdb3 already exists as a 64G swap partition
sitting unused at pri-1=. Tiering fast SSD swap at pri=100 over slow
overflow swap at pri-1= keeps the current behaviour and returns 8G to
the SSD. (Note /dev/sdb3 is on the rotational disk – it is overflow
insurance, not a working tier.)
Subvolume layout
Subvolumes are free, cost nothing when unused, and let PostgreSQL get different mount semantics from everything else. Created at the top level of the new filesystem:
| Subvolume | Mounted at | Options |
|---|---|---|
@development |
/mnt/development |
compress=zstd:3 |
@postgres |
/mnt/development/postgres |
nodatacow (via chattr +C), no compression |
PostgreSQL's 8K random rewrites are the one workload that genuinely
fragments badly under COW. chattr +C must be applied to the directory
while it is empty, before initdb – applying it afterwards does
nothing for existing files. This is the single most common btrfs +
database mistake and the story previously listed it as out of scope.
Sharing layers
Confirmed topology: the fleet is already git worktree-based – 8
worktrees hang off ores_dev_prime_origin/.git, sharing one 1.8G object
store. That is good, but it makes prime_origin a load-bearing special
case: deleting or corrupting it breaks the other 8. The clean form is a
bare repository with 9 equal worktrees, none privileged.
| Layer | Today | After |
|---|---|---|
| Git objects | shared via prime_origin/.git (fragile: one worktree is special) |
shared via bare ores.git, all 9 worktrees equal |
| ccache | shared dir, hard_link=true (unsafe – the bug) |
shared dir, file_clone=true + FS compression |
vcpkg_installed |
1.2G copied per worktree (~11G) | deduplicated to shared extents post-build |
| Build output | uncompressed, ~150G | transparently zstd-compressed |
| Everything else | -- | duperemove pass reclaims residual duplication |
Migration plan (btrfs, exact commands)
Nothing here has been run yet. /dev/sda is assumed throughout –
re-confirm with lsblk -f immediately before Step 3, as device naming
can change across boots and Step 3 is the irreversible one.
0. Preconditions and tooling (DONE 2026-07-28)
btrfs kernel support is present (btrfs.ko ships with kernel
7.1.4+deb14-amd64) but btrfs-progs was not installed – mkfs.btrfs
did not exist on this host. Install the toolchain first; discovering
this after umount would be painful.
Note two package names that are not what you would guess: compsize is
shipped as btrfs-compsize, and sgdisk (needed by Step 3, i.e.
after the umount) comes from gdisk, which was also absent.
sudo apt update sudo apt install btrfs-progs duperemove btrfs-compsize gdisk mkfs.btrfs --version # must succeed before proceeding sgdisk --version # ditto -- Step 3 cannot run without it
Installed and verified 2026-07-28: btrfs-progs v7.0, duperemove
0.15.2, btrfs-compsize 1.5, gdisk/sgdisk 1.0.10. modprobe btrfs
succeeds and btrfs appears in /proc/filesystems.
/dev/sda already carries a GPT label (disk identifier
D5A5F2FA-95B5-48DE-BF5A-DA950B1B0654, single 223.6G partition), so
Step 3's sgdisk commands need no MBR-to-GPT conversion.
apt update reports pre-existing errors from two third-party repos
(timescaledb-for-Ubuntu and a Microsoft mssql key). Unrelated to this
work and harmless here – everything needed comes from Debian main.
- Confirm no other user is on
neumann; no build or service may run during Steps 1-3. Note the current state for rollback reference (captured 2026-07-28 to
/home/marco/, which lives on/dev/sdb2and is untouched by this migration):lsblk -f > ~/pre-migration-lsblk.txt; blkid >> ~/pre-migration-lsblk.txt swapon --show > ~/swap.pre-migration.txt df -hT /mnt/development >> ~/swap.pre-migration.txt cp /etc/fstab ~/fstab.pre-migration cp /home/marco/.config/ccache/ccache.conf ~/ccache.conf.pre-migration # PostgreSQL cluster config -- pg_dropcluster in Step 6 deletes it: sudo cp -a /etc/postgresql/18/main ~/postgresql-18-main.conf.pre-migration
The PostgreSQL config copy is listed again under Step 2; doing it here is equivalent and gets it out of the way.
Keep an off-SSD copy of this story, since the disk holding it is the one being destroyed:
cp story.org ~/migrate-ccache-to-btrfs-reflink-story.org # survives reboot cp story.org /tmp/ # tmpfs: does NOT
/tmpon this host is tmpfs (15.6G, RAM-backed), so a copy there is lost at the reboot in Step 4.$HOMEis on/dev/sdb2, untouched by this migration.
0b. SSH agent (done 2026-07-28)
Step 6 re-clones from GitHub over SSH, so agent access must survive the
reboot. It previously did not: SSH_AUTH_SOCK was supplied by sourcing
~/crap.sh, a saved env snapshot pointing at a hand-started ssh-agent
(PID 1547, parented to init), which went stale on every boot. Four
agents were running concurrently as a result.
No new unit was needed – openssh-client already ships
ssh-agent.service / ssh-agent.socket in /usr/lib/systemd/user/,
already enabled, listening on %t/openssh_agent. The only gap was that
nothing exported SSH_AUTH_SOCK to shells. Now configured:
| File | Covers |
|---|---|
~/.config/environment.d/10-ssh-agent.conf |
graphical session and systemd --user services (Emacs daemon) |
~/.bashrc |
interactive terminals |
~/.profile |
login shells that are not interactive (cron, bash -lc, ssh neumann '<cmd>') |
~/.bashrc alone is insufficient: it returns early for non-interactive
shells, hence the duplicate block in ~/.profile. ~/.ssh/config gained
a github.com block (IdentityFile ~/.ssh/id_rsa, IdentitiesOnly
yes) and a trailing Host * default of AddKeysToAgent yes, so the
passphrase is entered once per boot rather than once per connection.
loginctl show-user marco -p Linger already reports Linger=yes, so the
agent starts without an interactive login.
~/crap.sh is now unused and can be deleted.
After the Step 4 reboot, confirm before relying on it:
systemctl --user status ssh-agent.socket echo "$SSH_AUTH_SOCK" # expect /run/user/1000/openssh_agent ssh-add -l # expect the RSA key, may prompt for passphrase git ls-remote git@github.com:OreStudio/OreStudio.git >/dev/null && echo OK
1. Stop everything using the partition (DONE 2026-07-28)
# Per worktree (swift_curie, prime_origin, brave_hopper, bright_faraday, # jolly_knuth, solid_dirac, merry_newton, eager_maxwell, clever_dijkstra): cd /mnt/development/Development/OreStudio/ores_dev_<name> ./projects/ores.compass/compass.sh services stop sudo systemctl stop postgresql@18-main postgresql # The 16G swapfile LIVES ON THE PARTITION BEING DESTROYED. umount fails # while it is active. /dev/sdb3 (64G) absorbs anything paged out. sudo swapoff /mnt/development/swapfile swapon --show # confirm only /dev/sdb3 remains # Nothing may hold the mount open: sudo fuser -vm /mnt/development || echo "clear"
- What this step missed in practice
compass services stoprefused to run:merry_newton's.envwas at format version 12 against a required 14, and compass exits rather than operating on a stale env. Regenerating.envon a worktree about to be destroyed is pointless, so the services were stopped by signal instead. Order matters: kill the controllers first, then the domain services. The controller is a supervisor and respawns its children – killing a domain service first simply produces a new one. There were five controllers running, not one, and six worktreenats-serverinstances.Then
fuserstill showed four holders that no service-stop would have touched:- A podman pod (
ores-pod-swift_curie, infra container up 8 hours) with itscwdinside a worktree. Stop it properly:podman pod stop --all. Notepodmanfails withcannot chdirif invoked with acwdthe target user cannot read. - A stale
tail -F | ugrepmonitor left over from an earlier Claude Code session watching a build log, parented tosystemd --user.
Add to this step, before
fuser:podman pod stop --all; podman stop --all # run from an accessible cwd pkill -TERM -f 'ores\.controller\.service' # supervisors FIRST pkill -TERM -f 'ores\.[a-z]*\.service' # then domain services pkill -TERM nats-server # see caveat below
Caveat:
pkill nats-serveralso matches the system-widenats-server.service(config in/etc,cwd=/), which is unrelated to the fleet and does not hold the mount. systemd restarts it immediately, so no harm – but match on the worktree config path if that matters.The general lesson is D6's, applied to processes rather than repositories: enumerate what is actually holding the mount with
fuser -vm, do not assume a known list of services covers it. - A podman pod (
2. Preserve only what cannot be regenerated
No bulk backup, and no PostgreSQL dump – the cluster is recreated
from scratch (D5). Build output, vcpkg trees, NATS state, the ccache
store and all database content are disposable by design. Two things are
not:
Uncommitted or unpushed git work. Push it to the remote – not to local disk.
Do not enumerate repositories by hand. The original version of this step looped over nine hardcoded worktree names and would have missed 17 of the 26 repositories actually present (D6). Discover them:
for p in $(find /mnt/development -name .git -not -path "*/vcpkg*" \ -not -path "*/node_modules/*" | sed 's|/\.git$||' | sort); do d=$(git -C "$p" status --porcelain | wc -l) u=$(git -C "$p" log --branches --not --remotes --oneline | wc -l) s=$(git -C "$p" stash list | wc -l) [ "$d$u$s" != "000" ] && printf '%-60s dirty=%-4s unpushed=%-4s stash=%s\n' \ "${p#/mnt/development/Development/}" "$d" "$u" "$s" done
Note
-maxdepthis deliberately absent:KargaFormacao/Kargasits at depth 5 and Claude Code agent worktrees under<worktree>/.claude/worktrees/sit deeper still. Both hold real work.vcpkgandnode_modulesare pruned because they contain vendored third-party repos, not our work. Gate: this sweep must come back clean, or every remaining item must be explicitly accepted as discarded, before Step 3 runs.State captured 2026-07-28, before any destructive step – this is the assignment Step 6 restores:
Worktree Branch Dirty Unpushed ores_dev_swift_curiefeature/debug-start-all-never-resumes-in-container0 -- ores_dev_prime_originfeature/migrate-to-oresmd-delete-legacy-schemes0 -- ores_dev_brave_hopperfeature/simulate-holding-group-provisioning-via-internal-impersonation0 -- ores_dev_bright_faradayfeature/extend-synthetic-binding-dialog-to-ir-curves0 -- ores_dev_jolly_knuthfeature/add-entity-filter-flag-to-regenerate0 -- ores_dev_solid_diracfeature/write-up-wire-format-architecture0 -- ores_dev_merry_newtonfeature/merge-asset-class-folders-into-one-theme-unit1 -- ores_dev_eager_maxwellfeature/badge-source-self-annotation0 -- ores_dev_clever_dijkstrafeature/codegen-qt-parent-scoped-list0 -- All outstanding work was found and resolved on 2026-07-28. What the full sweep turned up, well beyond the nine worktrees:
Repo Was Resolution Hedgr.remote6 dirty, 4 unpushed committed; branch feature/initial_infrastructurepushed (it had no upstream at all)Moimba/Baobab.local112 dirty, 1 unpushed committed and pushed; out/+tmp/gitignoredMoimba/Karga.local15 dirty committed and pushed Moimba/KargaFormacao/Karga6 dirty pushed to wip/kargaformacao-tallysheet3; also copied to$HOMEMoimba/Corimba.remote,Corimba.plan301 dirty each .claude/settings.jsoncommitted and pushedPapers4 dirty committed and pushed; also copied to $HOMEprime_origin/.claude/worktrees/agent-af474...2 dirty (283 lines) committed; pushed to worktree-agent-af474c160d620e68cOreStudio ×9 1 unpushed ( f8c7241a5)pushed. Reported from every worktree because they share one object store – one commit, not nine Engine.remote1 dirty deliberately left – see below Two deliberate non-actions:
Engine.remoteis a fork (originOreStudio/Engine,upstreamOpenSourceRisk/Engine) whose only change is incidental submodule checkout drift onORE-SWIG/QuantLib-SWIG, on amasterthat is 1324 commits behind origin. No work was done in it. It is re-cloned in Step 6, which also brings it current.- Stashes (3 shared across the OreStudio worktrees, 1 in each Baobab clone) are accepted as discarded, by explicit decision.
Hedgr.remote/Engine.remote(4.8G / 2.2G): verify clean against upstream, then re-clone rather than copy.for r in Hedgr.remote Engine.remote; do echo "=== $r ==="; cd /mnt/development/Development/OreStudio/$r git status --short; git log --branches --not --remotes --oneline done
The PostgreSQL cluster configuration at /etc/postgresql/18/main
lives on /, not on the SSD, so it survives the reformat untouched –
but Step 6 drops and recreates the cluster, which deletes it. Copy it
aside first so any local tuning can be diffed back in:
sudo cp -a /etc/postgresql/18/main ~/postgresql-18-main.conf.pre-migration
3. Repartition and reformat (POINT OF NO RETURN) – DONE 2026-07-28
lsblk -f # RE-CONFIRM /dev/sda is the 223.6G ADATA SU650 sudo umount /mnt/development # Recreate the partition table: sda1 btrfs (rest), sda2 swap (8G at end). sudo sgdisk --zap-all /dev/sda sudo sgdisk -n 1:0:-8G -t 1:8300 -c 1:development /dev/sda sudo sgdisk -n 2:0:0 -t 2:8200 -c 2:swap /dev/sda sudo partprobe /dev/sda lsblk /dev/sda # expect sda1 ~215G, sda2 8G # -m single: no duplicate metadata. Saves GBs across millions of small # build files; the trade is losing self-repair from the spare copy, # which is the right call for a disposable build scratch disk. sudo mkfs.btrfs -f -m single -L development /dev/sda1 sudo mkswap -L devswap /dev/sda2 sudo btrfs filesystem show /dev/sda1
4. Subvolumes, mount, fstab (DONE 2026-07-28)
sudo mount /dev/sda1 /mnt/development sudo btrfs subvolume create /mnt/development/@development sudo btrfs subvolume create /mnt/development/@postgres sudo umount /mnt/development sudo mount -o noatime,compress=zstd:3,space_cache=v2,subvol=@development \ /dev/sda1 /mnt/development sudo chown marco:marco /mnt/development sudo mkdir -p /mnt/development/postgres sudo mount -o noatime,space_cache=v2,subvol=@postgres /dev/sda1 /mnt/development/postgres # MUST be set while empty, before initdb -- has no effect on existing files: sudo chattr +C /mnt/development/postgres sudo chown postgres:postgres /mnt/development/postgres lsattr -d /mnt/development/postgres # expect ---------------C------
nodatacowas a mount option DOES NOT WORK
Tested on the empty filesystem 2026-07-28: mounting
@postgreswithnodatacowwhile@developmentwas already mounted withcompress=zstd:3produced a second mount that silently reportedcompress=zstd:3and nonodatacow. btrfs applies these options filesystem-wide, first mount wins; a second subvolume mount cannot override them. An fstab line carryingnodatacowlooks correct, passes review, and still yields a fully COW cluster.chattr +Cis the real mechanism – a per-inode attribute, independent of mount options. Verified both directions:Test Result chattr +Con empty dir, then create filefile inherits Ccreate file first, then chattr +Con dirfile does not get CThe second row is why
initdbmust run into an already-empty NOCOW directory, and why D5 (recreate, never restore a dump) is the only ordering that produces a genuinely non-COW cluster.Rewrite
/etc/fstab– replace the old ext4 line and delete the swapfile line; do not append. Actual UUIDs from the 2026-07-28 mkfs:# development SSD (ADATA SU650) on /dev/sda1 -- btrfs, see story 03831B51. # compress=zstd:3 is not optional: it is the reason btrfs was chosen over XFS. UUID=554d7790-02bd-4a66-852d-a5a1d0c8e732 /mnt/development btrfs noatime,compress=zstd:3,space_cache=v2,subvol=@development,nofail 0 0 # PostgreSQL data. NOCOW comes from `chattr +C` on the directory, NOT from a # nodatacow mount option -- see above. UUID=554d7790-02bd-4a66-852d-a5a1d0c8e732 /mnt/development/postgres btrfs noatime,space_cache=v2,subvol=@postgres,nofail 0 0 # Fast swap on /dev/sda2, replacing the old in-partition swapfile. UUID=bd1e0db0-a01b-4782-9440-45a4ea233ed0 none swap sw,pri=100 0 0
findmnt --verifywarnstarget specified more than once; that is only the two swap entries both usingnoneas their target. Harmless. Runsystemctl daemon-reloadafter editing, then verify withumount+mount -abefore rebooting.Then verify the entries mount cleanly before rebooting into them:
sudo findmnt --verify --verbose sudo swapon -a && swapon --show # sda2 pri=100, sdb3 pri=-1
5. Reconfigure ccache for reflink, not hard link (DONE 2026-07-28)
mkdir -p /mnt/development/.cache/ccache ccache --set-config=cache_dir=/mnt/development/.cache/ccache ccache --set-config=hard_link=false # THE BUG: currently true ccache --set-config=file_clone=true # Entries are now stored uncompressed (compression is force-disabled by # file_clone), so the same hit rate needs a much larger nominal budget. # The filesystem compresses it back down on disk. ccache --set-config=max_size=150G ccache --zero-stats ccache --show-config | grep -E 'hard_link|file_clone|max_size|cache_dir'
Keep direct_mode=true and hash_dir=false exactly as they are –
hash_dir=false is what makes cross-worktree sharing possible at all,
and changing it would silently defeat this whole story. Both confirmed
still set after the reconfiguration.
- Filesystem capability verified 2026-07-28
Both pillars of the design tested on the real filesystem before any build ran:
Property Test Result reflink cp --reflink=alwayssupported; both files report links=1, so it is a reflink and not a hard link – exactly the property whose absence caused the corruption bugcompression 200MB compressible payload 190M uncompressed -> 5.9M on disk extent sharing reflink the above, then compsize2 files, 381M referenced, still 5.9M on disk; 1526 extents carrying 3052 refs Caveat on the numbers: that payload was synthetic and repeating, so 3% flatters the result badly. The figure to plan against is the 6.0x (~17%) measured on this codebase's real debug objects, recorded under the filesystem decision above.
Note
compsizemust be run as root; as an unprivileged user it silently prints nothing rather than failing.
6. Recreate the fleet
Bare repo first, so no worktree is privileged:
Layout change: there is no Development/ level any more. It was a
redundant directory inside a mount already called /mnt/development.
Projects sit directly under the mount point:
/mnt/development/OreStudio/... /mnt/development/Moimba/... /mnt/development/.cache/ccache /mnt/development/postgres (the @postgres subvolume)
Every old path of the form
/mnt/development/Development/OreStudio/<x> becomes
/mnt/development/OreStudio/<x>.
mkdir -p /mnt/development/OreStudio cd /mnt/development/OreStudio git clone --bare git@github.com:OreStudio/OreStudio.git ores.git cd ores.git git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' git fetch origin # Then all nine as equal siblings, restoring the branch assignment from # the table in Step 2: git worktree add ../ores_dev_swift_curie feature/debug-start-all-never-resumes-in-container git worktree add ../ores_dev_prime_origin feature/migrate-to-oresmd-delete-legacy-schemes git worktree add ../ores_dev_brave_hopper feature/simulate-holding-group-provisioning-via-internal-impersonation git worktree add ../ores_dev_bright_faraday feature/extend-synthetic-binding-dialog-to-ir-curves git worktree add ../ores_dev_jolly_knuth feature/add-entity-filter-flag-to-regenerate git worktree add ../ores_dev_solid_dirac feature/write-up-wire-format-architecture git worktree add ../ores_dev_merry_newton feature/merge-asset-class-folders-into-one-theme-unit git worktree add ../ores_dev_eager_maxwell feature/badge-source-self-annotation git worktree add ../ores_dev_clever_dijkstra feature/codegen-qt-parent-scoped-list
Remote verified reachable 2026-07-28 via
git ls-remote git@github.com:OreStudio/OreStudio.git. SSH auth is via
the systemd-managed agent (see Step 0), not the old ~/crap.sh env
snapshot.
PostgreSQL cluster once, created fresh into the nodatacow subvolume
(see devops-provision-environment for the scripted form). The Debian
cluster definition at /etc/postgresql/18/main survives the reformat
but points at a data directory that no longer exists, so drop and
recreate it rather than trying to repair it:
# The old cluster is now a dangling definition -- data dir is gone. sudo pg_dropcluster 18 main --stop # Recreate with the data directory on the nodatacow subvolume. The dir # must not already contain data; pg_createcluster runs initdb into it, # and every file it creates inherits the +C flag set in Step 4. sudo pg_createcluster 18 main -d /mnt/development/postgres/18/main sudo systemctl start postgresql@18-main pg_lsclusters # expect: 18 main 5432 online # Confirm nodatacow was actually inherited by the new data directory: sudo lsattr -d /mnt/development/postgres/18/main # expect ---------------C------
- pg_createcluster produces a DEFAULT config – local tuning is lost
This is not a formality. Diffing
~/postgresql-18-main.conf.pre-migrationagainst the freshly generated config on 2026-07-28 found four settings silently reverted, two of them serious:Setting Was Fresh default Consequence if not restored shared_preload_libraries'timescaledb,pg_cron'absent TimescaleDB and pg_cron simply do not load; any database using them breaks max_connections800 100 nine worktrees' services exhaust the pool superuser_reserved_connections5 3 minor listen_addresses'localhost'commented none in practice (same effective value) pg_hba.conf,pg_ident.conf,start.confandconf.d/were unchanged, so onlypostgresql.confneeds attention.CONF=/etc/postgresql/18/main/postgresql.conf sudo cp "$CONF" "$CONF.fresh-backup" sudo sed -i "s|^max_connections = 100|max_connections = 800|" "$CONF" sudo sed -i "s|^#\?superuser_reserved_connections = .*|superuser_reserved_connections = 5|" "$CONF" sudo sed -i "s|^#\?shared_preload_libraries = .*|shared_preload_libraries = 'timescaledb,pg_cron'|" "$CONF" sudo sed -i "s|^#\?listen_addresses = .*|listen_addresses = 'localhost'|" "$CONF" sudo systemctl restart postgresql@18-main
Verify the settings are actually live, and that the preloaded libraries started rather than merely being named:
sudo -u postgres psql -tAc "select name||' = '||setting from pg_settings \ where name in ('max_connections','shared_preload_libraries', \ 'superuser_reserved_connections','listen_addresses')" grep -iE 'timescale|cron' /var/log/postgresql/postgresql-18-main.log | tail -3 # expect: "TimescaleDB background worker launcher connected to shared catalogs" # "pg_cron scheduler started"
Confirmed 2026-07-28: both loaded, cluster online, and the data files themselves carry the NOCOW flag –
lsattr /mnt/development/postgres/18/main/base/1/1255reports---------------C------, so thechattr +Cchain survives all the way from Step 4 throughinitdbto live database files.Then, per worktree, the normal idempotent path:
./projects/ores.compass/compass.sh db recreate -y
Gotcha (hit 2026-07-29): after the ccache-default fix merged,
db recreatefailed on all seven remaining worktrees withERROR: .env is out of date. Their.envfiles were written by the pre-mergeenv_init.py(version 12/14) while main now requires 15. This is the migration note on PR #1727, and it applies to any checkout that predates a version bump:# Re-run configure FIRST; db recreate refuses to touch a stale .env. ./projects/ores.compass/compass.sh env configure --preset linux-clang-debug-make -y
After that all seven wrote
ORES_ENV_VERSION=15, preservedORES_COMPILER_CACHE=ccachefrom their existing.env(no flag needed), and dropped the staleSCCACHE_*lines – confirming the fix propagates correctly through merge and rebase. All nine databases then recreated in ~1 minute each, 159 MB apiece, with/mnt/development/postgres/18/mainstill reporting---------------C------.Then build. Build one worktree to completion first to populate the ccache from cold, then the rest – this maximises reflink sharing and avoids nine cold-cache builds racing each other:
cd /mnt/development/OreStudio/ores_dev_prime_origin ./projects/ores.compass/compass.sh env configure -y ./projects/ores.compass/compass.sh build # cold, populates cache # then the remaining eight, which should run mostly on cache hits
The nine OreStudio worktree names are kept exactly as they were – they are familiar and there is no reason to churn them. Note that
ores_dev_prime_originis no longer special: under D3 it is an ordinary worktree like the other eight, and the object store lives inores.git. - One recorded branch had vanished upstream
ores_dev_eager_maxwellwas onfeature/badge-source-self-annotation, which had been merged via PR #1723 and deleted on the remote. It only still existed as a local branch on the old disk, so a fresh clone cannot recreate it andgit worktree addfails withinvalid reference. Its work is inmain; nothing is lost.Reassigned to
feature/decide-nats-compression-vs-http-transport– the most recent real feature branch not already checked out elsewhere.General point for any future re-clone: the branch table captured in Step 2 is a snapshot of local state, and a merged-and-deleted branch will not survive the round trip. Check each recorded branch still exists on the remote before relying on it.
Note also that
git clone --baremirrors remote heads directly intorefs/heads, so every branch is already a local branch –git worktree add ../x <branch>works without-b=/–track=, andgit switch -cfails with "branch already exists".
Actual result 2026-07-28
| Item | Value |
|---|---|
ores.git (bare) |
548M – against 1.8G for the old shared object store |
| Worktrees | 9, all on distinct branches |
| PostgreSQL | online, data files NOCOW-verified |
6b. Recreate Moimba
Scope: Corimba only. Karga and Baobab are not recreated on the SSD.
All of their work was committed and pushed during Step 2, so nothing is
lost – they can be cloned back at any time if needed. This also returns
~5.8G (Karga.local1 3.5G, Baobab.local1 2.1G, plus the two small
clones) to the disk.
Corimba adopts both OreStudio conventions (D7): a bare repo with equal
worktrees (D3), and the <project>_dev_<adjective>_<scientist> naming
scheme. The old Corimba.remote / Corimba.plan3x names are retired –
Corimba.remote was a clone that also hosted the other worktrees'
object store, exactly the load-bearing arrangement D3 exists to remove.
| Was | Branch it carried | Becomes |
|---|---|---|
Corimba.plan30 |
feature/tidy_up_employee_dataset |
corimba_dev_keen_lovelace |
Corimba.plan32 |
main |
corimba_dev_bold_turing |
Corimba.remote |
feature/cycle-phases-architecture |
corimba_dev_wise_shannon |
Corimba.plan31 |
detached, clean | dropped |
Names are chosen not to collide with the nine OreStudio ones.
Corimba.plan31 was detached and clean, so its slot is given to the
branch the old Corimba.remote clone was on – no branch is lost.
mkdir -p /mnt/development/Moimba cd /mnt/development/Moimba git clone --bare git@github.com:Moimba/Corimba.git corimba.git git -C corimba.git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' git -C corimba.git fetch origin git -C corimba.git worktree add ../corimba_dev_keen_lovelace feature/tidy_up_employee_dataset git -C corimba.git worktree add ../corimba_dev_bold_turing main git -C corimba.git worktree add ../corimba_dev_wise_shannon feature/cycle-phases-architecture
KargaFormacao is not restored to the SSD, consistent with dropping
Karga. It stays at /home/marco/Development/KargaFormacao (395M,
byte-identical copy taken 2026-07-28), and its committed work is on
Moimba/Karga at wip/kargaformacao-tallysheet3. It is the one tree
not reproducible from a remote alone, so do not delete the $HOME
copy – it is now the only full copy of that layout.
Finally, repoint the /home/marco/Development/Moimba symlink. It
targets /mnt/development/Development/Moimba, a path that no longer
exists under the new layout, so it dangles from the moment of the
reformat and would still dangle even after Moimba is recreated:
ln -sfn /mnt/development/Moimba /home/marco/Development/Moimba ls -l /home/marco/Development/Moimba && ls /home/marco/Development/Moimba/
7. Deduplicate
Reflinks only arise where something clones a file. The ~1.2G
vcpkg_installed tree that each worktree builds independently is
near-identical across all nine (~11G for ~1.2G of content) and nothing
clones it. An offline dedup pass converts those into shared extents:
# Whole mount, not just OreStudio: Moimba's five worktrees share vendored # NuGet/obj trees with each other in the same way. sudo duperemove -rdh --hashfile=/var/tmp/dev-dedup.hash /mnt/development sudo compsize /mnt/development
Re-run after any large rebuild wave. compsize reports both the
compression ratio actually achieved and the extent sharing. It must be
run as root – unprivileged it prints nothing rather than failing.
Exclude /mnt/development/postgres from any future dedup pass if one is
scripted: deduplicating a NOCOW database file reintroduces shared
extents, which silently converts writes back into copy-on-write and
undoes Step 4. The run below was scoped to OreStudio and Moimba
explicitly for this reason.
- Result (2026-07-29, all nine worktrees built)
Used Free Before 31G 185G After 14G 201G 17G reclaimed, ~26 minutes over 237,250 files / 60.5GB.
The prediction in this story was wrong, and instructively so. It named the nine near-identical
vcpkg_installedtrees as the prime target, estimating ~11G recoverable from pre-migrationdufigures. Measured on btrfs each vcpkg checkout costs 12M physical (30M logical) – ~108M for all nine. Compression had already absorbed that saving entirely.The 17G came from somewhere the plan never considered: linked shared libraries. Nine worktrees each link byte-identical
libores.*.sobinaries – hundreds of MB apiece – from identical object files, at nine separate paths. ccache cannot help there: it caches compilation, not linking. Reflinks cannot either, because nothing clones a linker's output. Dedup is the only mechanism that reaches it, which makes Step 7 considerably more valuable than "mopping up" as originally framed.Integrity spot-checked afterwards:
ores.qtintact at 38,111,376 bytes and executing (it reaches Qt platform-plugin init and fails only oncould not connect to display, correct for a headless shell).
8. Verify
Correctness – the bug this story exists to fix:
ccache --show-configreportshard_link = false,file_clone = true.- A build in two different worktrees of the same commit produces a
ccache hit in the second one (confirms the shared cache still works)
without the second worktree's object file being a hard link
(
stat -c '%h' <file>.oreports link count 1, not >1). - Full
compass build ratgreen in at least two worktrees run concurrently, deliberately racing the same TU, with no crash – the regression test for the bug this story fixes.
Space – the constraint that drove the filesystem choice:
sudo compsize /mnt/development/Development/OreStudio # ratio + shared extents sudo btrfs filesystem usage /mnt/development # real free space, not df df -h /mnt/development # for contrast; trust the above
compsizereports a compression ratio materially below 1.0 (target: roughly 0.35-0.5 across the build trees) – if it reports ~1.0, thecompress=zstd:3mount option did not take, and the entire rationale for choosing btrfs over XFS has silently evaporated. This is the check that matters most.btrfs filesystem usageshows unallocated space remaining. Chunk exhaustion, notdf, is what causes btrfs ENOSPC.- Total footprint after all nine worktrees are built is below the 173G the ext4 layout consumed, despite ccache now storing entries uncompressed.
Configuration:
lsattr -d /mnt/development/postgresshows theC(nodatacow) flag.swapon --showlists/dev/sda2atpri=100and/dev/sdb3atpri-1=; no swapfile on the btrfs filesystem.- A reboot mounts everything from
/etc/fstabwith no manual intervention.
8b. Validation results (2026-07-28)
Every acceptance claim verified on real builds, not inferred.
- The corruption fix
Check Result Link count, all objects in merry_newton3074/3074 at links=1 Link count, all objects in prime_origin3078/3078 at links=1 Any object with links > 1 none Under the old
hard_link=true, a cache hit produced an object file sharing an inode with the cache entry – link count 2 – which is how two worktrees compiling the same TU concurrently corrupted each other. That signature is now entirely absent. - Reflink proven at block level, not inferred
btrfs inspect-internal logical-resolveon one extent returned exactly two files:/mnt/development/OreStudio/ores_dev_merry_newton/build/output/.../app_command_args_tests.cpp.o /mnt/development/.cache/ccache/8/7/c3bb3a464c77d2b4f9d3de8982e305a0a00545_00
Same physical blocks, independent inodes, each with link count 1. That is
file_clone=trueexactly: the disk saving of a hard link with none of the shared-inode hazard. - Cache correctness
Full rebuild of
merry_newtonafter deleting its object tree: 2382/2382 hits, 0 misses, 0 errors, exit 0. Cache size did not move from 7.1 GB – hits clone existing entries rather than writing new ones. - Cross-worktree sharing (the
CCACHE_BASEDIRquestion)
base_diris empty in ccache.conf; it is injected at runtime bybuild/scripts/compiler_cache_wrapper.shasCCACHE_BASEDIR. Nothing prior to this test exercised it, because every hit until then came from the same worktree at the same path.prime_origin(different directory, different branch) built against the cachemerry_newtonpopulated:Sample Hit rate first 93 objects 79.6% 2128 objects 80.9% final: 2391 objects 72.5% (1734 hits, 657 misses) Had
CCACHE_BASEDIRorhash_dir=falsebeen broken, this would have been 0%. The drift from 80% to 72.5% is composition, not regression: shared infrastructure hits early, and the Qt UI sources where these two branches genuinely diverge legitimately miss. Same-commit worktrees approach 100%, as the rebuild above shows. - Space – the reason btrfs was chosen over XFS
Measurement Value ccache store 6.6G logical -> 1.3G on disk (5.1x) Build tree (one worktree) 14G logical -> 3.7G on disk (3.8x) Two built worktrees + shared cache, logical 38.9G Same, actually on disk 13G Pre-migration size of those same two worktrees + cache 38.4G Two fully built worktrees plus a 9.6G shared cache now occupy less disk than one worktree did before. Extrapolated to nine worktrees: ~145G logical lands near ~52G physical, against the 173G that filled the old partition.
The ccache store number is the load-bearing one. Those entries are genuinely uncompressed –
file_cloneforces ccache's own compression off – so on XFS they would occupy the full 6.6G. btrfs stores them in 1.3G. That gap is the migration's justification.
8c. Final state (2026-07-29)
Fleet rebuilt after merging the ccache-default fix and rebasing every worktree onto main. All nine: exit 0, zero errors.
| Worktree | Time | ccache hit rate |
|---|---|---|
merry_newton |
incremental | 5 objects |
solid_dirac |
4m | 99.87% |
eager_maxwell |
4m | 99.87% |
bright_faraday |
4m | 99.71% |
prime_origin |
2m | 99.85% (incremental, 661 objects) |
swift_curie |
4m | 99.71% |
brave_hopper |
4m | 99.62% |
jolly_knuth |
4m | 99.83% |
clever_dijkstra |
4m | 99.83% |
Eight full rebuilds at 99.6-99.9% in ~4 minutes each, against ~40 minutes cold. The whole fleet rebuilt in about the time one cold build takes. Note the contrast with the 72.5% measured earlier across divergent branches: once every worktree shares a common main base, reuse is essentially total.
Builds were run strictly one at a time. Concurrent builds contend on the vcpkg lock – the failure mode that stalled Step 6 – and serial runs keep per-worktree hit rates interpretable.
- Space, end to end
Measure Value Referenced (what nine worktrees see) 146G Unique uncompressed 34G Actually on disk 13G Marginal cost of one more built worktree ~4G physical (14G logical) Nine fully built worktrees, a 9.6G ccache, Corimba and PostgreSQL occupy 16G of 216G (14G before the databases). The old partition held nine partially built worktrees plus the cache at 173G, 83% full.
Three mechanisms stack, in this order of contribution: reflinks reduce 146G of references to 34G unique; zstd takes that to 13G; dedup collapses the linker output neither can reach.
- How many worktrees can this now support?
Disk has stopped being the constraint – 201G free at ~4G marginal cost is ~40 more. The binding limit is port allocation: 13.
BASE_PORT_START = 20000, BASE_PORT_STEP = 1000, top offset +6 EPHEMERAL_PORT_FLOOR = 32768 (kernel range here: 32768-60999)
Bases must stay below the ephemeral floor, giving 20000..32000 = 13 slots. Nine are held (20000-28000); four remain (29000-32000).
Raising it is not a one-constant change – version 11 moved
BASE_PORT_STARToff 50000 precisely to stop fixed listeners colliding with kernel-assigned ephemeral source ports. The clean lever isBASE_PORT_STEP: only 7 offsets are used, so 1000 is generous and 100 would give ~127 slots.Secondary limits arrive only if services run in many worktrees at once, not from building: 31G RAM against ~17 services per worktree, and PostgreSQL
max_connections(800, restored in Step 6).
9. Ongoing maintenance
Not required for acceptance, but this is what btrfs costs versus XFS and it should be written down rather than rediscovered:
- If
btrfs filesystem usageshows unallocated space approaching zero whiledfstill claims free space, runsudo btrfs balance start -dusage=50 /mnt/development. - Re-run
duperemove(Step 7) after large rebuild waves. - Never enable snapshots on
@developmentwithout revisiting the space budget – snapshots are the one thing that turns COW into standing overhead, which is precisely the failure mode this design avoids.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 24 |
| Now | All steps DONE. btrfs live; fleet re-cloned and rebuilt (9/9 clean); Corimba recreated; PostgreSQL and all nine databases recreated; caching validated; dedup run. PR #1727 merged. |
| Waiting on | Nothing. |
| Next | Close the story. Follow-ups: header-comment drift from PR #1727 review finding 3; rotate the postgres superuser password. |
| Last touched | 2026-07-28 |
Acceptance
/mnt/developmentis reformatted to btrfs and mounted with the new filesystem in/etc/fstab(ext4 entry replaced, not appended).- Shared ccache
cache_diris reconfigured withhard_link = false,file_clone = true, andccache --show-configconfirms both. - Every worktree in the fleet (
ores_dev_swift_curie,ores_dev_prime_origin,ores_dev_brave_hopper,ores_dev_bright_faraday,ores_dev_jolly_knuth,ores_dev_solid_dirac,ores_dev_merry_newton,ores_dev_eager_maxwell,ores_dev_clever_dijkstra) is recreated and builds clean from scratch. - PostgreSQL 18 cluster is recreated from scratch via
pg_createclusteronto thenodatacowsubvolume, and every worktree's database is rebuilt to a working state viadb recreate. No dump is restored. - A ccache hit across two different worktrees building the same
commit produces an object file with link count 1 (
stat -c '%h'), confirming =file_clone=/copy semantics rather than a hard link. compass build ratrun concurrently in two worktrees, deliberately racing the same TU, completes green with no crash – the regression test for the corruption bug this story fixes.- No git worktree loses committed history; any uncommitted work found during the pre-migration sweep is either pushed or explicitly accepted as discarded by the user before the reformat proceeds.
compsizeconfirms transparent compression is actually engaged on the build trees (ratio materially below 1.0). Without this the migration has taken on btrfs's costs and delivered none of its benefit.- The fleet is rooted on a bare
ores.gitwith nine equal worktrees; no worktree hosts the object store for the others. /mnt/development/postgrescarries thenodatacowattribute, set beforeinitdb.- Swap is a dedicated
/dev/sda2partition atpri=100; no swapfile exists on the btrfs filesystem. - Total footprint with all nine worktrees built is below the 173G the ext4 layout used.
- Moimba is recreated: three clones plus five worktrees on their
recorded branches, and
KargaFormacaorestored from the$HOMEcopy. /home/marco/Development/Moimbaresolves again (it dangles between Step 3 and Step 6b).- The Step 2 discovery sweep, re-run after recreation, reports no
repository with uncommitted or unpushed work other than the known
Engine.remotesubmodule drift.
Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
Decisions
D1: btrfs over XFS (2026-07-28)
Both support reflinks and both fix the bug. Chose btrfs solely for
transparent zstd compression, because file_clone=true force-disables
ccache's own compression (man ccache), inflating a 9.4G compressed
cache several-fold on a disk already at 83%. Measured 6.0x zstd ratio on
this codebase's debug objects. XFS is otherwise the better fit –
faster on parallel small-file metadata, honest df, no balance
maintenance, friendlier to PostgreSQL – and would be the choice if
space were not the binding constraint. Accepted costs: chunk-allocation
ENOSPC risk, balance maintenance, PostgreSQL requiring nodatacow.
D2: Rebuild rather than restore (2026-07-28)
All SSD data is destroyed and recreated from remotes. Nothing is bulk-
copied to /dev/sdb – it is rotational (ST2000DM008) and the copy
would be slow and pointless for regenerable build output. Only two
things are preserved: unpushed git work (pushed to the remote, not to
local disk) and the pre-migration config files. This also buys the
chance to recreate the layout cleaner than it was.
D3: Bare repo with nine equal worktrees (2026-07-28)
The fleet is already git worktree-based, but the object store lives
inside ores_dev_prime_origin/.git, making one worktree load-bearing
for the other eight. Since everything is being recreated anyway, re-root
on a bare ores.git. Same disk saving, no privileged worktree.
D4: Dedicated swap partition, 8G (2026-07-28)
The current 16G swapfile sits inside the partition being reformatted.
Keeping a swapfile on btrfs is possible but constrained – it must be
nodatacow, cannot be compressed, and blocks snapshots of its
subvolume. A dedicated /dev/sda2 partition avoids all three.
Sized at 8G, down from 16G:
- 31G RAM, 4.8G in use, 26G available; swap usage is 3.1G. 8G is ~2.6x observed peak usage.
/dev/sdb3already exists as a 64G swap partition, unused atpri-1=. Tiering fast SSD swap atpri=100over that as overflow means shrinking the fast tier cannot cause an OOM – the tail is still covered, just slowly. This is what makes 8G safe rather than optimistic.- Hibernation is not in play (it would require swap >= 31G RAM); this is a build host that stays up.
- Swap's real job here is evicting cold anonymous pages so RAM stays available for page cache – currently 23G of buff/cache, which is what keeps builds fast. That job needs a fast tier, not a large one.
- Returns 8G to a disk that has 36G free.
D5: PostgreSQL recreated, not dumped and restored (2026-07-28)
No pg_dumpall is taken. The cluster is dropped and recreated from
scratch with pg_createcluster, and each worktree's database is rebuilt
by compass db recreate from the repo's create=/=populate scripts –
the project's normal, already-idempotent path. The
ores_dev_local1..4 scratch databases (~9.3G logical) are accepted as
discarded.
This is also the technically cleaner option, not merely the cheaper
one: chattr +C only affects files created after it is set, so a fresh
initdb into an empty nodatacow directory is the only way to get a
fully NOCOW cluster. Restoring a dump into a pre-existing directory
would have left the requirement half-satisfied.
Note the cluster configuration at /etc/postgresql/18/main lives on
/, not the SSD, so it survives the reformat – but pg_dropcluster
deletes it, hence the config copy in Step 2.
D6: Discover repositories, never enumerate them (2026-07-28)
The original Step 2 gate looped over nine hardcoded worktree names. The partition actually holds 26 git repositories, and the missing 17 included an entire second project (Moimba, 12G) plus real uncommitted work. Three separate rounds of discovery were each incomplete:
- Enumerating the nine known worktrees missed Moimba entirely, plus
Hedgr.remote's 4 unpushed commits – which the plan had written off as "a clean clone, just re-clone it", an assumption that was false. - Checking
statusand unpushed commits but notstash listmissed five stashes. find -maxdepth 4missedKargaFormacao/Karga(depth 5, and the one tree with no SSH remote) and two Claude Code agent worktrees under.claude/worktrees/, one holding 283 uncommitted lines.
Rule going forward: enumerate by find, at full depth, checking dirty
and unpushed and stash. A hand-maintained list of directories is a
latent data-loss bug on a machine where new checkouts appear routinely.
D7: Layout and naming (2026-07-28)
Three changes settled while executing, all cheap now and expensive later:
- No
Development/level./mnt/development/Development/OreStudio/xbecomes/mnt/development/OreStudio/x. The old level was a redundant directory inside a mount already called/mnt/development. - OreStudio worktree names unchanged. All nine keep their
ores_dev_<adjective>_<scientist>names; they are familiar and there is no reason to churn them.ores_dev_prime_originis no longer privileged, though – under D3 the object store lives inores.git. - Corimba adopts the same conventions: bare
corimba.gitplus three equal worktrees namedcorimba_dev_<adjective>_<scientist>, chosen not to collide with the OreStudio nine.
Karga and Baobab are dropped from the SSD, along with
KargaFormacao. Their work is pushed and they can return whenever
wanted; this reclaims roughly 6.2G. KargaFormacao is the exception
worth watching: it is not reproducible from a remote alone, so the
$HOME copy is now its only full copy.
Out of scope
- Automated detection/recovery tooling for hard-link corruption (option C from the alternatives considered) – superseded by this migration, not built alongside it.
- Serializing builds across worktrees (option B) – rejected in favour of this fix.
- Migrating any host other than
neumann(the fleet's build host). - Changing ccache's
direct_mode=/=inode_cache=/=compiler_checksettings beyondhard_link=/=file_clone=/=max_size– out of scope unless a problem surfaces during verification.hash_dir=falsein particular must not change: it is what makes cross-worktree cache sharing work at all. - btrfs snapshots. Deliberately excluded: snapshots are the one
feature that converts COW into standing space overhead, which this
design cannot afford. (Subvolume layout,
compress=zstdandnodatacowwere previously listed here as optional; they have been pulled into scope – compression is the reason btrfs was chosen at all, andnodatacowis required for PostgreSQL to be usable.) - Migrating
sccache(74M, mostly unused) or consolidating it with ccache. - Finishing the SSD/Postgres physical-size inventory – superseded by D2: the data is being destroyed, so its physical size no longer gates anything.