The GLaDOS 3.0 corpus had become too large to share a hypervisor’s ZFS pool politely. It was competing with VM and container storage, had pushed the pool to roughly 90% capacity and was still growing.
An earlier ext4 volume had already demonstrated that reserving nearly the whole volume group without compression was not a durable answer. This time I removed an unused thin pool and created a smaller, dedicated Btrfs logical volume for the corpus, leaving meaningful unallocated headroom for the host.
The volume uses zstd:6, noatime, nofail and a bounded systemd device timeout. The failure behaviour matters on a hypervisor: a missing data volume should not put the whole node into emergency mode or make boot wait indefinitely. noatime avoids turning corpus reads into writes, and the final fstab field remains 0 because Btrfs does not use a traditional boot-time fsck pass. After changing fstab, systemd still needs a daemon reload before the generated mount unit is tested.
The volume is bind-mounted into an unprivileged development container and deliberately excluded from the container backup. Bind mounts are a root-only administrative action in Proxmox, and host-side ownership has to account for the container’s shifted ID mapping. The node-local mount also means the container is deliberately non-HA; the corpus workspace has a different availability and backup contract from replicated VM storage.
After moving the workload, the ZFS pool fell from about 90% to 47.4%. The corpus itself measured 731G logically with du, but occupied 153.8G physically: a 4.75:1 ratio.
The important measurement correction is that df does not lie about Btrfs compression. Both df and btrfs filesystem usage report physical, post-compression space. du supplies the uncompressed logical size. The useful compression check is therefore compsize, or the relationship between du and df, rather than treating disagreement between them as a filesystem accounting error.
The move reduced the measured footprint from 276.54G on ZFS to 153.8G on Btrfs: about 122.7G, or 44%. That is a compression-setting result rather than a filesystem contest. The ZFS dataset inherits compression=lz4, reports a 2.87× compression ratio and uses the default 128K record size. The Btrfs volume uses zstd:6 and reaches 4.75:1 on the same highly compressible JSONL. A fair filesystem comparison would put ZFS on zstd-6 too; with a larger record size, it might even edge ahead by seeing repeated boilerplate across a wider window. The result here shows the difference between LZ4 and a stronger Zstandard level on this data, not a uniquely clever filesystem.
VDO was the wrong alternative for the same reason. Its block-layer deduplication and compression would add a pinned UDS index and compress isolated 4K blocks with LZ4. That is useful for some virtual-machine workloads, but a poor match for large text shards which already respond exceptionally well to filesystem-level Zstandard.
The move also clarified two ZFS numbers I had been reading too dramatically. FRAG describes free-space fragmentation, not fragmented files, and freeing blocks does not make it fall immediately. There is no online zpool defrag; rewriting into a new dataset is the eventual remedy if fragmentation becomes a real problem. The gap between the pool’s raw and usable capacity was also ordinary 1/32 slop space, not missing storage. More importantly, dropping below roughly 80% capacity moved the allocator away from its slower best-fit path. For this pool, restoring headroom matters more to write latency than making the fragmentation number look nicer.
The Btrfs failure mode worth watching is metadata exhaustion. A many-small-files workload can reach ENOSPC while data space still appears plentiful because its metadata block group is full. This corpus is sharded rather than stored as one file per conversation, and its metadata allocation remains healthy rather than merely large.
There are two remaining compromises. The current tree is still a flat filesystem instead of a Btrfs subvolume, so adding snapshots later would require a real copy across a subvolume boundary. The cheaper plan is to create the next corpus tree as a subvolume from the beginning. The volume also uses data,single: checksums can detect corruption, but cannot repair it from another local copy. That is acceptable only because the local disk is working storage rather than the sole copy.
The remaining checks are operational. I still need to confirm that the monthly scrub timer is actually firing, because checksums only help when something reads them. I also need to measure the ratio again when the next corpus lands. More binary or pre-tokenized data will compress less, and zstd:6 will stop looking free. A future scratch volume for output deleted an hour later should probably use zstd:1, or no compression at all, rather than spending CPU to optimize temporary bytes.