Files
zeekayandHanzo Dev 352da70526 decrypt only what is actually sealed
Having age identities configured is not the same fact as an object being sealed,
but every read path treated it as one: DecryptionEnabled() is len(AgeIdentities)>0,
and all four call sites then decrypted unconditionally. A bucket holding BOTH
plaintext and sealed objects — ours does, because snapshots were written before age
was wired and sealed ones landed after — makes every older object fail:

  age decrypt: failed to read header: parsing age header: unexpected intro "LTX1\x00…"

That took chat, hanzo-app, dataroom and studio down tonight. The restore init
container cannot rehydrate an empty PVC, so no pod with this sidecar can cold-start,
and there was no configuration that read both: removing the identities makes the
WRITER fail closed instead (RequireEncryption defaults true with no recipients), so
the sidecar crashloops and the pod stays NotReady either way.

The write path already drew exactly this distinction — ExtractLTXTimestamp sniffs
the age intro so mixed buckets upload correctly. This adds the read half beside it,
so the two directions agree, and routes all four decrypt sites through it
(Replica.OpenLTXFile, the snapshot and WAL-segment readers, and the compactor).

It does not weaken anything. With zero identities the stream is returned untouched,
exactly as before; a sealed object still requires a valid identity; a corrupt one
still fails. Only the plaintext case, previously unreadable, now round-trips.

Mutation-proved 3/3: always-decrypt (the original bug), never-decrypt, and dropping
the sniff replay each turn the tests red. Failures on the root package are a strict
SUBSET of pristine main's (7 vs 8, none introduced) — verified against a clean
worktree, not assumed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 19:49:36 -07:00

478 lines
13 KiB
Go

