Largely improves user & operator visibility on imports, resiliency with retries, direct-to-offload storage, import cancellation (deleting imported messages) & more. Remove the modal import and manage all within the import tab in the settings modal
20 KiB
Tiered blob storage
Blobs (raw RFC822 email bodies and attachments) live in PostgreSQL by
default. Once a blob is older than MESSAGES_BLOBS_OFFLOAD_DELAY,
a periodic celery task moves its bytes to S3 and clears the PG row's
raw_content. Reads transparently fetch from whichever location the
row points at — application code only ever calls blob.get_content().
One exception skips the PG hot tier entirely: bulk imports create
their blobs with create_blob(prefer_offloaded=True), which uploads
the (compressed, encrypted) bytes straight to the object tier and
inserts the row with raw_content=NULL — a multi-gigabyte archive
never parks its bytes in Postgres waiting for the offload tick. The
offload policy still governs: when MESSAGES_BLOBS_OFFLOAD_ENABLED
is off or the content is under MESSAGES_BLOBS_OFFLOAD_MIN_SIZE, the
preference is silently skipped. Best effort: if object storage is
unconfigured or the upload fails, the blob falls back to the PG tier
and the periodic offload moves it later. The upload happens under the same per-sha advisory lock and
object-before-row ordering as offload_one_blob, so crash recovery
and verify_blobs semantics are unchanged.
Architecture
- Storage path:
blobs/{key_id}/{sha[:3]}/{sha}. The leadingkey_idsegment lets blobs encrypted with different keys coexist (essential for crash-safe online key rotation). The 3-char sha prefix shards each key into 4096 sub-prefixes for S3 request-rate balance. - Deduplication: identical content always lands as exactly ONE
Blobrow, regardless of how many mailboxes / messages / attachments reference it.BlobManager.create_blobhashes the input first and short-circuits on a sha-match — no compress, no encrypt, no insert. MultipleMessage/Attachment/MessageTemplaterows can FK the same Blob; cleanup is governed by the reference graph + GC sweep below. After offload, the single Blob row maps to one S3 object. The DB is the sole source of truth; drift between DB and bucket (external deletion, lifecycle expiry) is detected offline byverify_blobs --mode=db-to-storage, not on the hot path. - Concurrency: a Postgres transaction-scoped advisory lock keyed on the first 8 bytes of sha256 serializes offload, cleanup, and re-encrypt for any one content. Different shas run in parallel.
- Encryption: optional AEAD (currently AES-256-GCM; the algo is
named per-key in config so future additions don't require a format
change). Configured secrets are hashed to 32 bytes via SHA-256
(same pattern as
encrypted-fields'sSALT_KEY). The hash adds no entropy — its strength is whatever entropy the operator put into the input string. Storage layout per object:nonce(12) || ct+tag(16), total overhead 28 bytes, no base64. The blob's SHA-256 is bound as AAD on the auth tag, so ciphertext is non-portable: copying bytes between blob paths fails decrypt withInvalidTag. - Read-time hash verification (optional): set
MESSAGES_BLOBS_VERIFY_HASH=Trueto re-hash decompressed plaintext on every read. Adds one SHA-256 per read. Most useful forkey_id=0(plaintext-stored) blobs — encrypted blobs already get the AAD-bound auth tag for free.
Blob lifetime: reference graph + GC sweep
Blobs are not owned by a Mailbox/MailDomain at the schema level. A Blob is alive as long as any of these references it:
Message.blob(the raw RFC822 MIME) orMessage.draft_blob(the body of a draft being composed)Attachment.blob(per-attachment during draft composition)MessageTemplate.blob(signatures, autoreply bodies)InboundMessage.blob(in-flight internal message)
Plus a short-lived upload reservation in the form of a
MailboxBlob row carrying an explicit expires_at timestamp. The
JMAP upload endpoint creates one alongside the Blob row so the
blob_id survives until the follow-up attach call lands; the attach
flow drops it once the Attachment row exists.
When a reference source is deleted (Message, Attachment,
MessageTemplate, InboundMessage post_delete), the affected blob_id
is pushed into a Redis candidate set. A periodic Celery task —
gc_orphan_blobs_task in core/services/blob_gc.py — drains the
set, re-checks the reference graph under the per-sha advisory lock,
deletes the row if no references remain, and cleans up the S3
object inline. No per-blob celery fan-out; one task processes the
whole backlog within a 55-minute wall-clock budget per hourly tick.
Two modes:
mode="fast"(default, beat-scheduled hourly): drain the Redis candidate set, GC anything that's actually orphaned.mode="full": walk every Blob row. Use as a periodic safety net (weekly cron) to catch anything dropped by a Redis outage or a signal that didn't fire. Invoke manually withpython manage.py run_task core.services.blob_gc.gc_orphan_blobs_task --kwargs '{"mode": "full"}'.
Pass "dry_run": true to preview without deleting:
python manage.py run_task core.services.blob_gc.gc_orphan_blobs_task --kwargs '{"mode": "full", "dry_run": true}'.
The task logs one INFO line per would-be-deleted blob (id, sha256,
storage_location, size, created_at) and returns counts with
would_delete instead of deleted. Nothing is locked or mutated.
In fast mode, dry-run peeks at the Redis candidate set rather than
popping it, so a follow-up real run still sees the same ids. The result
is informational: a concurrent reference insert between dry-run and a
real run could turn a would-delete into a skip.
The GC-driven model fixes a latent bug from the FK-cascade era: when
two mailboxes shared a thread, deleting one mailbox would CASCADE
through Blob.mailbox and break content access for the other
mailbox. Without the FK, the blob is alive as long as any thread
access still references it.
Enabling offload
By default everything runs in PostgreSQL with raw_content populated.
To start moving cold blobs to S3:
- Provision a bucket and credentials.
- Set the
STORAGE_MESSAGE_BLOBS_*env vars (see env.md). make create-buckets(or the production equivalent).- (Optional) Preview what the next tick would offload:
python manage.py run_task core.services.tiered_storage_tasks.offload_blobs_task --kwargs '{"dry_run": true}'. The task logs one INFO line per eligible blob (id, size, stored, content_type, created_at) and returns counts withwould_offloadplusbytes_plain/bytes_stored. Bypasses both theMESSAGES_BLOBS_OFFLOAD_ENABLEDandservice.enabledgates so you can preview before configuring the bucket. - Set
MESSAGES_BLOBS_OFFLOAD_ENABLED=True.
A single celery beat task fires hourly and processes eligible blobs sequentially within a 55-minute wall-clock budget — no per-blob fan-out. Each row is offloaded under the per-sha advisory lock, so the row flip is atomic. Whatever isn't done in one tick is picked up by the next.
Encryption
To enable encryption-at-rest for blobs:
- Generate a random secret string. Use
openssl randor equivalent so the input is genuinely high-entropy:Startup emits a warning if any configured secret is shorter than 32 characters. The warning is a length check, not an entropy measurement —openssl rand -base64 32"a" * 32passes silently. Treat the floor as a tripwire for typos, not a security guarantee. - Add it to
MESSAGES_BLOBS_ENCRYPT_KEYSas a JSON dict. Every entry must spell outalgoandsecret. Add"active": trueto the entry whose key new blobs should be encrypted with:Entries withoutMESSAGES_BLOBS_ENCRYPT_KEYS='{"1": {"algo": "aes-gcm", "secret": "<the secret>", "active": true}}'active(or withactive: false) stay readable for legacy ciphertext but no longer encrypt new blobs. At most one entry may be active. Thealgovalue is a complete spec — picking a new cipher in the future means adding a new algo identifier, not changing what the current one means. Unknown algos are rejected at boot and at use time. - Restart. New blobs are encrypted; existing unencrypted blobs
(
encryption_key_id=0) keep working until they're rotated.
manage.py check validates the config at startup and refuses to
boot if more than one entry is active, or any algo is unknown.
Lose the key dict, lose the data. Back it up alongside your DB.
Rotate before you hit AES-GCM's safety limit. With a random
96-bit nonce per encryption, the standard guidance is to retire a
key after 2^32 (≈ 4 billion) encryptions to keep the
nonce-collision probability negligible. Plan a rotation at-or-below
that count even if no key compromise is suspected; the
re_store_blobs runbook below walks through
the steps. (One blob = one encryption, regardless of how many DB
rows reference it via dedup.)
Reconciling state: re_store_blobs
re_store_blobs makes every blob's state match the
current configuration. Two operations, applied based on settings:
- Key rotation (always). Every row whose
encryption_key_iddiffers from the entry currently flaggedactive=trueinMESSAGES_BLOBS_ENCRYPT_KEYSis re-encrypted under that key. - Restore (only when
MESSAGES_BLOBS_OFFLOAD_ENABLED=False). EveryOBJECT_STORAGErow is pulled back into PostgreSQL.
Two runbooks below use this command.
Key rotation runbook
To rotate from key 1 to key 2 while keeping tiered storage enabled:
-
Add the new key alongside the old one, with
active=trueonly on the new entry:MESSAGES_BLOBS_ENCRYPT_KEYS='{"1": {"algo": "aes-gcm", "secret": "<old>"}, "2": {"algo": "aes-gcm", "secret": "<new>", "active": true}}'New blobs encrypt under key 2 immediately; existing blobs stay on key 1 until rotated.
-
Pause offload to avoid a race:
MESSAGES_BLOBS_OFFLOAD_ENABLED=FalseWait for in-flight tasks to drain.
⚠️ Note: with offload disabled,
re_store_blobswill also pull OBJECT_STORAGE blobs back into PostgreSQL. If you only want to rotate keys and keep blobs in S3, leaveMESSAGES_BLOBS_OFFLOAD_ENABLED=Trueand accept that an offload task could race the rotation (both take the per-sha advisory lock, so the worst case is a few re-tries — no corruption). -
Run the rotation:
python manage.py re_store_blobsFor each blob with
encryption_key_id != 2, the command- writes the new ciphertext to
blobs/2/...(atomic S3), - flips the dedup cohort's
encryption_key_idto 2 (atomic DB), - best-effort deletes the old
blobs/1/...path.
Each step is independently atomic, so a crash at any point leaves the blob readable from one consistent path.
- writes the new ciphertext to
-
Confirm key 1's prefix is empty:
aws s3 ls s3://msg-blobs/blobs/1/If anything remains, re-run the command (it's idempotent) or use
verify_blobs --mode=storage-to-dbto list orphans for manual cleanup. -
Re-enable offload, then drop key 1 from the dict in a follow-up deploy:
MESSAGES_BLOBS_OFFLOAD_ENABLED=True MESSAGES_BLOBS_ENCRYPT_KEYS='{"2": {"algo": "aes-gcm", "secret": "<new>", "active": true}}'
Rolling tiered storage back
To bring every offloaded blob back into PostgreSQL:
- Disable offload so no new blobs leave PG:
Wait for in-flight offload tasks to drain (next beat tick is the kill switch — already-running tasks finish their current blob, then the loop's
MESSAGES_BLOBS_OFFLOAD_ENABLED=Falsedisabledcheck makes the next tick a no-op). - Verify your PG has room. Each row that comes back from S3
carries its own copy of the (compressed, encrypted) bytes —
dedup that was sharing a single S3 object becomes one PG row
per blob. Check
pg_total_relation_size('messages_blob')ahead of time. - Run the restore. Bucket creds must still be configured so
ciphertext can be read on the way home:
For every
python manage.py re_store_blobsOBJECT_STORAGErow the command:- downloads the ciphertext, decrypts under its current key, re-encrypts under the active key,
- writes
raw_contentand flipsstorage_location=POSTGRES(atomic DB), - opportunistically deletes the S3 object once the cohort empties out.
- Confirm the bucket is empty:
Anything still there is an orphan —
aws s3 ls s3://msg-blobs/blobs/ --recursiveverify_blobs --mode=storage-to-dblists them. - (Optional) Tear down bucket creds. Once
returns 0, you can safely unset
SELECT count(*) FROM messages_blob WHERE storage_location=2;STORAGE_MESSAGE_BLOBS_*— no read path will need them.
Operator commands
Two commands cover the operational surface; they are deliberately split so the read-only audit can never touch state and the mutating reconciliation can never be run by accident as part of "I just want to see what's going on":
python manage.py verify_blobs— read-only audit. Nothing is mutated; output drives manual recovery.python manage.py re_store_blobs— mutating reconciliation (key rotation + optional pull-back from S3 → PG). See the runbook section above.
verify_blobs (read-only)
Choose a mode with --mode=<name>. The default is full which
runs both checks.
--mode=db-to-storage— for every blob row markedOBJECT_STORAGE, HEAD the expected S3 object. ReportsMISSINGfor any drift (DB says present, bucket says absent).--mode=storage-to-db— LIST every object underblobs/and look up the matching DB row. ReportsORPHANfor any S3 object not referenced by any row, andINVALID PATHfor objects whose key doesn't match theblobs/{key_id}/{sha[:3]}/{sha}shape.--mode=full(default) — runsdb-to-storagethenstorage-to-dbback-to-back.--verify-hashes— additional flag. When combined withstorage-to-dborfull, downloads each S3 object, decrypts, decompresses, and recomputes SHA-256. ReportsHASH MISMATCHon divergence. Slow; use after a storage incident or as periodic paranoia. Silently no-op when used with--mode=db-to-storage.--limit=N— caps items checked (useful for sampling).--start-after-key=<key>— resumes astorage-to-dblisting past the given key. Pair with--limitto chunk a multi-million- object bucket across runs.
re_store_blobs (mutating)
Reads each blob, decrypts under its current key, re-encrypts under
the active key, and writes the result to the target storage
location implied by MESSAGES_BLOBS_OFFLOAD_ENABLED. The PG → S3
direction (offload of cold blobs) is owned by the periodic
offload_blobs_task celery beat task — not exposed here.
--dry-run— prints what would happen without writing.--limit=N— caps the worklist; rerun until empty.
Recovery scenarios
| Symptom | Detection | Fix |
|---|---|---|
DB row says OBJECT_STORAGE but S3 object missing |
verify --mode=db-to-storage reports MISSING |
Restore from backup (re-upload the bytes to the bucket and re-run verify --mode=db-to-storage to confirm). If no backup exists, re-ingest the original content from upstream (the producing source — IMAP, MTA, importer — or application logs that captured it). If neither is possible the blob is unrecoverable; mark the row with an incident annotation rather than fabricating bytes from another row. |
| S3 object with no DB row | verify --mode=storage-to-db reports ORPHAN |
After confirming, aws s3 rm the object. |
| Hash mismatch | verify --verify-hashes reports HASH MISMATCH |
The S3 object is corrupted (or AAD-bound integrity has been broken — same effect). Treat as data loss; restore from backup. |
| Old key_id prefix non-empty after rotation | aws s3 ls blobs/<old>/ |
Re-run re_store_blobs; it's idempotent. |
re_store_blobs reports errors on a subset of blobs |
command exit summary lists Errors: N |
The loop already skipped them and continued; failed blobs stay in their pre-run state. Re-run after fixing the underlying cause (key missing, S3 5xx, etc.); the command is idempotent. |
| Suspect orphan Blob rows accumulating (e.g. after a Redis outage) | SELECT count(*) FROM messages_blob significantly higher than the sum of Message.blob, Message.draft_blob, Attachment.blob, MessageTemplate.blob distinct ids |
Run the full GC: python manage.py run_task core.services.blob_gc.gc_orphan_blobs_task --kwargs '{"mode": "full"}'. It walks every Blob row and deletes anything without a remaining reference. Idempotent. |
Schema migration is one-way
Migration 0027 drops Blob.mailbox / Blob.maildomain and the
blob_has_owner constraint, and flips every Blob-referencing FK
(Message.blob, Message.draft_blob, Attachment.blob,
MessageTemplate.blob, MailboxBlob.blob) to on_delete=PROTECT.
The FK drop is intentionally not reversible: once those columns
are gone, there is no way to repopulate them from the reference
graph (a blob shared across mailboxes via dedup has no single
"owner" to write back). The on_delete=PROTECT flips are reversible
at the SQL level (just metadata), but rolling back re-introduces
the data-loss races the change fixed. Treat the migration as
one-way for production.
Operational notes
- Initial rollout on a populated DB. The offload task processes
eligible blobs sequentially within a 55-minute wall-clock budget per
hourly tick. There is no per-blob celery fan-out, so a backlog of
millions doesn't queue-bomb the broker. Ramping
MESSAGES_BLOBS_OFFLOAD_DELAYdown gradually (e.g. 365d → 90d → 30d → 1d, expressed in seconds) still helps spread the load over several days, especially if bandwidth to the bucket is the bottleneck. Run--mode=db-to-storageperiodically afterwards to confirm everything that flipped toOBJECT_STORAGEis actually in the bucket. - Reading offloaded blobs triggers a synchronous S3 GET in the request path. Old messages are rarely re-opened, so this is generally invisible — but expect 50–500 ms latency when it does happen.
- Bulk deletes (e.g. mailbox cascade,
QuerySet.delete()) push the affected blob_ids into the GC candidate set via post_delete signals on the reference sources (Message, Attachment, Template). No per-blob celery task is enqueued — the periodic GC drains the set in a single bounded task per tick. Cascade of 100k attachments produces 100k Redis SADDs (~1-2s total), not 100k Celery messages. - Drift between DB and bucket. Offload trusts the DB on dedup
(no S3 HEAD), so a missing-S3-but-DB-says-OBJECT_STORAGE row will
not auto-repair. Run
verify_blobs --mode=db-to-storageon a schedule (weekly, monthly — workload-dependent) to catch drift; the recovery scenarios table above maps each symptom to a fix. - Postgres disk growth. Offload nulls
raw_content, it doesn't delete the row, so TOAST stabilizes around the un-offloaded backlog while heap and non-partial indexes (pkey,sha256) grow with the cumulative row count. The offloadUPDATEchangesstorage_location(an indexed column), so HOT updates are disabled and each offload writes into every index on the table. At high write rates default autovacuum can fall behind on index and TOAST bloat. Monitordead_pctandpg_total_relation_sizeformessages_blobover time; if growth or dead-tuple ratio trend upward after autovacuum catches up, tighten per-table autovacuum and add a periodic online compaction (e.g.pg_repack) to the runbook. Partitioning bycreated_atis the lever to keep in mind once row count gets large.