Old blobs not deleted

Your question

Orphaned blobs in MinIO not cleaned up after account deletion

Version: 0.16.7
Data store: PostgreSQL
Blob store: MinIO

Problem:

I deleted several test accounts and domains months ago (from 0.15.x). About 20GB of data remain in MinIO that appear to be orphaned, which are no longer referenced by any active account. Running the maintenance tasks completes without errors but does not reclaim any space in MinIO.

How can data from these old accounts be pruned from the system? Whatever was going on in 0.15.x never removed the blob data and that has now carried over.

Let me look into how Stalwart handles blob cleanup and maintenance tasks.

The blob store clean-up runs on a separate schedule from general data-store maintenance. The two are independent: running the data-store maintenance task does not purge the blob store.

What controls blob clean-up:
The blobCleanupSchedule field on the DataRetention singleton sets how often the blob store is purged (default: daily at 04:00). The blob store purge is a StoreMaintenance task with maintenanceType: purgeBlob. If that scheduled task has not been running (or was not working in 0.15.x), blobs will accumulate indefinitely.

How to address the backlog:

  1. Check that blobCleanupSchedule is set – go to Settings › Storage › Data Retention in the WebUI and confirm the blob clean-up schedule is enabled and not set to null.

  2. Trigger a one-off blob purge immediately – you can create a StoreMaintenance task with maintenanceType: purgeBlob on demand via the WebUI (Management › Tasks) or the CLI:

    stalwart-cli create task/StoreMaintenance
    

    with the body {"@type": "StoreMaintenance", "maintenanceType": "purgeBlob"}. This will walk the data store’s blob reference table and delete any blobs in MinIO that are no longer referenced by any active account.

  3. Verify the blob cleanup schedule going forward – the default is daily at 04:00. You can confirm or change it on the DataRetention object:

    { "blobCleanupSchedule": {"@type": "Daily", "hour": 4, "minute": 0} }
    

The key thing to understand is that the scheduled maintenance tasks you were running were likely purgeData or purgeAccounts, not purgeBlob. The blob purge is a separate operation that must either run on schedule or be triggered explicitly.

Relevant docs:

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

  1. Blob cleanup is set to once a day. It was in 0.15.x as well
  2. The purgeBlob task finishes very fast without an error, and none of the old stale data is removed.

Perhaps those blobs are still being referenced from spam samples. Stalwart deletes blobs from the store only when there are no internal references left.

The accounts and domains are removed. It has been over 3 months since they were removed and the blobs are still around.

How can I troubleshoot this to find out if they are being referenced from somewhere?

Any suggestions? I am not sure how to safely reclaim the space that is still in use from these blobs.

You can check the spam training samples to see if there are any blobs referenced there. Also, make sure that this is not due to the account undeletion and/or email undeletion features. And lastly, check your logs for any errors deleting the blobs from MinIO.

They are not in the spam training samples.

There are no errors in Minio.

The accounts were deleted back in v0.15, and it never removed the blob data when the accounts were deleted. The upgrade instructions were followed to get it to 0.16 a while ago, and since the accounts were no longer in the directory, there was no trace of them in the migration operations.

So I’ve still got ~20GB of data lying around in Minio, and I’m not sure which blobs are valid and which aren’t, in order to clear out the old stuff, or even to make sure this doesn’t happen again.

Is something like this needed for S3-based backends as well? RocksDB: Enable blob garbage collection to reclaim disk space from de… · stalwartlabs/stalwart@69c2b05 · GitHub

Stalwart only removes a blob from the store once it has no remaining internal references, and it tracks those references in the data store (your Postgres), never by listing the MinIO bucket. The purge task frees a blob only when its commit record has zero link records left.
I am not aware of any current or past bugs related to orphaned blobs in the blob store caused by they garbage collection process (the purge task). Stalwart will not remove a blob link from Postgres unless the blob store returns success on the deletion request. So my guess is that this could be an issue in the migration process and not Stalwart itself.
To diagnose the issue please download list_active_blobs.py and decompress_blob.py from the Stalwart repository.

I asked Claude to write a small guide (see below) explaining how to install and use these scripts. If you have any questions or issues, let me know.

