~james/core

core/spindle/nixcache/prune.go -rw-r--r-- 8.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
package nixcache

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"maps"
	"math"
	"net/http"
	"net/url"
	"slices"
	"strings"
	"time"

	"tangled.org/core/spindle/db"
	"tangled.org/core/spindle/quota"
)

const (
	evictBatch  = 1000
	deleteBatch = 500
)

type Index interface {
	NixCacheUsage(ctx context.Context) (byRepo, byOwner map[string]int64, total int64, err error)
	NixEvictable(ctx context.Context, f db.NixEvictFilter, usedBefore time.Time, limit int) ([]db.NixObjectRow, error)
	MarkNixEvicting(ctx context.Context, rows []db.NixObjectRow, usedBefore time.Time) ([]db.NixObjectRow, error)
	NixEvicting(ctx context.Context) ([]db.NixObjectRow, error)
	FinishNixEviction(ctx context.Context, cache string, gone []db.NixObjectRow) error
}

type Attic interface {
	DeletePaths(ctx context.Context, cache string, storePathHashes []string) ([]string, error)
	MissingPaths(ctx context.Context, cache string, storePathHashes []string) ([]string, error)
}

// Pruner evicts least recently used paths that nothing in the cache references.
// it trims to MaxBytes and Retention itself. quota.Evictor trims repos and users through it
type Pruner struct {
	Index     Index
	Attic     Attic
	MaxBytes  int64
	Retention time.Duration
	// keeps a running workflow's fetched paths from being evicted under it. Grants stretches it to the longest live token
	Grace        time.Duration
	Grants       Grants
	Publishing   []db.NixPublishing
	Reservations Reservations
	Log          *slog.Logger

	now func() time.Time
}

func (p *Pruner) clock() time.Time {
	if p.now != nil {
		return p.now()
	}
	return time.Now()
}

func (p *Pruner) grace(ctx context.Context) (time.Duration, error) {
	if p.Grants == nil {
		return p.Grace, nil
	}
	life, err := p.Grants.LongestLiveNixGrant(ctx, p.clock())
	if err != nil {
		return 0, err
	}
	return max(p.Grace, life), nil
}

// Start prunes every interval until ctx ends. a zero interval only settles Publishing.
func (p *Pruner) Start(ctx context.Context, interval time.Duration) {
	if interval <= 0 {
		if len(p.Publishing) > 0 {
			go p.resolveOnce(ctx)
		}
		return
	}
	go func() {
		p.resolvePublishing(ctx)
		p.pruneUntilIdle(ctx)
		ticker := time.NewTicker(interval)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				p.resolvePublishing(ctx)
				p.pruneUntilIdle(ctx)
			}
		}
	}()
}

// paths referenced by what a pass freed only become free on the next pass
func (p *Pruner) pruneUntilIdle(ctx context.Context) {
	total := 0
	for {
		n, err := p.Prune(ctx)
		total += n
		if err != nil {
			p.Log.Warn("nix cache prune failed", "evicted", total, "err", err)
		}
		if n == 0 {
			if err == nil && total > 0 {
				p.Log.Info("evicted nix cache paths", "count", total)
			}
			return
		}
	}
}

// Prune skips a cache that fails, and stops once attic can't be reached.
func (p *Pruner) Prune(ctx context.Context) (int, error) {
	stuck, err := p.Index.NixEvicting(ctx)
	if err != nil {
		return 0, err
	}
	retried, failed, down, retryErr := p.evict(ctx, stuck)
	evicted := len(retried)
	if down {
		return evicted, retryErr
	}

	picked, spared, err := p.pick(ctx)
	if err != nil {
		return evicted, errors.Join(retryErr, err)
	}
	picked = slices.DeleteFunc(picked, func(row db.NixObjectRow) bool { return failed[row.Cache] })
	if len(picked) == 0 {
		return evicted, retryErr
	}
	marked, err := p.Index.MarkNixEvicting(ctx, picked, spared)
	if err != nil {
		return evicted, errors.Join(retryErr, err)
	}
	gone, _, _, err := p.evict(ctx, marked)
	return evicted + len(gone), errors.Join(retryErr, err)
}

// pick returns its cutoff because marking has to skip rows used after it.
func (p *Pruner) pick(ctx context.Context) ([]db.NixObjectRow, time.Time, error) {
	_, _, total, err := p.Index.NixCacheUsage(ctx)
	if err != nil {
		return nil, time.Time{}, err
	}
	grace, err := p.grace(ctx)
	if err != nil {
		return nil, time.Time{}, err
	}
	now := p.clock()
	spared := now.Add(-grace)

	var picked []db.NixObjectRow
	chosen := map[string]bool{}
	take := func(usedBefore time.Time, need int64) error {
		rows, err := p.Index.NixEvictable(ctx, db.NixEvictFilter{}, usedBefore, evictBatch+len(chosen))
		for _, row := range rows {
			if need <= 0 {
				break
			}
			if key := quota.NixCacheKey(row.Cache, row.StoreHash); !chosen[key] {
				chosen[key] = true
				picked = append(picked, row)
				need -= row.NarSize
			}
		}
		return err
	}

	if p.MaxBytes > 0 && total > p.MaxBytes {
		if err := take(spared, total-p.MaxBytes); err != nil {
			return nil, time.Time{}, err
		}
	}
	if p.Retention > 0 {
		if err := take(minTime(spared, now.Add(-p.Retention)), math.MaxInt64); err != nil {
			return nil, time.Time{}, err
		}
	}
	return picked, spared, nil
}

