The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act
class found two more instances, both now closed with the same discipline —
the decision runs at the serialization point that guards the apply.
add({ifAbsent}) / add({upsert}): the absence check ran before the commit
mutex, so N concurrent same-id creates could all pass it and all write — the
second silently overwriting the first, violating ifAbsent's "return the
existing id WITHOUT writing" contract and upsert's merge-never-clobber
contract. The insert leg now carries a must-be-absent commit precondition
(the same conditional-commit primitive ifRev uses), verified under the commit
mutex against the authoritative before-image. A losing caller takes its
documented resolution instead of overwriting: ifAbsent returns the existing
id with zero writes; upsert merges into the now-existing entity via update()
(shared param mapping in upsertMergeParams so the planning-time branch and
the conflict retry can never drift), with a bounded retry if a concurrent
delete lands between the conflict and the merge. The planning-time checks
remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep
planning-time semantics (documented, converges to a valid entity).
BlobStorage: write()'s dedup decision (exists → increment refCount, absent →
create at 1) and delete()'s decrement-then-remove were unserialized
read-modify-writes over blob-meta:<hash>. Concurrent writes of identical
content could lose references, so a later delete removed bytes another file
still referenced (data loss), or leaked unreferenced blobs. All
reference-count-bearing mutations now serialize through a per-hash
InMemoryMutex — distinct content never contends; incrementRefCount/
decrementRefCount are documented lock-assumed internals.
Both fixes are complete by construction in-process: storage enforces
single-writer-per-directory, so the process is the whole concurrency domain.
Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way
ifAbsent storm advances the store by exactly one generation with _rev 1;
8-way upsert storm on an absent id yields one create + seven merges
(_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N
identical concurrent blob writes → refCount === N; the blob survives until
the true last reference drops; interleaved write/delete storm never loses a
landed reference.
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
/**
|
|
* @module storage/blobStorage
|
|
* @description Content-addressed blob store. Backs VFS file content with
|
|
* SHA-256 addressing (automatic deduplication), reference counting, zstd
|
|
* compression where it pays (MIME-aware: already-compressed media is stored
|
|
* raw), and an LRU read cache.
|
|
*
|
|
* The store persists through a narrow key-value bridge
|
|
* ({@link BlobStoreAdapter}) provided by `BaseStorage`, which roots all keys
|
|
* under the `_cas/` storage area. Key naming is an explicit type contract:
|
|
* `blob:<hash>` keys hold binary bytes, `blob-meta:<hash>` keys hold JSON
|
|
* metadata — the key format decides how bytes are encoded, never content
|
|
* sniffing.
|
|
*/
|
|
|
|
import { createHash } from 'crypto'
|
|
import { unwrapBinaryData } from './binaryDataCodec.js'
|
|
import { InMemoryMutex } from '../utils/mutex.js'
|
|
|
|
/**
|
|
* @description Key-value bridge the blob store persists through. Implemented
|
|
* by `BaseStorage.initializeBlobStorage()` over the adapter's raw object
|
|
* primitives.
|
|
*/
|
|
export interface BlobStoreAdapter {
|
|
/** Read the bytes stored under `key`, or `undefined` when absent. */
|
|
get(key: string): Promise<Buffer | undefined>
|
|
/** Persist `data` under `key` (overwrites). */
|
|
put(key: string, data: Buffer): Promise<void>
|
|
/** Delete the value under `key`. Missing keys are ignored. */
|
|
delete(key: string): Promise<void>
|
|
/** List all keys starting with `prefix`. */
|
|
list(prefix: string): Promise<string[]>
|
|
}
|
|
|
|
/**
|
|
* @description Metadata persisted alongside each blob (under
|
|
* `blob-meta:<hash>`).
|
|
*/
|
|
export interface BlobMetadata {
|
|
/** SHA-256 content hash (the blob's identity). */
|
|
hash: string
|
|
/** Original (uncompressed) size in bytes. */
|
|
size: number
|
|
/** Stored size in bytes (after compression, if any). */
|
|
compressedSize: number
|
|
/** Compression applied to the stored bytes. */
|
|
compression: 'none' | 'zstd'
|
|
/** Creation timestamp (epoch ms). */
|
|
createdAt: number
|
|
/** Number of logical references to this blob (deduplicated writes). */
|
|
refCount: number
|
|
}
|
|
|
|
/**
|
|
* @description Options for {@link BlobStorage.write}.
|
|
*/
|
|
export interface BlobWriteOptions {
|
|
/**
|
|
* Compression strategy. `'auto'` (default) compresses payloads above 1 KB
|
|
* with zstd unless the MIME type says the bytes are already compressed.
|
|
* Explicit `'none'`/`'zstd'` is honoured as asserted by the caller.
|
|
*/
|
|
compression?: 'none' | 'zstd' | 'auto'
|
|
/**
|
|
* Content type of the payload (e.g. `image/jpeg`, `video/mp4`).
|
|
*
|
|
* When set on an `auto`-compression write, the store skips zstd for MIME
|
|
* types that are already heavily compressed (JPEG, PNG, WebP, MP4, WebM,
|
|
* MP3, ZIP, PDF, etc.). zstd over these formats wastes CPU and rarely
|
|
* shaves more than a single-digit percent — usually it actually grows the
|
|
* payload because the entropy is already maximised by the format itself.
|
|
*
|
|
* Has no effect when `compression` is `'none'` or `'zstd'` explicitly —
|
|
* the caller is asserting the choice and the store honours it.
|
|
*/
|
|
mimeType?: string
|
|
}
|
|
|
|
/**
|
|
* MIME types whose payload is already heavily compressed. zstd over these is
|
|
* almost always a CPU-only loss — the bytes are already near entropy-maximal,
|
|
* so the output is the same size or slightly larger plus the cost of running
|
|
* the compressor. Used by `BlobStorage.selectCompression()` in `auto` mode.
|
|
*
|
|
* Conservative denylist (well-known formats only). Anything not in this set
|
|
* goes through the size heuristic. False negatives (compressing something we
|
|
* should have skipped) waste CPU; false positives (skipping something we
|
|
* could have compressed) waste a few percent of bytes. The denylist favours
|
|
* CPU-cycle safety because the formats listed here are the ones where
|
|
* gzip/zstd is reliably a net loss.
|
|
*/
|
|
const ALREADY_COMPRESSED_MIME_TYPES = new Set<string>([
|
|
// Images
|
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
|
'image/avif', 'image/heic', 'image/heif', 'image/jp2',
|
|
// Video
|
|
'video/mp4', 'video/webm', 'video/x-matroska', 'video/quicktime',
|
|
'video/x-msvideo', 'video/mpeg', 'video/3gpp', 'video/x-ms-wmv',
|
|
// Audio
|
|
'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/webm',
|
|
'audio/opus', 'audio/flac', 'audio/x-ms-wma',
|
|
// Archives
|
|
'application/zip', 'application/gzip', 'application/x-gzip',
|
|
'application/x-bzip2', 'application/x-7z-compressed',
|
|
'application/x-rar-compressed', 'application/x-xz', 'application/x-zstd',
|
|
'application/x-compress', 'application/vnd.rar',
|
|
// Documents with internal compression
|
|
'application/pdf', 'application/epub+zip',
|
|
// Office formats (zip-based)
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.oasis.opendocument.text',
|
|
'application/vnd.oasis.opendocument.spreadsheet',
|
|
'application/vnd.oasis.opendocument.presentation'
|
|
])
|
|
|
|
/**
|
|
* @description True when the MIME type names a payload format known to be
|
|
* already heavily compressed. Strips any `;charset=…` / `;boundary=…`
|
|
* parameters and lowercases the bare type/subtype before lookup, so
|
|
* `'IMAGE/JPEG; charset=binary'` and `'image/jpeg'` resolve identically.
|
|
* @param mimeType - MIME type string, or `undefined`.
|
|
* @returns Whether `auto` compression should skip zstd for this payload.
|
|
*/
|
|
export function isAlreadyCompressedMimeType(mimeType: string | undefined): boolean {
|
|
if (!mimeType) return false
|
|
const bare = mimeType.split(';', 1)[0].trim().toLowerCase()
|
|
return ALREADY_COMPRESSED_MIME_TYPES.has(bare)
|
|
}
|
|
|
|
/**
|
|
* LRU cache entry.
|
|
*/
|
|
interface CacheEntry {
|
|
data: Buffer
|
|
metadata: BlobMetadata
|
|
lastAccess: number
|
|
size: number
|
|
}
|
|
|
|
/**
|
|
* @description Content-addressed, deduplicating, reference-counted blob
|
|
* store with MIME-aware zstd compression and an LRU read cache. See the
|
|
* module doc for the persistence contract.
|
|
*
|
|
* @example
|
|
* const hash = await blobStorage.write(buffer, { mimeType: 'image/png' })
|
|
* const bytes = await blobStorage.read(hash) // verified against the hash
|
|
* await blobStorage.delete(hash) // decrements refCount first
|
|
*/
|
|
export class BlobStorage {
|
|
private adapter: BlobStoreAdapter
|
|
private cache: Map<string, CacheEntry>
|
|
private cacheMaxSize: number
|
|
private currentCacheSize: number
|
|
|
|
// Compression (lazily loaded)
|
|
private zstdCompress?: (data: Buffer) => Promise<Buffer>
|
|
private zstdDecompress?: (data: Buffer) => Promise<Buffer>
|
|
private compressionReady = false
|
|
|
|
// Configuration
|
|
private readonly CACHE_MAX_SIZE = 100 * 1024 * 1024 // 100MB default
|
|
private readonly COMPRESSION_THRESHOLD = 1024 // 1KB - don't compress smaller
|
|
|
|
/**
|
|
* Per-hash write serialization. Every reference-count-bearing mutation
|
|
* (`write`'s dedup check-then-act, `delete`'s decrement-then-maybe-remove)
|
|
* is a read-modify-write over `blob-meta:<hash>` — unserialized, two
|
|
* concurrent writes of identical content both saw "absent" and both wrote
|
|
* `refCount: 1` (one reference lost → a later delete removed bytes another
|
|
* file still referenced), and concurrent increments/decrements could drop
|
|
* counts. Keyed by hash, so distinct content never contends; the process
|
|
* is the whole concurrency domain (storage enforces single-writer per
|
|
* directory).
|
|
*/
|
|
private readonly hashLocks = new InMemoryMutex()
|
|
|
|
/**
|
|
* @param adapter - Key-value bridge to persist through.
|
|
* @param options - `cacheMaxSize` bounds the LRU read cache (bytes,
|
|
* default 100 MB).
|
|
*/
|
|
constructor(adapter: BlobStoreAdapter, options?: { cacheMaxSize?: number }) {
|
|
this.adapter = adapter
|
|
this.cache = new Map()
|
|
this.cacheMaxSize = options?.cacheMaxSize ?? this.CACHE_MAX_SIZE
|
|
this.currentCacheSize = 0
|
|
}
|
|
|
|
/**
|
|
* Lazy-load the zstd compression module. Falls back to uncompressed
|
|
* storage when the optional dependency is unavailable.
|
|
*/
|
|
private async ensureCompressionReady(): Promise<void> {
|
|
if (this.compressionReady) return
|
|
try {
|
|
// Dynamic import to avoid loading if not needed
|
|
// @ts-ignore - Optional dependency, gracefully handled if missing
|
|
const zstd = await import('@mongodb-js/zstd')
|
|
this.zstdCompress = async (data: Buffer) => {
|
|
return Buffer.from(await zstd.compress(data, 3)) // Level 3 = fast
|
|
}
|
|
this.zstdDecompress = async (data: Buffer) => {
|
|
return Buffer.from(await zstd.decompress(data))
|
|
}
|
|
} catch (error) {
|
|
console.warn('zstd compression not available, falling back to uncompressed')
|
|
this.zstdCompress = undefined
|
|
this.zstdDecompress = undefined
|
|
}
|
|
this.compressionReady = true
|
|
}
|
|
|
|
/**
|
|
* @description Compute the SHA-256 content hash of `data`.
|
|
* @param data - Bytes to hash.
|
|
* @returns Hex-encoded SHA-256 hash.
|
|
*/
|
|
static hash(data: Buffer): string {
|
|
return createHash('sha256').update(data).digest('hex')
|
|
}
|
|
|
|
/**
|
|
* @description Write a blob. Content-addressed: the SHA-256 hash of the
|
|
* bytes is the storage key, so identical payloads deduplicate (the
|
|
* existing blob's reference count is incremented instead of rewriting).
|
|
*
|
|
* @param data - Blob bytes.
|
|
* @param options - Compression strategy and MIME hint (see
|
|
* {@link BlobWriteOptions}).
|
|
* @returns The blob's SHA-256 hash.
|
|
*/
|
|
async write(data: Buffer, options: BlobWriteOptions = {}): Promise<string> {
|
|
const hash = BlobStorage.hash(data)
|
|
|
|
// The dedup decision (exists → add a reference; absent → create with
|
|
// refCount 1) is check-then-act over the same metadata a concurrent
|
|
// same-content write mutates — serialized per hash so N concurrent
|
|
// writes of identical content yield exactly N references, never a lost
|
|
// count (a lost reference turns a later delete into premature removal
|
|
// of bytes another file still needs).
|
|
return this.hashLocks.runExclusive(hash, async () => {
|
|
// Deduplication: identical content already stored — just add a reference.
|
|
if (await this.has(hash)) {
|
|
await this.incrementRefCount(hash)
|
|
return hash
|
|
}
|
|
|
|
await this.ensureCompressionReady()
|
|
|
|
// Determine compression strategy
|
|
const compression = this.selectCompression(data, options)
|
|
|
|
// Compress if needed
|
|
let finalData = data
|
|
let compressedSize = data.length
|
|
|
|
if (compression === 'zstd' && this.zstdCompress) {
|
|
finalData = await this.zstdCompress(data)
|
|
compressedSize = finalData.length
|
|
}
|
|
|
|
// Record the ACTUAL compression state, not the intended one — prevents
|
|
// corruption if compression failed to initialize.
|
|
const actualCompression = finalData === data ? 'none' : compression
|
|
const metadata: BlobMetadata = {
|
|
hash,
|
|
size: data.length,
|
|
compressedSize,
|
|
compression: actualCompression,
|
|
createdAt: Date.now(),
|
|
refCount: 1
|
|
}
|
|
|
|
await this.adapter.put(`blob:${hash}`, finalData)
|
|
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
|
|
|
|
// Write-through cache (caches the ORIGINAL bytes, not the compressed form)
|
|
this.addToCache(hash, data, metadata)
|
|
|
|
return hash
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @description Read a blob: LRU cache first, then storage with
|
|
* decompression and integrity verification (the bytes are re-hashed and
|
|
* compared against the requested hash).
|
|
*
|
|
* @param hash - The blob's SHA-256 hash.
|
|
* @returns The original (decompressed) blob bytes.
|
|
* @throws Error when the blob is missing or fails integrity verification.
|
|
*/
|
|
async read(hash: string): Promise<Buffer> {
|
|
// Check cache first
|
|
const cached = this.getFromCache(hash)
|
|
if (cached) {
|
|
return cached.data
|
|
}
|
|
|
|
const metadataBuffer = await this.adapter.get(`blob-meta:${hash}`)
|
|
if (!metadataBuffer) {
|
|
throw new Error(`Blob metadata not found: ${hash}`)
|
|
}
|
|
// Unwrap before parsing (defense-in-depth): metadata should come back as
|
|
// JSON bytes, but an adapter might return the wrapped binary format.
|
|
const metadata: BlobMetadata = JSON.parse(unwrapBinaryData(metadataBuffer).toString())
|
|
|
|
const data = await this.adapter.get(`blob:${hash}`)
|
|
if (!data) {
|
|
throw new Error(`Blob not found: ${hash}`)
|
|
}
|
|
|
|
// Decompress if needed
|
|
let finalData = data
|
|
if (metadata.compression === 'zstd') {
|
|
if (!this.zstdDecompress) {
|
|
await this.ensureCompressionReady()
|
|
}
|
|
if (!this.zstdDecompress) {
|
|
throw new Error('zstd decompression not available')
|
|
}
|
|
finalData = await this.zstdDecompress(data)
|
|
}
|
|
|
|
// Defense-in-depth unwrap: even though the bridge unwraps, verify it
|
|
// happened and re-unwrap if needed. Hash verification must run on the
|
|
// original content bytes.
|
|
const unwrappedData = unwrapBinaryData(finalData)
|
|
|
|
// Integrity verification (always on — a content-addressed store that
|
|
// returns bytes not matching the address is corruption, not a result)
|
|
if (BlobStorage.hash(unwrappedData) !== hash) {
|
|
throw new Error(`Blob integrity check failed: ${hash}`)
|
|
}
|
|
|
|
this.addToCache(hash, unwrappedData, metadata)
|
|
|
|
return unwrappedData
|
|
}
|
|
|
|
/**
|
|
* @description Whether a blob with this hash exists (cache or storage).
|
|
* @param hash - The blob's SHA-256 hash.
|
|
* @returns True when the blob exists.
|
|
*/
|
|
async has(hash: string): Promise<boolean> {
|
|
if (this.cache.has(hash)) {
|
|
return true
|
|
}
|
|
const exists = await this.adapter.get(`blob:${hash}`)
|
|
return exists !== undefined
|
|
}
|
|
|
|
/**
|
|
* @description Drop one reference to the blob. The stored bytes and
|
|
* metadata are physically deleted only when the reference count reaches
|
|
* zero — deduplicated content shared by other writers survives.
|
|
*
|
|
* @param hash - The blob's SHA-256 hash.
|
|
*/
|
|
async delete(hash: string): Promise<void> {
|
|
// Decrement-then-maybe-remove must be atomic per hash: without the lock,
|
|
// a concurrent write() could re-reference the content between our
|
|
// reaching zero and the physical delete — removing bytes a live
|
|
// reference still needs.
|
|
await this.hashLocks.runExclusive(hash, async () => {
|
|
const refCount = await this.decrementRefCount(hash)
|
|
|
|
// Only delete if no references remain
|
|
if (refCount > 0) {
|
|
return
|
|
}
|
|
|
|
await this.adapter.delete(`blob:${hash}`)
|
|
await this.adapter.delete(`blob-meta:${hash}`)
|
|
this.removeFromCache(hash)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @description Read a blob's metadata without reading its bytes.
|
|
* @param hash - The blob's SHA-256 hash.
|
|
* @returns The metadata, or `undefined` when the blob does not exist.
|
|
*/
|
|
async getMetadata(hash: string): Promise<BlobMetadata | undefined> {
|
|
const data = await this.adapter.get(`blob-meta:${hash}`)
|
|
if (data) {
|
|
return JSON.parse(unwrapBinaryData(data).toString())
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// ========== PRIVATE METHODS ==========
|
|
|
|
/**
|
|
* Select the compression strategy for a write (see
|
|
* {@link BlobWriteOptions.compression}).
|
|
*/
|
|
private selectCompression(
|
|
data: Buffer,
|
|
options: BlobWriteOptions
|
|
): 'none' | 'zstd' {
|
|
if (options.compression === 'none') {
|
|
return 'none'
|
|
}
|
|
|
|
if (options.compression === 'zstd') {
|
|
return this.zstdCompress ? 'zstd' : 'none'
|
|
}
|
|
|
|
// Auto mode
|
|
if (data.length < this.COMPRESSION_THRESHOLD) {
|
|
return 'none' // Too small to benefit
|
|
}
|
|
|
|
// Content-type policy: skip already-compressed media. zstd over
|
|
// JPEG / MP4 / ZIP etc. is a CPU loss for no measurable byte savings,
|
|
// and on hot save paths (image / video uploads) it's the difference
|
|
// between fast and slow. Applies only to `auto`; explicit `'zstd'` is
|
|
// honoured because the caller is asserting the choice.
|
|
if (isAlreadyCompressedMimeType(options.mimeType)) {
|
|
return 'none'
|
|
}
|
|
|
|
return this.zstdCompress ? 'zstd' : 'none'
|
|
}
|
|
|
|
/**
|
|
* Increment the reference count for an existing blob.
|
|
* Caller MUST hold the per-hash lock ({@link hashLocks}) — this is a raw
|
|
* read-modify-write with no serialization of its own.
|
|
*/
|
|
private async incrementRefCount(hash: string): Promise<number> {
|
|
const metadata = await this.getMetadata(hash)
|
|
if (!metadata) {
|
|
throw new Error(`Cannot increment ref count, blob not found: ${hash}`)
|
|
}
|
|
|
|
metadata.refCount++
|
|
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
|
|
return metadata.refCount
|
|
}
|
|
|
|
/**
|
|
* Decrement the reference count for a blob (floored at zero).
|
|
* Caller MUST hold the per-hash lock ({@link hashLocks}) — this is a raw
|
|
* read-modify-write with no serialization of its own.
|
|
*/
|
|
private async decrementRefCount(hash: string): Promise<number> {
|
|
const metadata = await this.getMetadata(hash)
|
|
if (!metadata) {
|
|
return 0
|
|
}
|
|
|
|
metadata.refCount = Math.max(0, metadata.refCount - 1)
|
|
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
|
|
return metadata.refCount
|
|
}
|
|
|
|
/**
|
|
* Add a blob to the LRU cache (evicting least-recently-used entries to
|
|
* stay under the size bound).
|
|
*/
|
|
private addToCache(hash: string, data: Buffer, metadata: BlobMetadata): void {
|
|
if (data.length > this.cacheMaxSize) {
|
|
return // Blob too large for cache
|
|
}
|
|
|
|
while (
|
|
this.currentCacheSize + data.length > this.cacheMaxSize &&
|
|
this.cache.size > 0
|
|
) {
|
|
this.evictLRU()
|
|
}
|
|
|
|
this.cache.set(hash, {
|
|
data,
|
|
metadata,
|
|
lastAccess: Date.now(),
|
|
size: data.length
|
|
})
|
|
|
|
this.currentCacheSize += data.length
|
|
}
|
|
|
|
/**
|
|
* Get a blob from the cache, refreshing its LRU position.
|
|
*/
|
|
private getFromCache(hash: string): CacheEntry | undefined {
|
|
const entry = this.cache.get(hash)
|
|
if (entry) {
|
|
entry.lastAccess = Date.now() // Update LRU
|
|
}
|
|
return entry
|
|
}
|
|
|
|
/**
|
|
* Remove a blob from the cache.
|
|
*/
|
|
private removeFromCache(hash: string): void {
|
|
const entry = this.cache.get(hash)
|
|
if (entry) {
|
|
this.cache.delete(hash)
|
|
this.currentCacheSize -= entry.size
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evict the least-recently-used cache entry.
|
|
*/
|
|
private evictLRU(): void {
|
|
let oldestHash: string | null = null
|
|
let oldestTime = Infinity
|
|
|
|
for (const [hash, entry] of this.cache.entries()) {
|
|
if (entry.lastAccess < oldestTime) {
|
|
oldestTime = entry.lastAccess
|
|
oldestHash = hash
|
|
}
|
|
}
|
|
|
|
if (oldestHash) {
|
|
this.removeFromCache(oldestHash)
|
|
}
|
|
}
|
|
}
|