Auditing the blob store for orphans

Stalwart reference-counts blobs. The raw blob bytes live in the blob store
(an S3/MinIO bucket, the filesystem, or the data store), while the references
that keep each blob alive live in the data store under the SUBSPACE_BLOB_LINK
subspace (the PostgreSQL table named k). A blob becomes garbage-collectable
once it has no surviving reference; the housekeeper then deletes it from the blob
store.

If blobs accumulate in the bucket that the database no longer references (for
example after an interrupted purge, a restore, or a backend migration), you can
find and inspect them with the two scripts in this directory:

  • list_active_blobs.py prints the S3 object key of
    every blob that is still referenced, read straight from PostgreSQL.
  • decompress_blob.py decodes a raw blob object back to
    its original bytes (Stalwart stores blobs with a one-byte compression marker,
    optionally LZ4-compressed).

Diffing the bucket listing against list_active_blobs.py yields the orphans.

What counts as an “active” blob

list_active_blobs.py reads the k table and classifies each row by length
(the subspace byte is not stored; it only selects the table). All integers are
big-endian:

Bytes Meaning Active?
32 Commit marker (<hash:32>) No, it only records that the blob exists
40 Id link (<hash:32><id:8>) Yes
41 Document link (<hash:32><account:4><collection:1><document:4>) Yes
44 Temporary link (<hash:32><account:4><until:8>) Only while until (unix seconds) is in the future

A blob is reported as active when it has at least one Id/Document link, or a
Temporary reservation that has not expired. The S3 object key is the 32-byte
BLAKE3 hash encoded with Stalwart’s custom base32 alphabet, optionally preceded
by the configured key_prefix.

Temporary links cover blobs that were just uploaded but not yet committed to a
message. They are intentionally treated as active so an in-flight upload is
never flagged as an orphan.

Requirements

  • Python 3.8+
  • psycopg2 (or psycopg v3) for
    the PostgreSQL connection
  • lz4 for decompress_blob.py
  • A way to talk to your bucket: the MinIO client mc
    or the AWS CLI
python3 -m venv venv
source venv/bin/activate
pip install psycopg2-binary lz4

The scripts have no Stalwart dependency; run them from anywhere that can reach
your PostgreSQL server.

Listing referenced blobs

python3 list_active_blobs.py \
    --host localhost --port 5432 \
    --user stalwart --password stalwart --dbname stalwart \
    > active.txt

Connection settings can also come from the standard PGHOST, PGPORT,
PGUSER, PGPASSWORD, PGDATABASE environment variables, so the above is
equivalent to:

PGPASSWORD=stalwart python3 list_active_blobs.py > active.txt

Useful flags:

Flag Purpose
--prefix <str> The key_prefix configured on the S3 store, so the printed keys match the real object names. Omit it if no prefix is set.
--table <name> The blob-link table name (default k).
--now <unix_seconds> Override the clock used to expire temporary links.
--include-expired-temporary Treat expired reservations as active too (rarely needed).

Each line of output is one S3 object key, for example:

fxwmjktuburqu0qgzj3xeyr91z9pkqx0chk9gwdo79rrmjolazga
ooz7zxgwekjv3v7x7cwr2xetzw0bqskravgurchpdoxatne0frba

Finding blobs that are in MinIO but not in Stalwart

The orphans are the objects present in the bucket but absent from active.txt.

  1. List everything in the bucket. With mc:

    mc alias set myminio http://localhost:9000 minioadmin minioadmin
    mc ls --recursive myminio/stalwart | awk '{print $NF}' | sort > bucket.txt
    

    or with the AWS CLI:

    aws s3 ls --recursive s3://stalwart/ | awk '{print $NF}' | sort > bucket.txt
    

    If a key_prefix is configured, mc/aws print it as part of each key, so
    pass the same --prefix to list_active_blobs.py to keep both sides aligned.

  2. Sort the referenced set and compute the difference:

    sort -o active.txt active.txt
    comm -23 bucket.txt active.txt > orphans.txt
    

    orphans.txt now lists every object in the bucket that Stalwart no longer
    references.

