remote-caching
GitHub解析远程MCAP文件读取的HTTP层缓存实现,涵盖连接管理、LRU内存块缓存及流式读取流水线,用于优化大文件访问性能。
Trigger Scenarios
Install
npx skills add lichtblick-suite/lichtblick --skill remote-caching -g -y
SKILL.md
Frontmatter
{
"name": "remote-caching",
"description": "Deep implementation details of HTTP-layer caching for remote file access: CachedFilelike, VirtualLRUBuffer, connection management algorithm, BrowserHttpReader, FetchReader streaming, and RequestQueue concurrency control."
}
Remote Caching Skill
Full Pipeline (Remote MCAP)
BrowserHttpReader (fetch + Range headers)
│
▼
FetchReader (Streams API → EventEmitter: data/error/end)
│
▼
CachedFilelike (LRU block cache via VirtualLRUBuffer)
│
▼
BatchingReadable (coalesces nearby read() calls within a microtask tick)
│
▼
RemoteFileReadable (IReadable adapter: size(), read(offset, size))
│
▼ ┌─── Worker boundary ───┐
McapIndexedReader (footer → summary → chunk index) │
│ │
▼ │
McapIndexedIterableSource (messageIterator, getBackfillMessages)
│ │
└───────────────────────────────────────────────────────┘
│
▼
BufferedIterableSource (10s read-ahead, 300MB max, producer-consumer)
│
▼
DeserializingIterableSource (lazy deserialization)
│
▼
IterablePlayer (tick loop, state machine)
CachedFilelike
Source: packages/suite-base/src/util/CachedFilelike.ts
Purpose
Provides in-memory LRU caching for streaming file reads. Sits between BrowserHttpReader (network) and RemoteFileReadable (MCAP reader). Manages a single HTTP connection at a time and intelligently decides when to open new connections.
Constants
| Constant | Value | Purpose |
|---|---|---|
CACHE_BLOCK_SIZE |
10 MiB | VirtualLRUBuffer block granularity |
CLOSE_ENOUGH_BYTES_TO_NOT_START_NEW_CONNECTION |
5 MiB | Don't interrupt current download if it's within 5MB of the needed byte |
LOGGING_INTERVAL_IN_BYTES |
300 MiB | Progress log frequency |
Default cacheSizeInBytes (RemoteFileReadable) |
500 MiB | Total in-memory cache budget |
Architecture
class CachedFilelike {
#fileReader: FileReader; // BrowserHttpReader instance
#cacheSizeInBytes: number; // Max memory (default: Infinity, RemoteFileReadable sets 500MB)
#virtualBuffer: VirtualLRUBuffer; // Block-based LRU memory
#currentConnection?: { stream, remainingRange }; // Single active HTTP stream
#readRequests: { range, resolve, reject }[]; // Pending read queue
#lastResolvedCallbackEnd?: number; // Read-ahead hint
}
Read Flow
read(offset, length)→ queues areadRequestwith range and promise#updateState()fires:- Resolves any read requests whose data is already cached (
virtualBuffer.hasData()) - Calls
getNewConnection()to decide if a new HTTP stream is needed
- Resolves any read requests whose data is already cached (
- If new connection needed →
#setConnection(range):- Destroys previous stream
- Opens
fileReader.fetch(start, length)→ streamingFetchReader - On
datachunks: copies intoVirtualLRUBuffer, updatesremainingRange.start - After each chunk: calls
#updateState()to resolve newly-satisfiable reads
Error Handling
- With
keepReconnectingCallback: unlimited retries, callback notified of reconnection state - Without callback: two errors within 100ms → fatal via
#closeWithError()(destroys the active stream, rejects all pending#readRequests, cancels all#activeUncachedReads, resets#virtualBufferto an emptyVirtualLRUBuffer, and closes) - Single error: destroys stream, clears connection, calls
#updateState()to retry close()also funnels through#closeWithError()so explicit close and fatal shutdown share the same cleanup path
VirtualLRUBuffer Initialization
if (cacheSizeInBytes >= fileSize) {
// Single block covering entire file (no eviction needed)
new VirtualLRUBuffer({ size: fileSize });
} else {
// Multiple 10MB blocks with LRU eviction
new VirtualLRUBuffer({
size: fileSize,
blockSize: CACHE_BLOCK_SIZE, // 10MB
numberOfBlocks: Math.ceil(cacheSizeInBytes / CACHE_BLOCK_SIZE) + 2,
});
}
VirtualLRUBuffer
Source: packages/suite-base/src/util/VirtualLRUBuffer.ts
Purpose
Represents an entire file in memory using fixed-size blocks, but only keeps numberOfBlocks blocks allocated at any time. Evicts least-recently-used blocks to stay within budget.
Key Properties
byteLength: total file size this buffer represents#blockSize: bytes per block (default ~1GiB, CachedFilelike uses 10MiB)#numberOfBlocks: max concurrent blocks (Infinity = no eviction)#lastAccessedBlockIndices: LRU order array (tail = most recent)#rangesWithData: simplified range array tracking which byte ranges have valid data
Operations
| Method | Description |
|---|---|
hasData(start, end) |
Returns true if entire range is cached (backed by isRangeCoveredByRanges) |
slice(start, end) |
Returns Uint8Array — efficient single-block slice or multi-block copy |
copyFrom(source, targetStart) |
Writes data, triggers block allocation/eviction |
getRangesWithData() |
Returns minimal list of cached ranges (for getNewConnection) |
Eviction Algorithm
copyFrom()calls#getBlock(index)for each block the data spans#getBlock(index):- If block doesn't exist → allocate new
Uint8Array(blockSize) - Move
indexto end of#lastAccessedBlockIndices(mark as most recently used) - If
#lastAccessedBlockIndices.length > #numberOfBlocks:shift()the least-recently-used indexdelete #blocks[deleteIndex](allows GC)- Remove evicted block's range from
#rangesWithDatavia interval subtraction
- If block doesn't exist → allocate new
Performance Notes
- When all data fits in one block:
slice()returns a view (no copy) - Multi-block
slice()requires copying into a new buffer intervals-fnlibrary used for range algebra (simplify,unify,substract)
getNewConnection Algorithm
Source: packages/suite-base/src/util/getNewConnection.ts
Purpose
Determines whether CachedFilelike should open a new HTTP connection and what byte range to request. Called every time state changes (data received, read resolved, connection closed).
Constants
| Constant | Value | Purpose |
|---|---|---|
READ_AHEAD_BUFFER_SIZE |
50 MiB | How far ahead to proactively download |
Decision Logic
Case 1: Active read request exists
1. Compute notDownloadedRanges = missingRanges(readRequest, downloadedRanges)
2. Start new connection if:
a. No current connection exists, OR
b. Current connection doesn't overlap with needed ranges, OR
c. Current connection is >5MB away from first needed byte
3. If cache ≥ fileSize: download from first gap to next downloaded range
4. If downloading to end of request: read-ahead up to 50MB from request start
5. Otherwise: download first missing range
Case 2: No read request, no connection (proactive read-ahead)
1. If cache ≥ fileSize: try to download entire file (prefer after lastResolvedCallbackEnd)
2. If cache < fileSize: download 50MB starting from lastResolvedCallbackEnd
3. Only download ranges not already cached (via missingRanges)
Case 3: Active connection, no read request
- No action needed — let the current connection continue
Key Insight
The algorithm prioritizes sequential reads — after resolving a read request, it proactively fills the 50MB following that request. This matches MCAP's sequential chunk access pattern during playback.
BrowserHttpReader
Source: packages/suite-base/src/util/BrowserHttpReader.ts
open() — File Discovery
- Makes a full GET request with
cache: "no-store"(forces fresh response) - Immediately aborts the request (only needs headers)
- Validates
Accept-Ranges: bytesheader (required for random access) - Extracts
Content-Lengthfor file size - Returns
{ size, identifier }where identifier isETagorLast-Modified
Why GET instead of HEAD?
- S3 presigned URLs often only permit GET
- Avoids CORS issues with
Content-Rangeexposure
fetch(offset, length) — Range Request
const headers = new Headers({ range: `bytes=${offset}-${offset + length - 1}` });
const reader = new FetchReader(url, { headers });
reader.read();
return reader; // FileStream interface
CORS Requirements (browser)
Access-Control-Allow-Originmust be setAccess-Control-Expose-Headersmust includeAccept-Ranges- Server must support
Rangerequest header
FetchReader
Source: packages/suite-base/src/util/FetchReader.ts
Purpose
Wraps the Fetch/Streams API into an EventEmitter pattern (data, error, end) compatible with CachedFilelike's FileStream interface.
Architecture
class FetchReader extends EventEmitter<{ data, error, end }> {
#response: Promise<Response>; // Queued through globalRequestQueue
#reader?: ReadableStreamDefaultReader<Uint8Array>;
#controller: AbortController; // For cancellation
}
Read Loop
read() → getReader() → reader.read() → emit("data", chunk) → read() [recursive]
→ if done: emit("end")
→ on error: emit("error") unless aborted
Cancellation
destroy()sets#aborted = trueand calls#controller.abort()- If stream read rejects due to abort → emits
"end"(graceful) - CachedFilelike calls
destroy()when switching connections
RequestQueue
Source: packages/suite-base/src/util/RequestQueue.ts
Purpose
Global concurrency limiter for HTTP fetch requests. Prevents overwhelming the browser's connection pool or the server.
Configuration
const GLOBAL_REQUEST_QUEUE_MAX_CONCURRENT = 10; // from constants.ts
export const globalRequestQueue = new RequestQueue(GLOBAL_REQUEST_QUEUE_MAX_CONCURRENT);
Mechanism
run(fn): ifactiveCount < maxConcurrent, executes immediately- Otherwise: queues a resolver; when a slot frees, the next queued function is unblocked
- FIFO ordering for fairness
Impact on Remote Playback
- Each
FetchReaderconstruction goes through this queue - Multi-file sources (N files) won't exceed 10 simultaneous HTTP requests even during parallel initialization
- Prevents browser from queueing requests at the TCP level (which has less visibility)
RemoteFileReadable
Source: packages/suite-base/src/players/IterablePlayer/Mcap/RemoteFileReadable.ts
Purpose
Thin adapter bridging CachedFilelike (byte-offset Filelike API) to McapTypes.IReadable (bigint offset/size API).
const DEFAULT_CACHE_SIZE_BYTES = 1024 * 1024 * 500; // 500MiB
class RemoteFileReadable {
#remoteReader: CachedFilelike; // Cache size configurable, defaults to 500MiB
#batchingReadable: BatchingReadable; // Coalesces reads before CachedFilelike
constructor(url: string, options?: { cacheSizeInBytes?: number; readAheadEnabled?: boolean }) {
const fileReader = new BrowserHttpReader(url);
this.#remoteReader = new CachedFilelike({
fileReader,
cacheSizeInBytes: options?.cacheSizeInBytes ?? DEFAULT_CACHE_SIZE_BYTES,
readAheadEnabled: options?.readAheadEnabled,
});
const inner = {
size: async () => BigInt(this.#remoteReader.size()),
read: async (offset, size) => this.#remoteReader.read(Number(offset), Number(size)),
};
this.#batchingReadable = new BatchingReadable(inner);
}
async size(): Promise<bigint> { return BigInt(this.#remoteReader.size()); }
async read(offset: bigint, size: bigint): Promise<Uint8Array> {
return await this.#batchingReadable.read(offset, size); // → coalesced → CachedFilelike
}
}
BatchingReadable
Source: packages/suite-base/src/players/IterablePlayer/Mcap/BatchingReadable.ts
Purpose
Coalescing layer between McapIndexedReader and CachedFilelike. Accumulates read() calls that arrive within the same microtask tick, sorts them by offset, and merges those whose gap is < 64 KiB (up to a 4 MiB merged span) into a single underlying read — cutting HTTP Range requests for MCAP files with many small chunks.
Notes
- Single-member groups are forwarded zero-copy; multi-member groups are sliced (copied) per request so a small result does not pin the full merged buffer in memory.
- Only coalesces reads that are concurrently pending in the same tick.
McapIndexedReaderissues reads strictly sequentially (each awaited before the next), so real coalescing depends on concurrent access — validate request-count reduction empirically for a given workload.
HydratedSourcePool
Source: packages/suite-base/src/players/IterablePlayer/shared/HydratedSourcePool.ts
Purpose
Bounds resident heavyweight per-file reader objects (for example McapIndexedReader instances with chunk indexes, channel schemas, and deserializers) using a hybrid count + byte budget. This is a separate layer from CachedFilelike: it manages parsed reader objects, not raw downloaded file bytes and not decoded message payloads.
Key Properties
- Constructor options are all optional:
HydratedSourcePoolOptions = { maxBytes?, maxCount?, minResident? } maxCountis normalized toMath.max(1, Math.floor(...)), orInfinitywhen unsetmaxBytesdefaults toInfinityminResidentdefaults to1, then clamps toMath.min(maxCount, Math.max(1, Math.floor(...)))- Internal state is a
Map<object, Entry>where:token= caller-owned identity object for one sourceEntry = { hydrator, value: Promise<unknown>, pins: number, weight: number }
- JavaScript
Mappreserves insertion order. Deleting and re-setting an entry on access refreshes recency, so iteration order is LRU order (first entry = least recently used).
Architecture
type SourceHydrator<T> = {
open: () => Promise<T>;
close: (value: T) => Promise<void>;
weigh?: (value: T) => number;
};
type Entry = {
hydrator: SourceHydrator<unknown>;
value: Promise<unknown>;
pins: number;
weight: number;
};
class HydratedSourcePool {
#entries: Map<object, Entry>; // insertion order = LRU order
#totalWeight: number; // sum of resident entry weights
#terminated: boolean;
}
Operations
| Method | Description |
|---|---|
acquire(token, hydrator) |
Returns a resident value, opening it on demand and pinning it while in use |
release(token) |
Decrements the pin count (never below 0) and opportunistically triggers eviction |
admit(token, hydrator, value) |
Seeds the pool with an already-open value, usually from a source's own initialization path |
terminate() |
Prevents future admission/hydration, clears the pool, and closes every resident value |
acquire() / release() lifecycle
acquire(token, hydrator)checks#terminatedfirst and immediately throws"HydratedSourcePool has been terminated"when shutdown has started.- If the token is already resident:
- delete + re-set the
Mapentry to refresh LRU position - increment
pins - await and return the cached
valuepromise - if that promise rejects, roll back the pin increment and rethrow so a co-pending failed
open()does not leak a phantom pin
- delete + re-set the
- If the token is not resident:
- insert a new entry with
pins: 1,value: hydrator.open(),weight: 0 - await the value
- compute
weight = Math.max(0, hydrator.weigh?.(value) ?? 1) - add the weight to
#totalWeight - run eviction and return the value
- insert a new entry with
- If a new entry's hydration rejects:
- decrement
pins - only delete the map entry when
this.#entries.get(token) === entry - this identity guard prevents a late rejection from deleting a newer entry recreated for the same token by another caller
- decrement
release(token)decrementspinswhen the entry still exists, then fires-and-forgets#evictBeyondCapacity()so newly unpinned entries can be reclaimed.
admit() and terminate()
admit(token, hydrator, value)lets callers seed the pool with an already-hydrated value, avoiding a redundantopen()call.- If the pool is already terminated,
admit()immediately closes the supplied value instead of retaining it. - If the token is already resident, the pool keeps the existing entry, refreshes its LRU position, and closes the redundant newly supplied value.
- Otherwise it inserts the value as an unpinned entry (
pins: 0), computes weight, updates#totalWeight, and runs eviction. A newly admitted value may be evicted immediately if the pool is already over budget. terminate()sets#terminated = truebefore clearing the map so concurrent or lateracquire()/admit()calls cannot repopulate the pool during shutdown.- Shutdown snapshots the entries, clears the map, resets
#totalWeightto 0, and closes every resolved value in parallel. Each close is individually guarded so one failingclose()does not stop the rest.
Eviction Algorithm
#isOverCapacity() returns:
falsewhenentries.size <= minResident- otherwise
truewhen either:entries.size > maxCount, or#totalWeight > maxBytes
#evictBeyondCapacity() then:
- Loops while
#isOverCapacity()remains true - Scans current
Mapiteration order (LRU order) - Picks the first entry with
pins === 0 - Deletes it from the map before awaiting
close()so concurrent eviction passes cannot target it twice - Subtracts its weight from
#totalWeight - Awaits
hydrator.close(value)and logs errors instead of throwing
If every remaining entry is pinned, eviction stops early. The pool may temporarily remain over maxCount and/or maxBytes; that is intentional because pinned entries are actively in use and cannot be evicted.
Termination Semantics
#terminatedis a hard gate, not just a best-effort hintacquire()after termination throws immediatelyadmit()after termination discards and closes the supplied value immediately- Because the flag is set before the map is cleared, concurrent shutdown cannot race with a new resident entry being retained after
terminate()
readerWeight.ts / estimateReaderWeightBytes
Source: packages/suite-base/src/players/IterablePlayer/Mcap/readerWeight.ts
export const READER_BASE_BYTES = 2 * 1024 * 1024; // fixed reader/deserializer overhead
const BYTES_PER_CHUNK_INDEX_BASE = 128; // fixed scalar fields of one ChunkIndex
const BYTES_PER_MESSAGE_INDEX_ENTRY = 64; // one messageIndexOffsets entry per (chunk, channel)
const BYTES_PER_CHANNEL = 16 * 1024; // parsed schema + per-channel deserializer
export function estimateReaderWeightBytes(reader: McapIndexedReader): number {
let messageIndexEntries = 0;
for (const chunkIndex of reader.chunkIndexes) {
messageIndexEntries += chunkIndex.messageIndexOffsets.size;
}
return (
READER_BASE_BYTES +
reader.chunkIndexes.length * BYTES_PER_CHUNK_INDEX_BASE +
messageIndexEntries * BYTES_PER_MESSAGE_INDEX_ENTRY +
reader.channelsById.size * BYTES_PER_CHANNEL
);
}
This is the weigh() heuristic for pooled MCAP readers:
READER_BASE_BYTESmodels fixed parser/deserializer overhead- chunk-index count, message-index-entry count, and channel count scale the estimate with file complexity
- there is no
cacheBytesparameter — the weight reflects only reader/index/channel structure size, not each source's byte-cache allocation (for example itsCachedFilelikebudget)
Absolute values are approximate; the relative weighting is what matters. Heavier readers do not get special eviction priority directly — eviction still removes the next LRU unpinned entry — but heavier readers push the pool over maxBytes sooner, causing LRU eviction pressure earlier.
Session-Persistent Connections & Unpooled Fallback
type: "url"persistent transport: pooled indexed URL sources createRemoteFileReadableonce, stash it in#persistentReadable, and reuse that same connection + internalCachedFilelikebyte cache across every laterHydratedSourcePool.acquire()re-hydration; the source hydrator'sclose()only tears down the heavyweightMcapIndexedReader/ parsed-channel state.#persistentReadable.close()happens in three cases: indexed initialization itself fails, the source falls back to the unindexed streaming path (rawfetch()bypasses the pool/readable entirely), or the wholeMcapIterableSourceisterminate()d at normal session end (the common case for a healthy indexed source).type: "file"has no analogous persistent transport because the backingBlobis already resident.- Unindexed sources bypass the pool: if a source ends up unindexed (
chunkIndexes.length === 0,channelsById.size === 0, or the URL fallback path triggers),McapIterableSourcedoes notadmit()/acquire()it fromHydratedSourcePooleven when a pool exists; it stores the resultingMcapUnindexedIterableSourcein#eagerInnerfor the full session instead, because re-hydrating it would require replaying the whole stream/file from scratch. This still bypassesmaxHydratedSources/maxHydratedBytes(those only bound the indexed/pooled path), but it is no longer untracked:MultiIterableSourceconstructs oneEagerUnindexedGuard(shared/EagerUnindexedGuard.ts) per session and hands it to every source viaMcapSource.eagerUnindexedGuard. On the unindexed branch ofinitialize(),McapIterableSourcecallsguard.register(sizeBytes)with the raw file size /Content-Length(a cheap, best-effort proxy —McapUnindexedIterableSourceloads the whole file into memory, so encoded size roughly tracks resident footprint), andguard.unregister(sizeBytes)interminate(). The guard only tracks aggregate count/bytes across the session and logs a singlelog.warnthe first timemaxEagerUnindexedSources(default 3) ormaxEagerUnindexedBytes(default 2 GiB) is exceeded — it never evicts, blocks, or fails a source open, since that would make an otherwise-playable unindexed MCAP unplayable.
Multi-File Cache Budget Distribution
When MultiIterableSource handles multiple remote URLs:
const totalCache = dataSource.totalCacheSizeInBytes ?? 500 * 1024 * 1024; // 500MB total
const minPerSource = dataSource.minCachePerSourceBytes ?? 10 * 1024 * 1024; // 10MiB floor
const perSourceCache = Math.max(minPerSource, Math.floor(totalCache / urls.length));
// Each McapIterableSource gets perSourceCache for its RemoteFileReadable
Example: 3 remote MCAP files → each gets ~166MB cache budget.
This means:
- More files = less cache per file = more network re-fetches
MIN_CACHE_PER_SOURCE_BYTES = 10 MiBprevents multi-file sessions from slicing the total budget so small that a single MCAP summary/index read can crashCachedFilelikedataSource.minCachePerSourceBytesoverrides that floor when a caller needs a different minimumtotalCacheSizeInBytesis not a hard aggregate cap: becauseperSourceCache = Math.max(minPerSource, Math.floor(totalCache / numSources)), the per-source floor can win when there are many sources. WhenperSourceCache * numSources > totalCache, alog.warnreports it and the real aggregate cache usage exceeds the nominaltotalCacheSizeInBytesreadAheadEnabledstill defaults totruefor both single- and multi-file sessions (unlessdataSource.readAheadEnabledoverrides it); what changes forurls.length > 1isreadAheadBufferBytes, which defaults tomin(2 MiB, perSourceCache / 4)instead of the legacy 50 MiB default, bounding read-ahead so it doesn't outrun the smaller per-source cache slice- For large multi-file datasets, consider increasing
totalCacheSizeInBytes - Each file's CachedFilelike manages its own VirtualLRUBuffer independently
totalCacheSizeInBytes/perSourceCachegovern the raw byte cache for each source'sCachedFilelike/RemoteFileReadable.HydratedSourcePooladds a separate resident-reader-object budget (maxBytes/maxCount) for parsedMcapIndexedReaderinstances. Both budgets apply at the same time and solve different memory problems.
Key Files Reference
| File | Role |
|---|---|
packages/suite-base/src/util/CachedFilelike.ts |
LRU-cached streaming file reader |
packages/suite-base/src/util/VirtualLRUBuffer.ts |
Block-level LRU memory management |
packages/suite-base/src/util/getNewConnection.ts |
HTTP connection decision algorithm |
packages/suite-base/src/util/BrowserHttpReader.ts |
HTTP Range request implementation |
packages/suite-base/src/util/FetchReader.ts |
Streams API EventEmitter adapter |
packages/suite-base/src/util/RequestQueue.ts |
Global concurrency limiter (10 max) |
packages/suite-base/src/players/IterablePlayer/Mcap/RemoteFileReadable.ts |
IReadable adapter (500MB default); reads pass through BatchingReadable |
packages/suite-base/src/players/IterablePlayer/Mcap/BatchingReadable.ts |
Coalesces nearby read() calls (gap <64KiB, ≤4MiB span) into fewer inner reads |
packages/suite-base/src/players/IterablePlayer/shared/HydratedSourcePool.ts |
Resident-reader pool with LRU eviction across count and byte budgets |
packages/suite-base/src/players/IterablePlayer/shared/multiFileHydrationOptions.ts |
Shared multi-file hydration override merging, used by both data source factories and the MCAP worker |
packages/suite-base/src/players/IterablePlayer/shared/types.ts |
SourceHydrator and HydratedSourcePoolOptions type definitions |
packages/suite-base/src/players/IterablePlayer/Mcap/readerWeight.ts |
Heuristic weight estimate for pooled MCAP readers |
Version History
- 6435710 Current 2026-08-16 02:35
- cab9317 2026-07-24 12:17


