Skip to content

feature: Compression for memfile/rootfs assets#2034

Open
levb wants to merge 136 commits intolev-paths-refactorfrom
lev-compression-final
Open

feature: Compression for memfile/rootfs assets#2034
levb wants to merge 136 commits intolev-paths-refactorfrom
lev-compression-final

Conversation

@levb
Copy link
Copy Markdown
Contributor

@levb levb commented Mar 2, 2026

Summary

Compression for data files (memfile, rootfs). Files are broken into independently decompressible frames (2 MiB, zstd), stored in GCS alongside V4 headers with per-mapping frame tables. Fully backward-compatible: the read path auto-detects V3/V4 headers and routes compressed vs uncompressed reads per-mapping. Gated by compress-config LaunchDarkly flag (per-team/cluster/template targeting).

What changed

  • FramedFile interface replaces Seekable — unified GetFrame(ctx, offset, frameTable, decompress, buf, readSize, onRead) handles both compressed and uncompressed data
  • V4 header with FrameTable per mapping + BuildFileInfo (uncompressed size, SHA-256 checksum) per build; LZ4-block-compressed header blob
  • NFS cache extended for compressed frames (.frm files keyed by compressed offset+size); progressive streaming decompression on cache miss; write-through on upload
  • P2P resume integration — peers read uncompressed from origin during upload, then atomically swap to V4 header (CAS) when origin signals use_storage with serialized headers
  • compress-build CLI for background compression of existing uncompressed builds (supports --recursive for dependency chains)
  • New Chunker with mmap cache, and fetch sessions dedupe replacing streaming_chunk.go

Read path

  NBD/UFFD/Prefetch
    → header.GetShiftedMapping(offset) → BuildMap + FrameTable
    → DiffStore.Get(ctx, diff)         → cached Chunker
    → Chunker.GetBlock(offset, len, ft)
        → mmap hit? return reference
        → miss: fetchSession (dedup) → GetFrame
            → NFS hit? decompress from disk → mmap
            → NFS miss? GCS range read → decompress → mmap + NFS write-back

P2P header switchover

  Origin (pause):
    snapshot → register buildID in Redis → serve mmap cache via gRPC
    background: upload compressed data + V4 headers to GCS
    on completion: uploadedBuilds.Set(buildID, serialized V4 headers)
                → peerRegistry.Unregister(buildID)

  Peer (resume, upload in progress):
    GetFrame(ft=nil) → gRPC stream → origin serves from mmap (uncompressed)

  Peer (origin signals use_storage):
    checkPeerAvailability() → transitionHeaders.Store({memH, rootH})
                            → uploaded.Store(true)
    next GetFrame(ft=nil): ft==nil + transitionHeaders != nil
      → return PeerTransitionedError{headers}
      → build.File.swapHeader(): Deserialize(bytes) → CompareAndSwap(old, new)
        first goroutine wins CAS; others see swapped header on retry
      → retry: GetFrame(ft!=nil) → NFS/GCS compressed (mmap mostly warm)

Benchmark results

End-to-end pause/resume

(BenchmarkBaseImage, 50 iterations, local disk):

  ┌──────────────┬─────────┬────────────┐
  │     Mode     │ Latency │ Build time │
  ├──────────────┼─────────┼────────────┤
  │ Uncompressed │ 97 ms   │ 61.0s      │
  ├──────────────┼─────────┼────────────┤
  │ LZ4:0        │ 100 ms  │ 61.4s      │
  ├──────────────┼─────────┼────────────┤
  │ Zstd:1       │ 100 ms  │ 60.9s      │
  ├──────────────┼─────────┼────────────┤
  │ Zstd:2       │ 102 ms  │ 62.4s      │
  ├──────────────┼─────────┼────────────┤
  │ Zstd:3       │ 98 ms   │ 61.7s      │
  └──────────────┴─────────┴────────────┘

Full architecture doc: docs/compression-architecture.md

levb and others added 18 commits February 27, 2026 05:52
…ning

- Use header.HugepageSize for uncompressed fetch alignment (semantically correct)
- Stream NFS cache hits directly into ReadFrame instead of buffering in memory
- Fix timer placement to cover full GetFrame (read + decompression)
- Fix onRead callback: nil for compressed inner calls (prevents double-invoke),
  pass through for uncompressed (bytes are final)
- Remove panic recovery from runFetch (never in main)
- Remove low-value chunker tests subsumed by ConcurrentStress
- Remove 4MB frame configs from benchmarks (targeting 2MB only)
- Remove unused readCacheFile function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ble NFS cache