Take a consistent snapshot. The listing and the database query are not
atomic. A blob uploaded between the two steps would look like an orphan. List
the bucket first, run the query second, and ideally point the script at a
quiesced primary or a replica. When in doubt, re-run the diff and only act on
objects that appear as orphans in two consecutive runs comfortably apart in
time.

Fetching a blob to inspect its contents

Download a single object (object keys come from orphans.txt):

mc cp myminio/stalwart/<object-key> ./blob.bin
# or
aws s3 cp s3://stalwart/<object-key> ./blob.bin

Stalwart stores blobs with a trailing one-byte compression marker, and message
blobs are usually LZ4-compressed with a little-endian u32 uncompressed-size
prefix. decompress_blob.py strips the marker and decompresses as needed:

python3 decompress_blob.py blob.bin -o blob.eml

It also reads from stdin, so you can inspect an object without writing the raw
form to disk:

mc cat myminio/stalwart/<object-key> | python3 decompress_blob.py - | less

The marker byte determines the handling:

Last byte Encoding Action
0xa1 LZ4 (u32 size prefix + lz4_flex block) strip the marker, then LZ4-decompress
0x00 uncompressed strip the marker
anything else legacy blob without a marker emitted unchanged (a warning is printed)

A decoded message blob is a standard RFC 5322 message: open blob.eml in any
mail client or pipe it through tools like formail/reformime to examine it.

Removing orphans

These scripts are read-only and never delete anything. Prefer Stalwart’s own
garbage collection (it runs automatically and can be triggered from the admin
tooling) over deleting objects from the bucket by hand. If you do delete
directly, keep a backup of orphans.txt and the objects until you have
confirmed the server is healthy.

While I was doing post migration work after 0.15 → 0.16, I saw that there indeed where a couple of “accounts” with a lot of data that were still polluting the database.

After playing around with Claude for a bit, Claude was able to come up with the following dragon of an SQL query that will make a table of all “emails” in your database and IFF there is any account attached to it. For me, there where two “accounts” without principals attached. The purge store tasks did not clean this up.

What worked instead, is to boot Stalwart 0.16 in maintenance mode and create new accounts with the special “restore-” key matching the ones from the SQL query.

This essentially restores the account again. Now you can simply remove the account from the UI. Finally, now you should run the blob and data prune tasks.

If you have enterprise: you will need “reschedule” (pull forward) the DeleteAccount task that was schedule for said account.

WITH counts AS (
  SELECT ('x' || encode(substring(k from 1 for 4),'hex'))::bit(32)::int AS account_id,
         count(*) FILTER (WHERE get_byte(k,5) = 50) AS emails,
         count(*) FILTER (WHERE get_byte(k,5) = 91) AS tombstoned
  FROM p WHERE length(k) = 10 AND get_byte(k,4) = 0
  GROUP BY 1
),
accounts AS (
  SELECT ('x' || encode(substring(k from 7 for 4),'hex'))::bit(32)::int AS account_id,
         t.toks[array_position(t.toks,'argon2id') - 1] AS login,
         array_to_string(ARRAY(
           SELECT m[1] FROM regexp_matches(encode(v,'escape'),
             '\m[A-Z][a-z]{2,}\M', 'g') AS m), ' ') AS display_name
  FROM d
  CROSS JOIN LATERAL (
    SELECT ARRAY(SELECT m[1] FROM regexp_matches(
             encode(v,'escape'), '[A-Za-z0-9._+-]{2,}', 'g') AS m) AS toks
  ) t
  WHERE length(k) = 10 AND get_byte(k,0) = 0 AND get_byte(k,1) = 0
),
quota AS (
  SELECT ('x' || encode(substring(k from 1 for 4),'hex'))::bit(32)::int AS account_id,
         v AS quota_bytes
  FROM n WHERE length(k) = 5 AND get_byte(k,4) = 255
)
SELECT c.account_id, a.login, a.display_name,
       c.emails, c.tombstoned, pg_size_pretty(q.quota_bytes) AS quota
FROM counts c
LEFT JOIN accounts a USING (account_id)
LEFT JOIN quota q USING (account_id)
ORDER BY c.emails DESC;