From 8036d93914a2a99c2a0fea85d1ef44daec8f5353 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 17 Jun 2026 07:58:01 +0200 Subject: [PATCH] Read chunks from cached blobs via ReadAt instead of full-blob Get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore's per-chunk loop called blobCache.Get(blobHash) and sliced the returned []byte to extract the chunk it actually wanted. Get reads the entire blob from disk into memory — so for a 10 GB blob, every chunk extraction was a 10 GB ReadFile to get back a few KB. With ~40k files needing ~600ms per cache hit, that alone was burning ~6 hours of wall-clock on a real restore. Hot loop now: - If the blob isn't cached: download (full plaintext into memory), Put to disk cache, satisfy this chunk from the in-memory buffer. - If it's cached: blobCache.ReadAt(hash, offset, length) — reads only the chunk's bytes from the on-disk blob file. ReadAt was already implemented on blobDiskCache; restore just wasn't using it. Debug timings from the user's photo-catalog restore showed ms_cache_gets dominating every cache-hit file at 500-1000ms. With ReadAt those should drop to sub-millisecond and the visible throughput should be bound by single-stream blob download + decrypt/decompress rather than disk-read amplification. --- internal/vaultik/restore.go | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index 5adaf9e..31e9ae0 100644 --- a/internal/vaultik/restore.go +++ b/internal/vaultik/restore.go @@ -569,14 +569,22 @@ func (v *Vaultik) restoreRegularFile( return fmt.Errorf("getting blob %s: %w", blobChunk.BlobID, err) } - // Download and decrypt blob if not cached + // If the blob isn't on disk yet, download it (one full readout + // of the plaintext into memory), write it to the cache, and + // satisfy THIS chunk from the in-memory copy. Subsequent files + // needing chunks from the same blob hit the ReadAt branch and + // read only the bytes they actually want — never the whole + // blob — which is the difference between sub-millisecond chunk + // extraction and ~1 s per cache hit on a 10 GB blob. blobHashStr := blob.Hash.String() + var chunkData []byte t0 = time.Now() - blobData, ok := blobCache.Get(blobHashStr) + blobInCache := blobCache.Has(blobHashStr) cacheGetDur += time.Since(t0) - if !ok { + + if !blobInCache { t0 = time.Now() - blobData, err = v.downloadBlob(ctx, blobHashStr, blob.CompressedSize, identity) + blobData, err := v.downloadBlob(ctx, blobHashStr, blob.CompressedSize, identity) downloadDur += time.Since(t0) if err != nil { return fmt.Errorf("downloading blob %s: %w", blobHashStr[:16], err) @@ -589,17 +597,22 @@ func (v *Vaultik) restoreRegularFile( downloadCount++ result.BlobsDownloaded++ result.BytesDownloaded += blob.CompressedSize + + if blobChunk.Offset+blobChunk.Length > int64(len(blobData)) { + return fmt.Errorf("chunk %s extends beyond blob data (offset=%d, length=%d, blob_size=%d)", + fc.ChunkHash[:16], blobChunk.Offset, blobChunk.Length, len(blobData)) + } + chunkData = blobData[blobChunk.Offset : blobChunk.Offset+blobChunk.Length] } else { cacheHitCount++ + t0 = time.Now() + chunkData, err = blobCache.ReadAt(blobHashStr, blobChunk.Offset, blobChunk.Length) + cacheGetDur += time.Since(t0) + if err != nil { + return fmt.Errorf("reading chunk %s from cached blob %s: %w", fc.ChunkHash[:16], blobHashStr[:16], err) + } } - // Extract chunk from blob - if blobChunk.Offset+blobChunk.Length > int64(len(blobData)) { - return fmt.Errorf("chunk %s extends beyond blob data (offset=%d, length=%d, blob_size=%d)", - fc.ChunkHash[:16], blobChunk.Offset, blobChunk.Length, len(blobData)) - } - chunkData := blobData[blobChunk.Offset : blobChunk.Offset+blobChunk.Length] - // Write chunk to output file t0 = time.Now() n, err := outFile.Write(chunkData)