Files
kage/pack/cache.go
T
Tam Nguyen Duc 108b80f2fb Add incremental packing that reuses unchanged compression (#22)
Compressing clusters with zstd is the slow part of packing a large
mirror. When a mirror is re-packed after a small change, most clusters
are byte-identical and there is no reason to compress them again.

pack --incremental keeps a content-addressed cache of compressed
clusters in a sidecar next to the output. On the next pack, a cluster
whose uncompressed bytes match the cache is served from it instead of
being recompressed; only new or changed clusters go through zstd. A
cache hit returns exactly what a fresh compression would, so the archive
stays deterministic and byte-identical to a cold pack.

The zim writer gains a settable cluster compressor so the cache can hook
in without changing the format. The cache only writes back the clusters
it touched this run, so clusters that left the mirror drop out and it
cannot grow without bound.
2026-06-15 19:12:16 +07:00

135 lines
3.9 KiB
Go

package pack
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/binary"
"io"
"os"
"sort"
"github.com/tamnd/kage/zim"
)
// cacheMagic tags the sidecar so a stale or foreign file is recognised and
// ignored rather than misread. The trailing digit is the format version.
var cacheMagic = [8]byte{'k', 'a', 'g', 'e', 'z', 'c', 'h', '1'}
// clusterCache is a content-addressed store of compressed ZIM clusters, kept in
// a sidecar next to the archive. Compressing clusters with zstd is the dominant
// cost of packing a large mirror, so a re-pack reuses the compression of every
// cluster whose uncompressed bytes are unchanged and only compresses the rest.
//
// A cache hit returns exactly what a fresh compression would have produced, so
// the archive stays deterministic and valid; the cache only saves CPU. Clusters
// are keyed by the SHA-256 of their uncompressed data section, which the zim
// writer assembles before compression.
type clusterCache struct {
prev map[[32]byte][]byte // clusters loaded from the previous pack
used map[[32]byte][]byte // clusters touched this pack, written back on save
reused int // clusters served from the cache this pack
compressed int // clusters compressed fresh this pack
}
func newClusterCache() *clusterCache {
return &clusterCache{prev: map[[32]byte][]byte{}, used: map[[32]byte][]byte{}}
}
// loadClusterCache reads the sidecar at path. A missing, unreadable, or
// truncated cache is not an error: packing starts cold and rebuilds it.
func loadClusterCache(path string) *clusterCache {
c := newClusterCache()
f, err := os.Open(path)
if err != nil {
return c
}
defer func() { _ = f.Close() }()
r := bufio.NewReader(f)
var magic [8]byte
if _, err := io.ReadFull(r, magic[:]); err != nil || magic != cacheMagic {
return c
}
var count uint32
if err := binary.Read(r, binary.LittleEndian, &count); err != nil {
return c
}
for i := uint32(0); i < count; i++ {
var h [32]byte
if _, err := io.ReadFull(r, h[:]); err != nil {
return newClusterCache() // truncated: drop the partial load
}
var n uint32
if err := binary.Read(r, binary.LittleEndian, &n); err != nil {
return newClusterCache()
}
b := make([]byte, n)
if _, err := io.ReadFull(r, b); err != nil {
return newClusterCache()
}
c.prev[h] = b
}
return c
}
// Compress returns the stored bytes for an uncompressed cluster: the cached
// compression when the cluster's bytes are unchanged, a fresh zstd compression
// on a miss. It is the function handed to zim.Writer.SetCompress.
func (c *clusterCache) Compress(data []byte) []byte {
h := sha256.Sum256(data)
if b, ok := c.used[h]; ok {
return b
}
if b, ok := c.prev[h]; ok {
c.used[h] = b
c.reused++
return b
}
b := zim.Compress(data)
c.used[h] = b
c.compressed++
return b
}
// save writes the clusters touched this pack to path, replacing the previous
// sidecar. Only touched clusters are written, so clusters that left the mirror
// drop out and the cache cannot grow without bound. Entries are sorted by hash
// so the sidecar itself is reproducible. The write goes through a temp file and
// a rename so a crash mid-write cannot corrupt an existing cache.
func (c *clusterCache) save(path string) error {
hashes := make([][32]byte, 0, len(c.used))
for h := range c.used {
hashes = append(hashes, h)
}
sort.Slice(hashes, func(i, j int) bool {
return bytes.Compare(hashes[i][:], hashes[j][:]) < 0
})
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
w := bufio.NewWriter(f)
_, _ = w.Write(cacheMagic[:])
_ = binary.Write(w, binary.LittleEndian, uint32(len(hashes)))
for _, h := range hashes {
b := c.used[h]
_, _ = w.Write(h[:])
_ = binary.Write(w, binary.LittleEndian, uint32(len(b)))
_, _ = w.Write(b)
}
if err := w.Flush(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}