Durability
What you'll learn: the layers that keep a stored object exactly the bytes you put — through crashes, power loss, and silent disk rot — and how to get a durable copy off the box.
The write path is atomic
OpenBucket never writes an object in place. Every blob write stages, syncs, and renames:
1. write bytes to DATA_DIR/tmp/put-<uuid> (O_EXCL, so two writers never collide)
2. fsync the temp file (the data is on the platter)
3. rename → DATA_DIR/blobs/<bucket>/<key> (rename(2) is atomic on one filesystem)
4. fsync the parent directory (the rename itself is now durable)
A crash at any point leaves either the old object or the new one — never a torn half-written blob. The same tmp→fsync→rename shape backs multipart parts, multipart compose, backups, and rehydrated (tiered) reads.
tmp/ and blobs/ on one filesystemrename(2) is atomic only within a single filesystem. If DATA_DIR/tmp and
DATA_DIR/blobs land on different mounts, Node returns EXDEV and OpenBucket
falls back to a (non-atomic) copy + unlink, logging a loud warning. Mount all of
DATA_DIR as one volume — don't bind-mount subdirectories separately.
Overwrites are crash-safe
Overwriting a key is destructive-in-place by nature, so before the swap OpenBucket hard-links the current blob aside as a backup (zero-copy — the old inode survives). If the write or the metadata commit fails, it restores the backup; on success it discards it. A crash mid-overwrite is reconciled by the startup recovery scan, which also removes orphaned multipart staging directories and reports (but never deletes) blobs with no matching metadata row.
Metadata is durable too
Metadata lives in one SQLite file (<DATA_DIR>/openbucket.db) opened in WAL
mode with synchronous = FULL, so a committed transaction is fsync'd to the WAL
before the write returns. Replication intents are written in the same
transaction as the object metadata (a transactional outbox), so a committed PUT
can never be lost or orphaned by a rollback.
Every object carries a SHA-256
On write, OpenBucket computes and stores two digests over the plaintext bytes
(before any SSE encryption): the MD5 that becomes the S3 ETag, and a
SHA-256 (contentSha256). Because the hash is over plaintext, one digest
validates single-part and multipart objects alike.
Read-time integrity gate
A full GET re-hashes the blob as it streams and compares it to the stored
SHA-256. On a mismatch OpenBucket returns a 500 rather than serve corrupted
bytes — bit-rot never reaches a client silently.
Background integrity scrubbing
The read gate only catches objects someone reads. A background scrubber
proactively walks current, local objects, re-hashes each blob through the same
verifier as the read gate, and records a per-object verdict (unchecked →
ok/corrupt). It is off by default and strictly rate-limited — a hard
per-tick object cap and a per-tick byte budget, a resume cursor between ticks —
so it never starves request traffic.
# Enable the scrubber (standalone):
OB_INTEGRITY_SCRUB_ENABLED=true
OB_INTEGRITY_SCRUB_MAX_BYTES_PER_TICK=1073741824 # 1 GiB/tick (default)
When a blob is corrupt and a replication target is configured, the scrubber
self-heals: it fetches the good remote copy, stages it through the two-phase
writer, re-verifies the on-disk bytes against the stored SHA-256, and atomically
swaps it in — flipping the row back to ok. A remote copy that also fails the
digest is rolled back, never overwriting the local blob. The admin Integrity
page (and /api/admin/integrity) surfaces the scan summary, the corrupt-object
list, and a manual "scrub now" trigger.
Replication: a durable off-box copy
Async one-way replication mirrors every committed PUT/DELETE to an external S3-compatible target (AWS S3, Cloudflare R2, Backblaze B2, MinIO, or another OpenBucket) via the transactional outbox. A background worker drains it with per-key ordering, last-writer-wins coalescing, exponential-backoff retry, and a dead-letter cap. Because intents are durable, the worker just resumes on boot after a crash or a remote outage — local reads and writes keep working while the remote is unreachable, and the backlog drains on recovery.
OB_REPLICATION_ENABLED=true
OB_REPLICATION_BUCKET=my-remote-mirror # must already exist
OB_REPLICATION_ACCESS_KEY_ID=…
OB_REPLICATION_SECRET_ACCESS_KEY=…
# Omit OB_REPLICATION_ENDPOINT for real AWS S3; set it for R2/B2/MinIO.
The worker sends object plaintext (SSE is decrypted before sending), so an
http:// endpoint leaks object contents and warns at boot. Prefer https://
unless the target is MinIO on a trusted LAN. Credentials are never logged.
The admin Replication page (and /api/admin/replication) shows pending/failed
depth and lag, and offers a bounded, single-flight Reconcile that re-enqueues
anything missing or size-divergent on the remote.
Cold-object tiering (read-through)
Tiering reuses the replication target to offload cold objects and
rehydrate them transparently on read. A <Transition> rule (age since last
access + a StorageClass) drives a sweep that ships cold bytes remotely —
only after confirming durability — then soft-deletes the local blob. A later
GET fetches the bytes back, integrity-verifies them before serving, and
flips the row local again (large objects get a short-lived presigned redirect
instead of being proxied). There is no data-loss window: the local blob is
deleted only after the remote copy is confirmed, and a corrupt/truncated remote
yields a 500, never bad data.
Backups: point-in-time snapshots
Beyond the on-demand backup/restore endpoints, OpenBucket can write .zip
snapshots on a schedule and prune them by a retention policy:
OB_SCHEDULED_BACKUP_ENABLED=true
OB_SCHEDULED_BACKUP_SCOPE=instance # or 'buckets' (one .zip per bucket)
OB_SCHEDULED_BACKUP_CRON='0 3 * * *' # OR OB_SCHEDULED_BACKUP_INTERVAL_MINUTES
OB_SCHEDULED_BACKUP_KEEP_LAST=7 # retention floor
OB_SCHEDULED_BACKUP_MAX_AGE_DAYS=30 # union with keep-last
Each snapshot is written atomically (stream to <final>.part, fsync, rename)
so a crash never leaves a torn .zip masquerading as a good backup, with a
sidecar recording size, object count, and SHA-256. Union retention means
keep-last-N is a hard floor and max-age can never delete a fresh snapshot. A
pre-flight free-space guard skips a cycle rather than fill the disk.
A backup archive holds decrypted object bytes (same posture as replication),
so snapshot files are 0o600 under a 0o700 directory. Treat the backup volume
with the same trust boundary as DATA_DIR.
Next steps
- Storage layout — where blobs, versions, trash, and the SQLite DB live.
- Security model — SSE-S3 keys and secret handling.
- Monitoring — watch replication lag and integrity counts in Prometheus.
- Upgrading — always snapshot before you upgrade.