tka: implement filesystem chonk garbage collection
Signed-off-by: Tom DNetto <tom@tailscale.com>tom/tka6
parent
6f9aed1656
commit
6462876f04
|
@ -4800,6 +4800,10 @@ func (b *LocalBackend) initTKALocked() error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("opening tailchonk: %v", err)
|
return fmt.Errorf("opening tailchonk: %v", err)
|
||||||
}
|
}
|
||||||
|
// Actually delete data which has been purged for 7 days:
|
||||||
|
if err := storage.CollectGarbage(7 * 24 * time.Hour); err != nil {
|
||||||
|
b.logf("tka garbage collection failed: %v", err)
|
||||||
|
}
|
||||||
authority, err := tka.Open(storage)
|
authority, err := tka.Open(storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initializing tka: %v", err)
|
return fmt.Errorf("initializing tka: %v", err)
|
||||||
|
|
106
tka/tailchonk.go
106
tka/tailchonk.go
|
@ -201,10 +201,6 @@ func ChonkDir(dir string) (*FS, error) {
|
||||||
return nil, fmt.Errorf("chonk directory %q is a file", dir)
|
return nil, fmt.Errorf("chonk directory %q is a file", dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(tom): *FS marks AUMs as deleted but does not actually
|
|
||||||
// delete them, to avoid data loss in the event of a bug.
|
|
||||||
// Implement deletion after we are fairly sure in the implementation.
|
|
||||||
|
|
||||||
return &FS{base: dir}, nil
|
return &FS{base: dir}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,6 +214,9 @@ func ChonkDir(dir string) (*FS, error) {
|
||||||
// much smaller than JSON for AUMs. The 'keyasint' thing isn't essential
|
// much smaller than JSON for AUMs. The 'keyasint' thing isn't essential
|
||||||
// but again it saves a bunch of bytes.
|
// but again it saves a bunch of bytes.
|
||||||
type fsHashInfo struct {
|
type fsHashInfo struct {
|
||||||
|
// diskHash specifies the AUMHash this structure describes.
|
||||||
|
diskHash AUMHash
|
||||||
|
|
||||||
Children []AUMHash `cbor:"1,keyasint"`
|
Children []AUMHash `cbor:"1,keyasint"`
|
||||||
AUM *AUM `cbor:"2,keyasint"`
|
AUM *AUM `cbor:"2,keyasint"`
|
||||||
CreatedUnix int64 `cbor:"3,keyasint,omitempty"`
|
CreatedUnix int64 `cbor:"3,keyasint,omitempty"`
|
||||||
|
@ -344,6 +343,7 @@ func (c *FS) get(h AUMHash) (*fsHashInfo, error) {
|
||||||
if out.AUM != nil && out.AUM.Hash() != h {
|
if out.AUM != nil && out.AUM.Hash() != h {
|
||||||
return nil, fmt.Errorf("%s: AUM does not match file name hash %s", f.Name(), out.AUM.Hash())
|
return nil, fmt.Errorf("%s: AUM does not match file name hash %s", f.Name(), out.AUM.Hash())
|
||||||
}
|
}
|
||||||
|
out.diskHash = h
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -380,6 +380,104 @@ func (c *FS) AllAUMs() ([]AUMHash, error) {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CollectGarbage frees up disk space by removing purged AUMs
|
||||||
|
// and files which contain no data.
|
||||||
|
func (c *FS) CollectGarbage(maxAge time.Duration) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// Collect the list of all stored hashes which are marked
|
||||||
|
// for deletion & old enough to delete.
|
||||||
|
var (
|
||||||
|
deletionCandidates = make(map[AUMHash]*fsHashInfo)
|
||||||
|
purgeBefore = time.Now().Add(-maxAge)
|
||||||
|
)
|
||||||
|
err := c.scanHashes(func(info *fsHashInfo) {
|
||||||
|
// Mark for deletion all hashes which are explicitly purged, or
|
||||||
|
// hashes that store no data.
|
||||||
|
purged := info.PurgedUnix > 0 && time.Unix(info.PurgedUnix, 0).Before(purgeBefore)
|
||||||
|
if purged || (info.AUM == nil && len(info.Children) == 0) {
|
||||||
|
deletionCandidates[info.diskHash] = info
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: consistency check that no deletion candidate is the last active
|
||||||
|
// ancestor nor a parent is the last active ancestor.
|
||||||
|
|
||||||
|
for h, info := range deletionCandidates {
|
||||||
|
|
||||||
|
// First, if we store the parent, remove the reference to this
|
||||||
|
// hash as a child.
|
||||||
|
if info.AUM != nil {
|
||||||
|
if parent, haveParent := info.AUM.Parent(); haveParent {
|
||||||
|
dir, base := c.aumDir(parent)
|
||||||
|
_, err := os.Stat(filepath.Join(dir, base))
|
||||||
|
parentExists := err == nil
|
||||||
|
|
||||||
|
if parentExists {
|
||||||
|
err := c.commit(parent, func(info *fsHashInfo) {
|
||||||
|
newChildren := make([]AUMHash, 0, len(info.Children))
|
||||||
|
for _, c := range info.Children {
|
||||||
|
if c != h {
|
||||||
|
newChildren = append(newChildren, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.Children = newChildren
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mutating parent %x of %x: %v", parent, h, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, base := c.aumDir(h)
|
||||||
|
path := filepath.Join(dir, base)
|
||||||
|
if len(info.Children) == 0 {
|
||||||
|
// This hash has no dependencies.
|
||||||
|
//
|
||||||
|
// Technically, info.Children could be stale, because if this hash was
|
||||||
|
// someones parent then that someone would have removed their hash from
|
||||||
|
// the list. Because thats only ever a deletion tho, this is still safe,
|
||||||
|
// staleness will result in us not deleting this file but it will be
|
||||||
|
// deleted next time.
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return fmt.Errorf("removing dead entry: %w", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// This hash has children it needs to keep track of, so might not
|
||||||
|
// be able to be deleted outright.
|
||||||
|
var delete bool
|
||||||
|
err := c.commit(h, func(info *fsHashInfo) {
|
||||||
|
info.AUM = nil // in all cases this hash shouldnt store its own AUM info
|
||||||
|
newChildren := make([]AUMHash, 0, len(info.Children))
|
||||||
|
for _, c := range info.Children {
|
||||||
|
if _, deleted := deletionCandidates[c]; !deleted {
|
||||||
|
newChildren = append(newChildren, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.Children = newChildren
|
||||||
|
delete = len(newChildren) == 0
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mutating entry %x: %v", h, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if delete {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return fmt.Errorf("removing empty entry: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *FS) scanHashes(eachHashInfo func(*fsHashInfo)) error {
|
func (c *FS) scanHashes(eachHashInfo func(*fsHashInfo)) error {
|
||||||
prefixDirs, err := os.ReadDir(c.base)
|
prefixDirs, err := os.ReadDir(c.base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -630,6 +630,7 @@ func TestCompact(t *testing.T) {
|
||||||
// OLD is deleted because it does not match retention criteria, and
|
// OLD is deleted because it does not match retention criteria, and
|
||||||
// though it is a descendant of the new lastActiveAncestor (C), it is not a
|
// though it is a descendant of the new lastActiveAncestor (C), it is not a
|
||||||
// descendant of a retained AUM.
|
// descendant of a retained AUM.
|
||||||
|
// O is deleted because it is orphaned.
|
||||||
// G, & H are retained as recent (MinChain=2) ancestors of HEAD.
|
// G, & H are retained as recent (MinChain=2) ancestors of HEAD.
|
||||||
// E & F are retained because they are between retained AUMs (G+) and
|
// E & F are retained because they are between retained AUMs (G+) and
|
||||||
// their newest checkpoint ancestor.
|
// their newest checkpoint ancestor.
|
||||||
|
@ -648,6 +649,9 @@ func TestCompact(t *testing.T) {
|
||||||
| -> F1 -> F2 | -> G2
|
| -> F1 -> F2 | -> G2
|
||||||
| -> OLD
|
| -> OLD
|
||||||
|
|
||||||
|
// Orphaned AUM
|
||||||
|
O
|
||||||
|
|
||||||
// make {A,B,C,D} compaction candidates
|
// make {A,B,C,D} compaction candidates
|
||||||
A.template = checkpoint
|
A.template = checkpoint
|
||||||
B.template = checkpoint
|
B.template = checkpoint
|
||||||
|
@ -658,13 +662,14 @@ func TestCompact(t *testing.T) {
|
||||||
F1.hashSeed = 1
|
F1.hashSeed = 1
|
||||||
OLD.hashSeed = 2
|
OLD.hashSeed = 2
|
||||||
G2.hashSeed = 3
|
G2.hashSeed = 3
|
||||||
|
O.hashSeed = 4
|
||||||
`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState}))
|
`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState}))
|
||||||
|
|
||||||
storage := &compactingChonkFake{
|
storage := &compactingChonkFake{
|
||||||
Mem: (*c.Chonk().(*Mem)),
|
Mem: (*c.Chonk().(*Mem)),
|
||||||
aumAge: map[AUMHash]time.Time{(c.AUMHashes["F1"]): time.Now()},
|
aumAge: map[AUMHash]time.Time{(c.AUMHashes["F1"]): time.Now()},
|
||||||
t: t,
|
t: t,
|
||||||
wantDelete: []AUMHash{c.AUMHashes["A"], c.AUMHashes["B"], c.AUMHashes["OLD"]},
|
wantDelete: []AUMHash{c.AUMHashes["A"], c.AUMHashes["B"], c.AUMHashes["O"], c.AUMHashes["OLD"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
lastActiveAncestor, err := Compact(storage, c.AUMHashes["H"], CompactionOptions{MinChain: 2, MinAge: time.Hour})
|
lastActiveAncestor, err := Compact(storage, c.AUMHashes["H"], CompactionOptions{MinChain: 2, MinAge: time.Hour})
|
||||||
|
@ -681,3 +686,87 @@ func TestCompact(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectGarbage(t *testing.T) {
|
||||||
|
fakeState := &State{
|
||||||
|
Keys: []Key{{Kind: Key25519, Votes: 1}},
|
||||||
|
DisablementSecrets: [][]byte{bytes.Repeat([]byte{1}, 32)},
|
||||||
|
}
|
||||||
|
|
||||||
|
c := newTestchain(t, `
|
||||||
|
A -> B -> C -> C2 -> D -> E -> F -> G -> H
|
||||||
|
| -> OLD | -> G2
|
||||||
|
|
||||||
|
// make {A,B,C,D} compaction candidates
|
||||||
|
A.template = checkpoint
|
||||||
|
B.template = checkpoint
|
||||||
|
C.template = checkpoint
|
||||||
|
D.template = checkpoint
|
||||||
|
|
||||||
|
// tweak seeds of forks so hashes arent identical
|
||||||
|
OLD.hashSeed = 2
|
||||||
|
G2.hashSeed = 3
|
||||||
|
`, optTemplate("checkpoint", AUM{MessageKind: AUMCheckpoint, State: fakeState}))
|
||||||
|
|
||||||
|
// Populate a *FS chonk.
|
||||||
|
storage, err := ChonkDir(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, update := range c.AUMs {
|
||||||
|
if err := storage.CommitVerifiedAUMs([]AUM{update}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := storage.SetLastActiveAncestor(c.AUMHashes["A"]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run compaction.
|
||||||
|
lastActiveAncestor, err := Compact(storage, c.AUMHashes["H"], CompactionOptions{MinChain: 2, MinAge: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Compact() failed: %v", err)
|
||||||
|
}
|
||||||
|
if lastActiveAncestor != c.AUMHashes["D"] {
|
||||||
|
t.Errorf("last active ancestor = %v, want %v", lastActiveAncestor, c.AUMHashes["C"])
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedAUMs := []AUMHash{c.AUMHashes["A"], c.AUMHashes["B"], c.AUMHashes["C"], c.AUMHashes["C2"], c.AUMHashes["OLD"]}
|
||||||
|
|
||||||
|
// Make sure deleted AUMs are unreadable.
|
||||||
|
for _, h := range deletedAUMs {
|
||||||
|
if _, err := storage.AUM(h); err != os.ErrNotExist {
|
||||||
|
t.Errorf("storage.AUM(%v).err = %v, want ErrNotExist", h, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.CollectGarbage(0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure files for deleted AUMs are gone.
|
||||||
|
for _, h := range deletedAUMs {
|
||||||
|
dir, base := storage.aumDir(h)
|
||||||
|
path := filepath.Join(dir, base)
|
||||||
|
// C2 is excluded, because its child D exists and the file
|
||||||
|
// stores the parent->child relationship.
|
||||||
|
if _, err := os.Stat(path); err == nil && h != c.AUMHashes["C2"] {
|
||||||
|
t.Errorf("file for deleted AUM %v exists", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
for name, hash := range c.AUMHashes {
|
||||||
|
t.Logf("AUM[%q] = %v", name, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lastly, lets make sure an authority can start from the garbage-collected state.
|
||||||
|
a, err := Open(storage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.Head() != c.AUMHashes["H"] {
|
||||||
|
t.Errorf("head = %v, want %v", a.Head(), c.AUMHashes["H"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue