Files
spinifex/docs/development/feature/snapshot.md
T
2026-02-10 16:57:50 +11:00

9.1 KiB

Viperblock Snapshot Feature

Overview

Viperblock snapshots enable instant, zero-copy point-in-time captures of block storage volumes. A snapshot freezes the block-to-object mapping at a moment in time without copying any data. Both the snapshot and the live volume share the same immutable chunk files on S3. Volumes created from snapshots use a copy-on-write (COW) layered read path, reading unmodified blocks from the source volume's chunks and writing new data to their own chunks.

This is the foundation for AMI-backed instance launches -- aws ec2 run-instances --image-id ami-ubuntu will boot a VM that reads Ubuntu blocks on demand from the AMI's frozen snapshot, with zero image copying.

Key Design Decisions

1. Snapshots are frozen block maps, not data copies

Viperblock's chunk files are immutable -- once chunk.00000001.bin is written, it never changes. New writes always produce new chunks with higher ObjectIDs. This means a snapshot only needs to capture the current BlocksToObject mapping (which block lives at which offset in which chunk file). The actual block data stays in place.

Trade-off: This makes snapshot creation O(mapping size) instead of O(data size), but it means the source volume's chunk files cannot be garbage collected while any snapshot references them.

2. Copy-on-write via layered block maps

A clone volume created from a snapshot has two block maps:

  • Own map (BlocksToObject) -- blocks written since clone creation
  • Base map (BaseBlockMap) -- frozen read-only map from the parent snapshot

The read path checks own map first, then falls back to the base map, then returns zeros:

Read block N:
  1. Own BlocksToObject / BlockStore  -> found? return data from own chunks
  2. BaseBlockMap                     -> found? return data from source volume's chunks via ReadFrom()
  3. Neither                          -> zero block (never written)

Writes always go to the clone's own chunks. Once block N is written in the clone, it shadows the snapshot's version permanently.