func minTime(a, b time.Time) time.Time {
	if b.Before(a) {
		return b
	}
	return a
}

// rows left unfinished stay evicting and the next pass retries them.
func (p *Pruner) evict(ctx context.Context, rows []db.NixObjectRow) (gone []db.NixObjectRow, failed map[string]bool, down bool, err error) {
	byCache := map[string][]db.NixObjectRow{}
	for _, row := range rows {
		byCache[row.Cache] = append(byCache[row.Cache], row)
	}
	var errs []error
	failed = map[string]bool{}
	for _, cache := range slices.Sorted(maps.Keys(byCache)) {
		for batch := range slices.Chunk(byCache[cache], deleteBatch) {
			if err := p.deleteBatch(ctx, cache, batch); err != nil {
				errs = append(errs, fmt.Errorf("evicting from %s: %w", cache, err))
				failed[cache] = true
				if unreachable(err) {
					return gone, failed, true, errors.Join(errs...)
				}
				break
			}
			gone = append(gone, batch...)
		}
	}
	return gone, failed, false, errors.Join(errs...)
}

var _ quota.CacheKind = (*Pruner)(nil)

// a subject with paths still evicting lists nothing new. otherwise a cache attic keeps refusing gets marked again every pass
func (p *Pruner) Evictable(ctx context.Context, s quota.Subject, limit int) ([]quota.CacheEntry, error) {
	f := db.NixEvictFilter{RepoDID: s.DID}
	if s.Scope == quota.ScopeUser {
		f = db.NixEvictFilter{OwnerDID: s.DID}
	}
	stuck, err := p.Index.NixEvicting(ctx)
	if err != nil {
		return nil, err
	}
	for _, row := range stuck {
		if row.RepoDID == f.RepoDID || row.OwnerDID == f.OwnerDID {
			return nil, nil
		}
	}
	grace, err := p.grace(ctx)
	if err != nil {
		return nil, err
	}
	rows, err := p.Index.NixEvictable(ctx, f, p.clock().Add(-grace), limit)
	if err != nil {
		return nil, err
	}
	entries := make([]quota.CacheEntry, len(rows))
	for i, row := range rows {
		entries[i] = quota.CacheEntry{
			Key:      quota.NixCacheKey(row.Cache, row.StoreHash),
			RepoDID:  row.RepoDID,
			OwnerDID: row.OwnerDID,
			Size:     row.NarSize,
			LastUsed: row.LastUsedAt,
			Version:  row.Gen,
		}
	}
	return entries, nil
}

// paths left evicting by a failed delete stay for Prune to retry
func (p *Pruner) Evict(ctx context.Context, entries []quota.CacheEntry) ([]quota.CacheEntry, error) {
	rows := make([]db.NixObjectRow, 0, len(entries))
	byKey := make(map[string]quota.CacheEntry, len(entries))
	for _, e := range entries {
		i := strings.LastIndex(e.Key, "/")
		if i < 0 {
			return nil, fmt.Errorf("not a nix cache key: %q", e.Key)
		}
		rows = append(rows, db.NixObjectRow{
			Cache: e.Key[:i], StoreHash: e.Key[i+1:], RepoDID: e.RepoDID, OwnerDID: e.OwnerDID,
			NarSize: e.Size, LastUsedAt: e.LastUsed, Gen: e.Version,
		})
		byKey[e.Key] = e
	}
	grace, err := p.grace(ctx)
	if err != nil {
		return nil, err
	}
	marked, err := p.Index.MarkNixEvicting(ctx, rows, p.clock().Add(-grace))
	if err != nil {
		return nil, err
	}
	gone, _, _, err := p.evict(ctx, marked)
	evicted := make([]quota.CacheEntry, len(gone))
	for i, row := range gone {
		evicted[i] = byKey[quota.NixCacheKey(row.Cache, row.StoreHash)]
	}
	return evicted, err
}

// attic's deleted list leaves out paths it never had, and a gone cache counts as done
func (p *Pruner) deleteBatch(ctx context.Context, cache string, batch []db.NixObjectRow) error {
	hashes := make([]string, len(batch))
	for i, row := range batch {
		hashes[i] = row.StoreHash
	}
	if _, err := p.Attic.DeletePaths(ctx, cache, hashes); err != nil && !errors.Is(err, ErrNotFound) {
		return err
	}
	return p.Index.FinishNixEviction(ctx, cache, batch)
}

// a transport error or a 5xx status means attic is down, and every cache would fail too
func unreachable(err error) bool {
	var transport *url.Error
	var atticErr *Error
	return errors.As(err, &transport) || errors.As(err, &atticErr) && atticErr.Status >= http.StatusInternalServerError
}