package replicate
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/hanzoai/ltx"
"github.com/luxfi/age"
metric "github.com/luxfi/metric"
"github.com/hanzoai/replicate/internal"
)
// Compactor handles compaction and retention for LTX files.
// It operates solely through the ReplicaClient interface, making it
// suitable for both DB (with local file caching) and VFS (remote-only).
type Compactor struct {
client ReplicaClient
logger *slog.Logger
// VerifyCompaction enables post-compaction TXID consistency verification.
// When enabled, verifies that files at the destination level have
// contiguous TXID ranges after each compaction. Disabled by default.
VerifyCompaction bool
// RetentionEnabled controls whether Replicate actively deletes old files
// during retention enforcement. When false, cloud provider lifecycle
// policies handle retention instead. Local file cleanup still occurs.
RetentionEnabled bool
// CompactionVerifyErrorCounter is incremented when post-compaction
// verification fails. Optional; if nil, no metric is recorded.
CompactionVerifyErrorCounter metric.Counter
// LocalFileOpener optionally opens a local LTX file for compaction.
// If nil or returns os.ErrNotExist, falls back to remote.
// This is used by DB to prefer local files over remote for consistency.
LocalFileOpener func(level int, minTXID, maxTXID ltx.TXID) (io.ReadCloser, error)
// LocalFileDeleter optionally deletes local LTX files after retention.
// If nil, only remote files are deleted.
LocalFileDeleter func(level int, minTXID, maxTXID ltx.TXID) error
// CacheGetter optionally retrieves cached MaxLTXFileInfo for a level.
// If nil, max file info is always fetched from remote.
CacheGetter func(level int) (*ltx.FileInfo, bool)
// CacheSetter optionally stores MaxLTXFileInfo for a level.
// If nil, max file info is not cached.
CacheSetter func(level int, info *ltx.FileInfo)
// Age encryption for compaction. When set, reads are decrypted and
// writes are encrypted so compacted files remain encrypted at rest.
AgeIdentities []age.Identity
AgeRecipients []age.Recipient
// RequireEncryption mirrors Replica.RequireEncryption for the compaction
// write path. When true and no recipient is configured, Compact fails closed
// with ErrEncryptionRequired rather than decrypting source files and writing
// a plaintext compacted result (a silent downgrade at rest).
RequireEncryption bool
}
// NewCompactor creates a new Compactor with the given client and logger.
func NewCompactor(client ReplicaClient, logger *slog.Logger) *Compactor {
if logger == nil {
logger = slog.Default()
}
return &Compactor{
client: client,
logger: logger,
RetentionEnabled: true,
}
}
func (c *Compactor) setLogger(logger *slog.Logger) {
c.logger = logger
}
// MaxLTXFileInfo returns metadata for the last LTX file in a level.
// Uses cache if available, otherwise fetches from remote.
func (c *Compactor) MaxLTXFileInfo(ctx context.Context, level int) (ltx.FileInfo, error) {
if c.CacheGetter != nil {
if info, ok := c.CacheGetter(level); ok {
return *info, nil
}
}
itr, err := c.client.LTXFiles(ctx, level, 0, false)
if err != nil {
return ltx.FileInfo{}, err
}
defer itr.Close()
var info ltx.FileInfo
for itr.Next() {
item := itr.Item()
if item.MaxTXID > info.MaxTXID {
info = *item
}
}
if c.CacheSetter != nil && info.MaxTXID > 0 {
c.CacheSetter(level, &info)
}
return info, itr.Close()
}
// Compact compacts source level files into the destination level.
// Returns ErrNoCompaction if there are no files to compact.
func (c *Compactor) Compact(ctx context.Context, dstLevel int) (*ltx.FileInfo, error) {
srcLevel := dstLevel - 1
// Fail closed: refuse to produce a plaintext compacted file when encryption
// is mandated but no recipient is configured. Checked before any source read
// so we never decrypt inputs only to re-emit them in the clear.
if c.RequireEncryption && len(c.AgeRecipients) == 0 {
return nil, ErrEncryptionRequired
}
prevMaxInfo, err := c.MaxLTXFileInfo(ctx, dstLevel)
if err != nil {
return nil, fmt.Errorf("cannot determine max ltx file for destination level: %w", err)
}
seekTXID := prevMaxInfo.MaxTXID + 1
itr, err := c.client.LTXFiles(ctx, srcLevel, seekTXID, false)
if err != nil {
return nil, fmt.Errorf("source ltx files after %s: %w", seekTXID, err)
}
defer itr.Close()
var rdrs []io.Reader
defer func() {
for _, rd := range rdrs {
if closer, ok := rd.(io.Closer); ok {
_ = closer.Close()
}
}
}()
var minTXID, maxTXID ltx.TXID
for itr.Next() {
info := itr.Item()
if minTXID == 0 || info.MinTXID < minTXID {
minTXID = info.MinTXID
}
if maxTXID == 0 || info.MaxTXID > maxTXID {
maxTXID = info.MaxTXID
}
if c.LocalFileOpener != nil {
if f, err := c.LocalFileOpener(srcLevel, info.MinTXID, info.MaxTXID); err == nil {
rdrs = append(rdrs, f)
continue
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("open local ltx file: %w", err)
}
}
f, err := c.client.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
if err != nil {
return nil, fmt.Errorf("open ltx file: %w", err)
}
if len(c.AgeIdentities) > 0 {
dr, err := internal.DecryptIfSealed(f, func(x io.Reader) (io.Reader, error) {
return age.Decrypt(x, c.AgeIdentities...)
})
if err != nil {
f.Close()
return nil, fmt.Errorf("age decrypt ltx file: %w", err)
}
rdrs = append(rdrs, &decryptReadCloser{Reader: dr, closer: f})
} else {
rdrs = append(rdrs, f)
}
}
if len(rdrs) == 0 {
return nil, ErrNoCompaction
}
pr, pw := io.Pipe()
go func() {
var w io.WriteCloser = pw
if len(c.AgeRecipients) > 0 {
ew, err := age.Encrypt(pw, c.AgeRecipients...)
if err != nil {
pw.CloseWithError(fmt.Errorf("age encrypt: %w", err))
return
}
w = ew
}
comp, err := ltx.NewCompactor(w, rdrs)
if err != nil {
w.Close()
pw.CloseWithError(fmt.Errorf("new ltx compactor: %w", err))
return
}
comp.HeaderFlags = ltx.HeaderFlagNoChecksum
if err := comp.Compact(ctx); err != nil {
w.Close()
pw.CloseWithError(err)
return
}
if w != pw {
if err := w.Close(); err != nil {
pw.CloseWithError(err)
return
}
}
pw.Close()
}()
info, err := c.client.WriteLTXFile(ctx, dstLevel, minTXID, maxTXID, pr)
if err != nil {
return nil, fmt.Errorf("write ltx file: %w", err)
}
if c.CacheSetter != nil {
c.CacheSetter(dstLevel, info)
}
// Verify level consistency if enabled
if c.VerifyCompaction {
if err := c.VerifyLevelConsistency(ctx, dstLevel); err != nil {
c.logger.Warn("post-compaction verification failed",
"level", dstLevel,
"error", err)
if c.CompactionVerifyErrorCounter != nil {
c.CompactionVerifyErrorCounter.Inc()
}
}
}
return info, nil
}
// VerifyLevelConsistency checks that LTX files at the given level have
// contiguous TXID ranges (prevMaxTXID + 1 == currMinTXID for consecutive files).
// Returns an error describing any gaps or overlaps found.
func (c *Compactor) VerifyLevelConsistency(ctx context.Context, level int) error {
itr, err := c.client.LTXFiles(ctx, level, 0, false)
if err != nil {
return fmt.Errorf("fetch ltx files: %w", err)
}
defer itr.Close()
var prevInfo *ltx.FileInfo
for itr.Next() {
info := itr.Item()
// Skip first file - nothing to compare against
if prevInfo == nil {
prevInfo = info
continue
}
// Check for TXID contiguity: prev.MaxTXID + 1 should equal curr.MinTXID
expectedMinTXID := prevInfo.MaxTXID + 1
if info.MinTXID != expectedMinTXID {
if info.MinTXID > expectedMinTXID {
return fmt.Errorf("TXID gap detected: prev.MaxTXID=%s, next.MinTXID=%s (expected %s)",
prevInfo.MaxTXID, info.MinTXID, expectedMinTXID)
}
return fmt.Errorf("TXID overlap detected: prev.MaxTXID=%s, next.MinTXID=%s",
prevInfo.MaxTXID, info.MinTXID)
}
prevInfo = info
}
if err := itr.Close(); err != nil {
return fmt.Errorf("close iterator: %w", err)
}
return nil
}
// EnforceSnapshotRetention enforces retention of snapshot level files by timestamp.
// Files older than the retention duration are deleted (except the newest is always kept).
// Returns the minimum snapshot TXID still retained (useful for cascading retention to lower levels).
func (c *Compactor) EnforceSnapshotRetention(ctx context.Context, retention time.Duration) (ltx.TXID, error) {
timestamp := time.Now().Add(-retention)
c.logger.Debug("enforcing snapshot retention", "timestamp", timestamp)
itr, err := c.client.LTXFiles(ctx, SnapshotLevel, 0, false)
if err != nil {
return 0, fmt.Errorf("fetch ltx files: %w", err)
}
defer itr.Close()
var deleted []*ltx.FileInfo
var lastInfo *ltx.FileInfo
var minSnapshotTXID ltx.TXID
for itr.Next() {
info := itr.Item()
lastInfo = info
if info.CreatedAt.Before(timestamp) {
deleted = append(deleted, info)
continue
}
if minSnapshotTXID == 0 || info.MaxTXID < minSnapshotTXID {
minSnapshotTXID = info.MaxTXID
}
}
if len(deleted) > 0 && deleted[len(deleted)-1] == lastInfo {
deleted = deleted[:len(deleted)-1]
}
if !c.RetentionEnabled {
c.logger.Debug("skipping remote deletion (retention disabled)", "level", SnapshotLevel, "count", len(deleted))
} else if err := c.client.DeleteLTXFiles(ctx, deleted); err != nil {
return 0, fmt.Errorf("remove ltx files: %w", err)
}
if c.LocalFileDeleter != nil {
for _, info := range deleted {
c.logger.Debug("deleting local ltx file",
"level", SnapshotLevel,
"minTXID", info.MinTXID,
"maxTXID", info.MaxTXID)
if err := c.LocalFileDeleter(SnapshotLevel, info.MinTXID, info.MaxTXID); err != nil {
c.logger.Error("failed to remove local ltx file", "error", err)
}
}
}
return minSnapshotTXID, nil
}
// EnforceRetentionByTXID deletes files at the given level with maxTXID below the target.
// Always keeps at least one file.
func (c *Compactor) EnforceRetentionByTXID(ctx context.Context, level int, txID ltx.TXID) error {
c.logger.Debug("enforcing retention", "level", level, "txid", txID)
itr, err := c.client.LTXFiles(ctx, level, 0, false)
if err != nil {
return fmt.Errorf("fetch ltx files: %w", err)
}
defer itr.Close()
var deleted []*ltx.FileInfo
var lastInfo *ltx.FileInfo
for itr.Next() {
info := itr.Item()
lastInfo = info
if info.MaxTXID < txID {
deleted = append(deleted, info)
continue
}
}
if len(deleted) > 0 && deleted[len(deleted)-1] == lastInfo {
deleted = deleted[:len(deleted)-1]
}
if !c.RetentionEnabled {
c.logger.Debug("skipping remote deletion (retention disabled)", "level", level, "count", len(deleted))
} else if err := c.client.DeleteLTXFiles(ctx, deleted); err != nil {
return fmt.Errorf("remove ltx files: %w", err)
}
if c.LocalFileDeleter != nil {
for _, info := range deleted {
c.logger.Debug("deleting local ltx file",
"level", level,
"minTXID", info.MinTXID,
"maxTXID", info.MaxTXID)
if err := c.LocalFileDeleter(level, info.MinTXID, info.MaxTXID); err != nil {
c.logger.Error("failed to remove local ltx file", "error", err)
}
}
}
return nil
}
// EnforceL0Retention retains L0 files based on L1 compaction progress and time.
// Files are only deleted if they have been compacted into L1 AND are older than retention.
// This ensures contiguous L0 coverage for VFS reads.
func (c *Compactor) EnforceL0Retention(ctx context.Context, retention time.Duration) error {
if retention <= 0 {
return nil
}
c.logger.Debug("enforcing l0 retention", "retention", retention)
itr, err := c.client.LTXFiles(ctx, 1, 0, false)
if err != nil {
return fmt.Errorf("fetch l1 files: %w", err)
}
var maxL1TXID ltx.TXID
for itr.Next() {
info := itr.Item()
if info.MaxTXID > maxL1TXID {
maxL1TXID = info.MaxTXID
}
}
if err := itr.Close(); err != nil {
return fmt.Errorf("close l1 iterator: %w", err)
}
if maxL1TXID == 0 {
return nil
}
threshold := time.Now().Add(-retention)
itr, err = c.client.LTXFiles(ctx, 0, 0, false)
if err != nil {
return fmt.Errorf("fetch l0 files: %w", err)
}
defer itr.Close()
var (
deleted []*ltx.FileInfo
lastInfo *ltx.FileInfo
processedAll = true
)
for itr.Next() {
info := itr.Item()
lastInfo = info
createdAt := info.CreatedAt
if createdAt.IsZero() {
createdAt = threshold
}
if createdAt.After(threshold) {
processedAll = false
break
}
if info.MaxTXID <= maxL1TXID {
deleted = append(deleted, info)
}
}
if processedAll && len(deleted) > 0 && lastInfo != nil && deleted[len(deleted)-1] == lastInfo {
deleted = deleted[:len(deleted)-1]
}
if len(deleted) == 0 {
return nil
}
if !c.RetentionEnabled {
c.logger.Debug("skipping remote deletion (retention disabled)", "level", 0, "count", len(deleted))
} else if err := c.client.DeleteLTXFiles(ctx, deleted); err != nil {
return fmt.Errorf("remove expired l0 files: %w", err)
}
if c.LocalFileDeleter != nil {
for _, info := range deleted {
c.logger.Debug("deleting expired local l0 file",
"minTXID", info.MinTXID,
"maxTXID", info.MaxTXID)
if err := c.LocalFileDeleter(0, info.MinTXID, info.MaxTXID); err != nil {
c.logger.Error("failed to remove local l0 file", "error", err)
}
}
}
c.logger.Info("l0 retention enforced", "deleted_count", len(deleted), "max_l1_txid", maxL1TXID)
return nil
}