Trade-off: Reads of unmodified blocks require cross-volume I/O (reading from the source volume's S3 prefix). This is the same latency as a normal backend read but targets a different path. The BlockStore caches fetched base blocks so repeated reads are fast.

3. Backend interface extended with ReadFrom/WriteTo

The existing Backend.Read() and Backend.Write() methods construct S3 keys using the backend's configured volume name. Snapshots need to read/write across volume boundaries:

  • CreateSnapshot writes checkpoint and config to {snapshotID}/ prefix
  • Clone volumes read chunk data from {sourceVolumeName}/ prefix

Rather than temporarily swapping the backend's volume name (error-prone, not thread-safe), we added two new methods to the Backend interface:

ReadFrom(volumeName string, fileType FileType, objectId uint64, offset uint32, length uint32) ([]byte, error)
WriteTo(volumeName string, fileType FileType, objectId uint64, headers *[]byte, data *[]byte) error

These are identical to Read/Write but use the provided volume name for path construction instead of the backend's configured name.

Trade-off: This adds two methods to an interface that all backends must implement. The alternative was adding a volumeName parameter to the existing methods (breaks all callers) or using a separate backend instance per snapshot operation (heavier). The ReadFrom/WriteTo approach is minimal and backward-compatible.

4. Snapshot state persisted in VBState

Two fields were added to VBState (the serialized volume config):

SnapshotID       string  // ID of parent snapshot (empty for non-clones)
SourceVolumeName string  // Volume whose chunks the base map references

On LoadState(), if SnapshotID is set, OpenFromSnapshot() is called automatically to reload the base block map. This means a clone volume survives restarts -- the snapshot relationship is restored from the persisted config.

5. Both read paths modified (BlockStore and legacy)

Viperblock has two read paths: the optimized readBlockStore() (O(1) sharded index) and the legacy read() (map rebuilding). Both were modified identically:

  • When a block is not found in the volume's own data structures, check BaseBlockMap
  • Blocks found in the base map are collected into a separate baseConsecutiveBlocks list
  • These are fetched via fetchBaseBlocksFromBackend() which uses ReadFrom(sourceVolumeName, ...)
  • Fetched base blocks are cached in the BlockStore/LRU for subsequent reads

The two-list approach (own blocks vs base blocks) avoids mixing Read() and ReadFrom() calls in the same fetch loop.

6. Snapshot checkpoint reuses existing serialization

The snapshot's frozen block map is serialized in the same binary format as SaveBlockState() -- a header followed by 26-byte BlockLookup entries with CRC32 checksums. LoadSnapshotBlockMap() deserializes using the same readBlockWalChunk() function.

This means snapshot checkpoints are validated with the same integrity checks as regular checkpoints, and no new serialization code was needed.

S3 Layout

vol-abc123/                              # Source volume
  config.json
  chunks/chunk.00000001.bin              # Shared -- snapshot references these
  chunks/chunk.00000002.bin
  checkpoints/blocks.00000001.bin

snap-xyz789/                             # Snapshot
  config.json                            # SnapshotState metadata
  checkpoints/blocks.00000000.bin        # Frozen block map at snapshot time

vol-newclone/                            # Clone volume
  config.json                            # VBState with SnapshotID set
  chunks/chunk.00000001.bin              # Only new writes after clone creation
  checkpoints/blocks.00000001.bin        # Only clone's own block mappings

Files Changed (viperblock repo)

File Change
types/types.go Added ReadFrom, WriteTo to Backend interface
viperblock/viperblock.go Added BaseBlockMap, SourceVolumeName, SnapshotID to VB struct. Added snapshot fields to VBState. Modified readBlockStore() and legacy read() for base map fallback. Added fetchBaseBlocksFromBackend(). Updated SaveState/LoadState.
viperblock/snapshot.go New file: SnapshotState type, CreateSnapshot(), LoadSnapshotBlockMap(), OpenFromSnapshot(), LookupBaseBlockToObject()
viperblock/backends/file/file.go Implemented ReadFrom(), WriteTo()
viperblock/backends/s3/s3.go Implemented ReadFrom(), WriteTo()
viperblock/backends/memory/memory.go Updated stub to satisfy new interface
viperblock/snapshot_test.go 6 test cases across file and S3 backends

Test Coverage

Test What it validates
TestCreateSnapshot Flush + checkpoint + config written to backend; round-trip via LoadSnapshotBlockMap
TestSnapshotReadFallback Clone reads unmodified blocks from source volume's chunks
TestSnapshotCopyOnWrite Overwritten blocks return new data; unmodified blocks return snapshot data
TestSnapshotZeroBlocks Blocks never written in snapshot or clone return zeros
TestSnapshotMultiChunk Snapshot spanning multiple 4MB chunk files
TestLoadFromSnapshot Clone survives save/reset/reload cycle with base map restored

All tests run against 4 backend configurations: file, file_nocache, s3, s3_nocache.

Hive API Integration

Request Flow

AWS SDK (create-snapshot --volume-id vol-xxx)
  -> Gateway (port 9999): parse AWS query params, validate VolumeId
    -> NATS ec2.CreateSnapshot: routed to daemon via queue group
      -> Daemon handleEC2CreateSnapshot -> SnapshotServiceImpl.CreateSnapshot()
        1. Fetch volume config from Predastore (validate volume exists)
        2. NATS ebs.snapshot -> viperblockd: flush + freeze block map
        3. Write snapshot metadata to Predastore ({snapId}/metadata.json)
      <- NATS response: ec2.Snapshot JSON
    <- Gateway: wrap in XML
  <- AWS SDK: Snapshot response

NATS Topics

Topic Direction Purpose
ec2.CreateSnapshot Gateway -> Daemon AWS API entry point
ebs.snapshot Daemon -> Viperblockd Flush live VB instance and create frozen block map checkpoint
ec2.DescribeSnapshots Gateway -> Daemon List/filter snapshots
ec2.DeleteSnapshot Gateway -> Daemon Delete snapshot metadata and checkpoint
ec2.CopySnapshot Gateway -> Daemon Copy snapshot metadata (metadata-only)

Files Changed (hive repo)

File Change
hive/handlers/ec2/snapshot/service.go SnapshotService interface
hive/handlers/ec2/snapshot/service_impl.go Direct implementation with S3 storage + NATS ebs.snapshot call
hive/handlers/ec2/snapshot/service_nats.go NATS proxy (gateway -> daemon)
hive/gateway/ec2/snapshot/CreateSnapshot.go Gateway validation + NATS dispatch
hive/daemon/daemon_handlers.go handleEC2CreateSnapshot wiring
hive/services/viperblockd/viperblockd.go ebs.snapshot subscription handler
hive/config/config.go EBSSnapshotRequest / EBSSnapshotResponse types