- Remove dead flagsClient chain through chunker/build/template layers (~15 files)
- Delete ChunkerConfigFlag (unused after flagsClient removal)
- Delete mock_flagsclient_test.go
- Simplify GetUploadOptions: remove redundant intOr/strOr fallbacks (flags have defaults)
- Add GetCompressionType helper to frame_table.go, deduplicate compression type extraction
- Replace [16]byte{} with uuid.Nil and "rootfs.ext4" with storage.RootfsName in inspect-build
- Simplify UploadV4Header return pattern
- Remove onRead callback from legacy fullFetchChunker (FullFetch should not use progressive reads)
- Re-enable NFS cache in template cache.go
- Remove all fmt.Printf debug instrumentation from orchestrator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sionType threading

Add per-build file size and SHA-256 checksum to V4 headers, eliminating
the redundant Size() network call when opening upstream data files on
the read path. Checksums are computed for free by piggybacking on
CompressStream's existing frame iteration.

Remove the separate compressionType parameter threaded through
getBuild → newStorageDiff → NewChunker; the read path now derives
compression state from the per-mapping FrameTable directly.

V4 binary format change (not yet deployed):
  [Metadata] [LZ4: numBuilds, builds(uuid+size+checksum),
              numMappings, mappings...]

V3 path unchanged — falls back to Size() call when size is unknown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
levb and others added 6 commits March 2, 2026 10:58
- Merge writeFrameToCache and writeChunkToCache into unified writeToCache
  with lock + atomic rename, used by all three cache write paths
- Fix file descriptor leak in cache hit paths: defer f.Close() and wrap
  in NopCloser so ReadFrame's close doesn't double-close the fd
- Add defer uploader.Close() in CompressStream so PartUploader file
  handles are released on error paths between Start() and Complete()
- Make Close() idempotent via sync.Once on fsPartUploader and filePartWriter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
levb and others added 2 commits March 3, 2026 06:09
The SHA-256 checksum in BuildFileInfo now covers uncompressed data,
making it useful for end-to-end integrity verification of the original
content. Updated inspect-build to use SHA-256 (replacing MD5) and
verify checksums against the header. Fixed early-return lint warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GetUploadOptions now accepts fileType and useCase parameters, enriching
the LD evaluation context so dashboard targeting rules can differentiate
(e.g. compress memfile but not rootfs, or builds but not pauses).
TemplateBuild accepts per-file opts directly instead of holding an ff
reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
levb and others added 12 commits March 27, 2026 10:54
…LZ4/zstd wrappers

Foundation types for frame-based compression: CompressionType enum,
FrameTable (U-space↔C-space offset mapping), FrameOffset/FrameSize,
and encoder/decoder pooling for zstd and LZ4 block codecs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rtUploader interface

CompressConfig with LaunchDarkly feature flag support, compressStream
pipeline (parallel frame compression → ordered emit → concurrent upload),
and GCS multipart partUploader implementation with zero-copy slice uploads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…leInfo, and validation

V4 headers store per-mapping FrameTables and per-build file metadata
(size + SHA-256 checksum). The mappings block is LZ4-compressed with a
uint32 size prefix for exact-size decompression.

Adds: SerializeHeader, DeserializeBytes (auto-detecting V3/V4),
LoadHeader, StoreHeader, ValidateHeader, CloneForUpload, AddFrames,
mergeFrameTables, and compressed path helpers on TemplateFiles.

Existing Serialize/Deserialize/DeserializeBytes APIs preserved for
backward compatibility — signature changes deferred to read-path PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove unused OverrideJSONFlag, propagate Subset errors through
MergeMappings, align newCompressorPool signature with final, and
trim redundant/trivial tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve conflicts: keep final's behavior/API (Serialize/Deserialize,
LoadHeader, StoreHeader, show-build-diff), apply primitives cleanup
(assert→require, removed duplicate tests, deduplicated CompressConfigFlag).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… cleanup, deterministic serialization

- Switch back to LZ4 streaming API: handles incompressible data
  automatically, adds per-block xxHash32 checksums, pool encoders
  and decoders symmetrically with zstd
