TinyStore

Blobs / Integrity

Integrity

Two things can go wrong with a stored file: its bytes change on the disk, or two writers overwrite each other. A whole read is checked against the file's SHA-256, a scrub reads every file in the background, and ifMatch and create keep two writes from crossing.

Checked reads

const file = await docs.get('contract.pdf')
try {
	const bytes = await file!.bytes() // hashed as it is read
} catch (err) {
	if (err instanceof CorruptError) return restoreFromBackup('contract.pdf')
	throw err
}
file = await docs.get("contract.pdf")
try:
    data = await file.read()  # hashed as it is read
except tinystore.CorruptError:
    return await restore_from_backup("contract.pdf")
file, found, err := docs.Get(ctx, "contract.pdf")
defer file.Close()
data, err := io.ReadAll(file) // hashed as it is read
var e *tinystore.Error
if errors.As(err, &e) && e.Kind == tinystore.Corrupt {
	return restoreFromBackup("contract.pdf")
}
let mut file = docs.get("contract.pdf")?.expect("contract.pdf");
match file.read_all() {
    Err(err) if err.kind() == ErrorKind::Corrupt => restore_from_backup("contract.pdf"),
    other => other.map(|_| ()),
}?;

A whole read hashes the bytes as they come and compares them with the hash taken when they were written. The last bytes are held back until they match, so a file whose bytes changed fails corrupt before its end, and a whole body is never handed over as good (contract BLOB-02).

A response that is already streaming has sent its status and its first bytes when that happens: your handler ends it on the error, and the client sees a body cut short, never a whole wrong one.

A range, slice or a seek, is not checked: only a read from the first byte to the last is.

The scrub

In the background the store reads every stored file over 30 days, a slice a minute, and checks each against its hash. A file whose bytes changed is said in the store's log, once, with every path that names it:

blobs: a file's bytes are changed  content=4471 paths=["docs/contracts/2026/acme.pdf"]

A file missing from the disk, or shorter than its size, is said the same way. With the store's own metrics on, tinystore_blobs_damaged_total counts them.

A disk that rots a byte is found within a month, whether or not anyone reads the file, while last month's backup still holds it whole.

Write only if nobody changed it

ifMatch takes the ETag a read gave: if the file changed, expired or was deleted since, the write is conflict and nothing is written.

const doc = await docs.head('notes.md')
await docs.put('notes.md', edited, { ifMatch: doc!.etag }) // ConflictError if someone saved first
doc = await docs.head("notes.md")
await docs.put("notes.md", edited, if_match=doc.etag)
doc, found, err := docs.Head(ctx, "notes.md")
_, err = docs.Put(ctx, "notes.md", strings.NewReader(edited), blobs.IfMatch(doc.ETag))
let doc = docs.head("notes.md")?.expect("notes.md");
docs.key("notes.md").if_match(&doc.etag).put(&edited)?;

Of two writes that read the same ETag, one conflicts (contract BLOB-04). The editor that loses reads the file again and decides: merge, ask the user, or save as a copy. ifMatch works on put, upload, delete, copy and rename.

Write only if there is none

const created = await docs.create('invoices/2026-0042.pdf', pdf) // false when a file was there
created = await docs.create("invoices/2026-0042.pdf", pdf)
created, err := docs.Create(ctx, "invoices/2026-0042.pdf", bytes.NewReader(pdf))
let created = docs.create("invoices/2026-0042.pdf", &pdf)?;

create writes only when the path holds no file and says whether it did, as kv's create does: a file already there is an answer, not an error. An invoice numbered twice keeps the first.

Errors

ErrorWhenDo
invalida bad name, path, folder or size; a body longer or shorter than its sizefix the call
not foundcopy or rename from a path with no filecheck the path
conflictifMatch did not match; copy or rename onto a file; over a connection, a read of a file replaced since its getread again and decide
limitpast maxFileSize; a write that would leave the disk less than the store keeps freewrite less, or free the disk
corrupta whole read whose bytes do not match their hashrestore the file from a backup
outcome unknownthe commit failed after the write ranhead the path before writing again

Every error names the files and the path: files docs: "notes.md": conflict. get and head of a path with no file, and delete of one, are not errors.

Why this design?

Why hash on every whole read. A disk, a cable or a controller can change a byte, and a filesystem rarely notices. Without a check, the program serves the damaged file as good, and the backup made tonight copies the damage. SHA-256 is computed while the bytes stream anyway, so the check costs the hash and nothing else.

Why hold the last bytes back. A check that runs after the last byte is sent tells the program too late: the client already has a whole, wrong file. Holding back the end until the hash matches turns damage into a body cut short, which every client treats as a failed download.

See also

  • Blobs: the engine's overview.
  • Backup: a backup of files checks every byte when it is restored.

Edit this page on GitHub Updated 19 October 2026

esc
↑↓ navigate ↵ open ⇥ filter 0 results