Files
brianmcgee e3a7790e57
buildbot/nix-eval Build done.
PR Size Review Check / pr-size-review-gate (pull_request) Successful in 24s
sizelint / sizelint (pull_request) Successful in 2m12s
buildbot/nix-build Build done.
gitea-mq Already up to date with target branch
fix breaks in gofumpt and golangci-lint from updating nixpkgs
2026-07-07 16:32:14 +01:00

180 lines
4.6 KiB
Go

package state
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"time"
"git.clan.lol/clan/data-mesher/pkg/crypto"
"git.clan.lol/clan/data-mesher/pkg/model"
)
// expiredEntry holds enough state to remove an expired sig + its on-disk file
// in the second pass of SweepExpired. We collect these during iteration so we
// don't mutate the BoltDB store while a cursor is still walking it.
type expiredEntry struct {
networkID *crypto.PublicKey
name string
filePath string
}
// SweepExpired walks the signature store and removes any signatures whose
// ValidFor TTL has elapsed (i.e. SignedAt + ValidFor is before now), along
// with their on-disk file. Returns the number of entries removed.
//
// Expiry is decided independently by every node from signed metadata, so the
// sweeper does NOT produce a tombstone — there is nothing to gossip. A
// concurrent reader on another goroutine will either see the old signature
// and fail to open the file (returning ErrFileNotFound, which is correct
// because it is expired) or fail to find the signature at all (also
// correct).
func (f *Files) SweepExpired(now time.Time) (int, error) {
tx, err := f.signatures.BeginTx(true)
if err != nil {
return 0, fmt.Errorf("failed to begin write transaction: %w", err)
}
defer func() {
_ = tx.Rollback()
}()
r, err := f.ListSignatures(tx)
if err != nil {
return 0, fmt.Errorf("failed to list signatures: %w", err)
}
// Pass 1: collect expired entries.
// We don't delete inside this loop because BoltDB cursors are invalidated by writes through the same tx.
var expired []expiredEntry
buf := make([]model.Signature, 16)
for {
n, readErr := r.Read(buf)
for i := range n {
sig := buf[i]
if !sig.Expired(now) {
continue
}
// look up the network so we can derive the on-disk file path
network, ok := f.cfg.NetworksByID[sig.NetworkID.String()]
if !ok {
slog.Warn(
"sweeper: signature for unknown network, skipping",
slog.String("network_id", sig.NetworkID.String()),
slog.String("name", sig.Name),
)
continue
}
filePath, fpErr := f.FilePath(network, sig.Name)
if fpErr != nil {
slog.Warn(
"sweeper: failed to resolve file path, skipping",
slog.String("name", sig.Name),
slog.Any("err", fpErr),
)
continue
}
expired = append(expired, expiredEntry{
networkID: sig.NetworkID,
name: sig.Name,
filePath: filePath,
})
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return 0, fmt.Errorf("failed to read signatures: %w", readErr)
}
}
if len(expired) == 0 {
// nothing to do — release the (still-empty) write tx
return 0, nil
}
// Pass 2: delete signatures from the DB, then commit, then remove files from disk.
// Doing things in this order ensures the only possible inconsistency is orphaned files on disk with no signatures
// in the db, effectively making them invisible.
// The integrity checker will clean up any orphans on next startup.
for _, e := range expired {
if err = f.signatures.Delete(tx, e.networkID, e.name); err != nil {
return 0, fmt.Errorf("failed to delete signature %s: %w", e.name, err)
}
}
if err = tx.Commit(); err != nil {
return 0, fmt.Errorf("failed to commit sweep transaction: %w", err)
}
// Pass 3: best-effort file removal.
// Failures are logged but don't prevent us from reporting how many entries were swept — the DB is already
// consistent and orphaned files are harmless.
for _, e := range expired {
if err = os.Remove(e.filePath); err != nil && !errors.Is(err, os.ErrNotExist) {
slog.Warn(
"sweeper: failed to remove expired file",
slog.String("path", e.filePath),
slog.Any("err", err),
)
}
}
return len(expired), nil
}
// SweepLoop runs SweepExpired on the given interval until ctx is cancelled.
// Errors from individual sweeps are logged but not returned, so a transient
// failure does not bring down the server's errgroup. Returns nil on
// context cancellation.
func (f *Files) SweepLoop(ctx context.Context, interval time.Duration, clock model.Clock) error {
if interval <= 0 {
slog.Info("sweeper disabled (interval <= 0)")
<-ctx.Done()
return nil
}
if clock == nil {
return errors.New("clock cannot be nil")
}
slog.Info("starting expired-file sweeper", slog.Duration("interval", interval))
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-t.C:
n, err := f.SweepExpired(clock())
if err != nil {
slog.Error("sweep failed", slog.Any("err", err))
continue
}
if n > 0 {
slog.Info("swept expired files", slog.Int("removed", n))
}
}
}
}