- Move header LZ4 compress/decompress into header package (V4 wire format)
- Always wait on uploadEG before returning (errors.Join)
- Sort BuildFiles by UUID for deterministic V4 serialization
- Remove dead decoderConcurrency key from CompressConfigFlag
- Fix CompressConfigFromLDValue ctx parameter order

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compression read/write path changes on top of the primitives merge:

  Bug fixes:
  - P2P peer transition now sends V4 headers (was sending V3 from snapshot)
  - FinalizeHeaders returns serialized bytes; StoreHeader returns ([]byte, error)
  - peerFramedFile.GetFrame requests len(buf) bytes (was requesting blockSize)

  Diff minimization:
  - Restore OpenBlob 3-arg signature to match main
  - Restore main's variable style, comments, span names throughout
  - Revert gratuitous changes: storage_fs_test.go, cache.go, template_metadata.go
  - Inline createChunker into Init (mirrors main's structure)
  - Chunker takes storagePath instead of buildID+fileType
  - Resolve CompressConfig at call site, not in LayerExecutor
  - storagePath private (matches main), remove buildFileSize verbosity

  Code quality:
  - Rename chunk_framed.go → chunk.go (only implementation)
  - Consolidate storage mocks in-package, delete mocks/ subdir
  - Move PeerTransitionedError into storage.go
  - Precomputed metrics as two plain vars (chunkerAttrs, chunkerAttrsCompressed)
  - assert→require in chunk_test.go, deterministic test data, remove unused pipelinedReader
  - Delete redundant TestDiffStoreConcurrentInitAndAccess
  - Simplify StoreFile — explicit returns instead of named return dance

  Integration tests:
  - Merge compression tests into regular suite (remove build tag + separate target)
  - Move TestLargeMemoryPauseResume into sandbox_pause_test.go
  - GHA workflow always runs with LZ4 level 0, 8 workers
  - Strip compression suffix at server (resolve.go) not client
@levb levb changed the base branch from main to lev-compression-primitives March 30, 2026 05:59
levb and others added 6 commits March 29, 2026 23:03
Integration tests already run with compression enabled (lz4, level 0,
8 workers) — the separate job passed an undefined input and broke CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses dobrac's review comments on the compression primitives PR:

  Serialization:
  - Split serialization.go into serialization_v3.go and serialization_v4.go
  - Each version's wire format is self-contained in one file
  - Move Metadata types/constants to metadata.go
  - Remove unused exported Serialize() wrapper

  V4 wire format:
  - Replace packed CompressionTypeNumFrames uint64 with separate
    CompressionType uint32 + NumFrames uint32 (no bit-shifting)
  - Remove MaxCompressedHeaderSize limit (uint32 prefix + LZ4 frame
    boundary are sufficient)

  Naming and reuse:
  - Rename AddFrames → SetFrames (replaces, not appends)
  - Use SetFrames in MergeMappings instead of inline Subset calls
  - Remove unnecessary maps.Clone in ToDiffHeader

  Formatting:
  - Use decimal (%d) instead of hex (%#x) in error messages and
    String() methods for consistency with the rest of the codebase

  Tests:
  - Add test for uploadPartSlices (MD5 hashing, body concatenation)

  Documentation:
  - Flag BuildFiles incompleteness: only contains builds from current
    upload session, not upstream dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename TemplateFiles → Paths (it maps a build ID to storage paths)
- Remove generic string-accepting methods (DataPath, HeaderPath,
  CompressedDataPath) that allowed callers to bypass constants
- Add explicit compressed path methods (MemfileCompressed,
  RootfsCompressed) for the new compression write path
- Rename free functions for consistency: ParseStoragePath → SplitPath,
  BaseFileName → StripCompression, CompressedPath → AppendCompression
- Fix manual string concatenation in template/storage.go to use Paths
- Simplify build_upload helpers to take resolved storage paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove ValidateHeader from NewHeader — avoids rejecting zero-size
  templates on deserialization and removes O(n log n) sort from the
  hot read path. ValidateHeader remains exported for CLI/diagnostic use.
- Fix O(N²) in applyToHeader: add SubsetFrom/SetFramesFrom with cursor
  so consecutive sorted mappings walk the FrameTable once instead of
  rescanning from the start for each mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@levb levb changed the base branch from lev-compression-primitives to main March 31, 2026 13:29
levb and others added 6 commits March 31, 2026 06:32
Move uncompressed (V3) uploader to build_upload_v3.go and compressed
(V4) uploader to build_upload_v4.go. Shared types, helpers, and
PendingBuildInfo remain in build_upload.go.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ingReader

Replace the monolithic FramedFile.GetFrame() callback-based API with a
composable Seekable interface backed by io.ReadCloser streams. This
eliminates the ReadFrame/readInto/readFrameDecompress plumbing from the
storage layer and pushes progressive read logic into the Chunker.

Storage interfaces:
- Remove FramedFile (GetFrame + Size + StoreFile) and RangeReadFunc
- Add Seekable (StreamingReader + SeekableWriter + Size) with
  OpenRangeReader returning io.ReadCloser for both compressed and
  uncompressed paths
- Add SeekableReader interface (ReadAt + Size) used by build.Diff
- Add NewDecompressingReader for pooled LZ4/zstd decompression
- Split compress_pool.go into compress_encode.go + compress_decode.go
- StorageProvider.OpenFramedFile → OpenSeekable throughout

Chunker (streaming_chunk.go):
- Accept StreamingReader instead of StorageProvider + storagePath
- Replace GetFrame callback with io.ReadFull loop in progressiveRead()
- Feature-flag-tunable min read batch size (MinChunkerReadSizeKB)
  replacing the removed ChunkerConfigFlag JSON flag
- Rename ReadBlock→ReadAt, SliceBlock→Slice
- Split FramedBlockReader into FramedReader + FramedSlicer interfaces

NFS cache (storage_cache_seekable.go → + storage_cache_compressed.go):
- Rewrite cachedFramedFile as cachedSeekable implementing Seekable
- Extract compressed cache path into storage_cache_compressed.go with
  TeeReader-based write-through on Close
- Uncompressed path uses write-through NFS caching with isCompleteRead
  validation

P2P / gRPC:
- Reduce diff with main to the essential: peerserver data-serving path
  is unchanged (renames only: FramedSource→SeekableSource, framed.go→
  seekable.go, SliceBlock→Slice). Proto RPC renamed GetBuildFrame →
  ReadAtBuildSeekable.
- Peerclient: replace peerFramedFile with peerSeekable, adding
  OpenRangeReader for streaming peer reads and fixing peerStreamReader
  to fill the caller's buffer across gRPC message boundaries.

Build path:
- StorageDiff.Init opens Seekable once, passes it to NewChunker
- getBuild receives CompressionType to construct the correct data path
- Diff interface now embeds SeekableReader + FramedSlicer (was
  FramedBlockReader)
- NoDiff and localDiff implement Size() for SeekableReader conformance

Tests:
- Rewrite chunk tests using fakeSeekable + controlledChunker with
  channel-based flow control (no time.Sleep)
- Add TestChunker_CacheHit, TestChunker_PanicRecovery,
  TestChunker_ConcurrentSameChunk
- Rewrite peerclient tests for peerSeekable (ReadAt, OpenRangeReader,
  header transition, upload-skips-peer)
- Regenerate mocks (mockdiff, mock_seekable, orchestrator mocks)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Minimize diff with lev-paths-refactor base branch:
- Restore SeekableObjectType parameter to OpenSeekable interface,
  implementations, mocks, and all call sites
- Restore storageHeaderObjectType/storageObjectType helpers and
  struct fields in template/storage.go and build/storage_diff.go
- Restore peerStreamReader.Read to base's one-message-per-Read
- Restore StreamingReader type assertions on gcpObject and fsObject
- Restore isCompleteRead 3-param signature with err/EOF handling
- Restore cache_seekable tests via testReadAt helper preserving
  base test names and structure
- Revert cosmetic renames (files→paths, variable names, comments)
- Restore base interface comments on SeekableReader, SeekableWriter
- Remove redundant TestLargeMemoryPauseResume integration test

Fix compression bugs:
- Fix nil FrameTable panic: move cfg.IsEnabled() check before
  small-file optimization in GCP StoreFile
- Add nil FrameTable guard in compressedUploader.UploadData

Clean up storage package:
- Replace compositeReadCloser with newDecompressingReadCloser
- Privatize internal symbols: parseCompressionType,
  newDecompressingReadCloser, compressConfigFromLDValue (inlined)
- Unify openRangeReader length param to int64 across providers
- Simplify AWS: inline OpenRangeReader, error on compressed
- Rename compressedCacheReader → decompressingCacheReader
- Use isCompleteRead consistently in both cache write-through paths
- Idiomatic error handling in decompressingCacheReader.Close

Add OTEL instrumentation to read paths:
- Span ("read") on cachedSeekable.OpenRangeReader covering full
  open-to-close lifecycle via withSpan wrapper, with offset/length/
  compressed attributes
- NFS cache read timer on both uncompressed and compressed paths
  with compressed/compression_type attributes
- Write-back span on decompressingCacheReader.Close matching
  existing span on cacheWriteThroughReader.Close
- Extract constructors: withSpan, newCacheWriteThroughReader,
  newDecompressingCacheReader — sequential wrapping, no nesting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@levb levb changed the base branch from main to lev-paths-refactor April 3, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants