Deleting mail never frees disk space with the RocksDB backend (BlobDB GC disabled) — measurements + 1-line fix

Hi — while capacity-testing Stalwart v0.16.5 (RocksDB backend, default settings, single node) we found that deleting mail effectively never returns disk space on a mostly-idle server, and traced it to the RocksDB BlobDB configuration.

What we measured

  1. Seeded a mailbox with 40,000 messages (~8.7 GB of blob data, realistic text+attachment mix) over IMAP.
  2. Expunged all of them (STORE +FLAGS \Deleted + EXPUNGE, verified EXISTS 0).
  3. Sampled the data directory every 15 minutes for 11.5 hours (idle otherwise).

Result: the blob store stayed byte-identical the whole time — 138 .blob files, 8,705 MB, zero bytes reclaimed. The WAL was also static, i.e. the periodic purge had not deleted the blob keys in that window either.

Why

crates/store/src/backend/rocksdb/main.rs enables BlobDB on the blobs CF (set_enable_blob_files(true), set_min_blob_size(...)) but does not enable blob garbage collection. With GC off, a .blob file is deleted only when no SST references it — and since blob keys are content-hash distributed, real-world files mix live and deleted blobs and are never rewritten. There’s no fallback either: purge_store issues per-key deletes only, and the backend never calls manual compaction, so the only idle-store backstop is RocksDB’s ~30-day stale-file horizon — which still can’t shrink partially-live blob files.

Operationally this means mailbox deletions (tenant offboarding, retention enforcement) look like a silent disk leak.

This refines the answer in GH discussion #335 (“it is up to the database when this data will actually be removed … usually occurs when the database performs a compaction”): for the blobs CF that only holds for blob files whose every message was deleted — compaction never rewrites partially-live blob files while GC is off, and as noted there, manual compaction isn’t triggerable on RocksDB either. So for mixed live/deleted data the space is effectively never returned.

Proposed fix (1 line)

In crates/store/src/backend/rocksdb/main.rs, blobs CF setup:

        // Blobs
        let mut cf_opts = Options::default();
        cf_opts.set_enable_blob_files(true);
        cf_opts.set_min_blob_size(config.blob_size);
        // Enable BlobDB garbage collection: without it, a .blob file is only
        // deleted once *no* SST references it, so blob files holding a mix of
        // live and deleted messages are never reclaimed — deleting mail frees
        // no disk space on mostly-idle stores. With GC enabled, compactions
        // rewrite live values out of the oldest blob files (default age
        // cutoff 0.25), allowing space from deleted messages to be returned.
        cf_opts.set_enable_blob_gc(true);
        cfs.push(ColumnFamilyDescriptor::new(CF_BLOBS, cf_opts));

RocksDB’s defaults (age_cutoff 0.25, force_threshold 1.0) then let normal compactions rewrite live values out of the oldest blob files and return the space, with write amplification bounded by the age cutoff (only the oldest quarter of blob files are GC candidates per compaction). This is the standard recommended configuration when running BlobDB with deletions. The set_enable_blob_gc API is available in the rocksdb = "0.24" crate you already pin.

Ready-to-cherry-pick commit (GitHub PRs are collaborator-restricted, so posting here instead):

Happy to sign the FLA if you want to take the commit as-is, and to test a build against our measurement setup — or to add a config knob next to blobSize if you’d prefer it tunable.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This is a well-researched report. Let me verify the current state of the RocksDB backend code to confirm the findings before responding.

Your analysis is accurate and the source code confirms it. The blobs CF is set up with set_enable_blob_files(true) and set_min_blob_size(...) but there is no set_enable_blob_gc(true) call. This matches your description exactly: without BlobDB garbage collection enabled, .blob files that contain a mix of live and deleted blobs will never be rewritten by compaction, so deleting messages does not reclaim disk space unless an entire .blob file’s worth of keys happen to be deleted.

The proposed fix – adding cf_opts.set_enable_blob_gc(true) immediately before the cfs.push(...) line for the blobs CF – is a well-scoped, targeted change that aligns with RocksDB’s documented recommendation for BlobDB deployments that involve deletions. The defaults (age_cutoff 0.25, force_threshold 1.0) are conservative and appropriate for a first pass.

This is a genuine bug with real operational impact (silent disk leak on tenant offboarding / retention enforcement), and your measurements and root-cause analysis are thorough and reproducible. Since GitHub PRs are collaborator-restricted, posting here is the right path – a Stalwart maintainer will need to review and apply or adapt the commit.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Any update on this issue? This has real operational impact:

  1. Blocking server resources (storage) after mailbox de-provisioning
  2. Violating GDPR Right-To-Delete since old mailbox data is essentially never removed from disk.

The 1-line fix (enable RocksDB Garbage Collection) is trivial.

This has been fixed. The fix will be included in v0.16.10.

The one caveat is that this only reclaims space going forward, not retroactively. New databases pick up the options automatically and space comes back gradually as compactions touch each blob file, but an existing oversized database won’t shrink until a compaction runs over the blobs column family.

For some reason after following your steps and running `ldb` command the database gets corrupted - this shows repeatedly in the logs.

ERROR RocksDB error (store.rocksdb-error) listenerId = "imaps", localPort = 993, remoteIp = [REDACTED], remotePort = [REDACTED], reason = "Corruption: Merge operator failed", causedBy = "crates/store/src/dispatch/store.rs:133", causedBy = "crates/common/src/storage/quota.rs:31", accountId = [REDACTED], id = "[REDACTED]", details = "Internal Server Error", code = "CONTACTADMIN", causedBy = "crates/imap/src/op/quota.rs:176"

Apologies, and stop using ldb on this database. The corruption is coming from that step, not from your data.

Stalwart registers a custom merge operator on a few RocksDB column families (the counter, quota, and in-memory-counter families). Stock ldb doesn’t have that operator compiled in, and even with --try_load_options it can only reconstruct it as a placeholder. ldb opens the whole database, not just the column family you name, so when it flushes or compacts it can write merge data that the real operator later can’t reconcile. That’s exactly the “Corruption: Merge operator failed” you’re seeing, and it surfaces on a quota counter read.
My earlier ldb suggestion was wrong.

The good news is your mail and blob data (the blobs family) is almost certainly intact; what’s damaged is the quota counter family. If you have a backup from before you ran ldb, restore it onto the 0.16.10 build and let RocksDB’s own compactions reclaim the space gradually as they touch each blob file. That background reclamation is the safe path and needs no manual step. If you don’t have a pre-ldb backup, tell me and I’ll help work out how to rebuild the counter family.

I’ll pull the ldb instructions from this thread and look at offering reclamation as a proper in-server maintenance action instead, since that runs with the correct merge operator and can’t hit this.

Thanks for the clarification. I restored from backup so I’m fine. I just wanted to report the result so others don’t run into the same issue.