~james/core

core/spindle/db/nix_objects.go -rw-r--r-- 8.3 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 14 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
package db

import (
	"context"
	"database/sql"
	"encoding/json"
	"slices"
	"strings"
	"time"

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

var _ quota.NixUseRecorder = (*QuotaStore)(nil)

const (
	nixUseBatch  = 500
	nixDropBatch = 500
)

type NixObjectRow struct {
	Cache, StoreHash, RepoDID, OwnerDID string
	NarSize                             int64
	LastUsedAt                          time.Time
	Gen                                 int64
}

type NixEvictFilter struct {
	RepoDID, OwnerDID string
}

const nixObjectColumns = `cache, store_hash, repo_did, owner_did, nar_size, last_used_at, gen`

func scanNixObjects(rows *sql.Rows) ([]NixObjectRow, error) {
	defer rows.Close()
	var out []NixObjectRow
	for rows.Next() {
		var r NixObjectRow
		var lastUsed int64
		if err := rows.Scan(&r.Cache, &r.StoreHash, &r.RepoDID, &r.OwnerDID, &r.NarSize, &lastUsed, &r.Gen); err != nil {
			return nil, err
		}
		r.LastUsedAt = time.Unix(lastUsed, 0)
		out = append(out, r)
	}
	return out, rows.Err()
}

func (d *QuotaStore) upsertNixObject(ctx context.Context, conn execQuerier, repoDID, ownerDID, encoded string) error {
	var obj quota.NixObject
	if err := json.Unmarshal([]byte(encoded), &obj); err != nil {
		return err
	}
	now := d.now().Unix()
	if _, err := conn.ExecContext(ctx, `
		INSERT INTO nix_objects (cache, store_hash, repo_did, owner_did, store_path, nar_size, seq, last_used_at, state, gen)
		VALUES (?, ?, ?, ?, ?, ?, (SELECT coalesce(max(seq), 0) + 1 FROM nix_objects), ?, 'live', 1)
		ON CONFLICT(cache, store_hash) DO UPDATE SET
			repo_did = excluded.repo_did,
			owner_did = excluded.owner_did,
			store_path = excluded.store_path,
			nar_size = excluded.nar_size,
			last_used_at = excluded.last_used_at,
			state = 'live',
			gen = gen + 1
	`, obj.Cache, obj.StoreHash, repoDID, ownerDID, obj.StorePath, obj.NarSize, now); err != nil {
		return err
	}
	if _, err := conn.ExecContext(ctx, `DELETE FROM nix_refs WHERE cache = ? AND from_hash = ?`, obj.Cache, obj.StoreHash); err != nil {
		return err
	}
	for _, ref := range obj.References {
		hash, _, _ := strings.Cut(ref, "-")
		if hash == "" || hash == obj.StoreHash {
			continue
		}
		if _, err := conn.ExecContext(ctx, `
			INSERT OR IGNORE INTO nix_refs (cache, from_hash, to_hash) VALUES (?, ?, ?)
		`, obj.Cache, obj.StoreHash, hash); err != nil {
			return err
		}
		// bumping gen makes the pending finish skip it. if attic already deleted it, the reference dangles and nix builds it
		if _, err := conn.ExecContext(ctx, `
			UPDATE nix_objects SET state = 'live', gen = gen + 1
			WHERE cache = ? AND store_hash = ? AND state = 'evicting'
		`, obj.Cache, hash); err != nil {
			return err
		}
	}
	return nil
}

func (d *QuotaStore) RecordNixUse(ctx context.Context, cache string, storeHashes []string) error {
	return d.withTx(ctx, func(conn *sql.Conn) error {
		now := d.now().Unix()
		for batch := range slices.Chunk(storeHashes, nixUseBatch) {
			args := []any{now, cache}
			for _, hash := range batch {
				args = append(args, hash)
			}
			if _, err := conn.ExecContext(ctx, `
				UPDATE nix_objects SET last_used_at = max(last_used_at, ?)
				WHERE cache = ? AND store_hash IN (`+placeholders(len(batch))+`)
			`, args...); err != nil {
				return err
			}
		}
		return nil
	})
}

// NixCacheUsage counts evicting rows too because their bytes stay in the cache until the delete lands
func (d *QuotaStore) NixCacheUsage(ctx context.Context) (byRepo, byOwner map[string]int64, total int64, err error) {
	byRepo, byOwner = map[string]int64{}, map[string]int64{}
	err = d.withReadTx(ctx, func(tx *sql.Tx) error {
		rows, err := tx.QueryContext(ctx, `SELECT repo_did, owner_did, SUM(nar_size) FROM nix_objects GROUP BY repo_did, owner_did`)
		if err != nil {
			return err
		}
		defer rows.Close()
		for rows.Next() {
			var repo, owner string
			var size int64
			if err := rows.Scan(&repo, &owner, &size); err != nil {
				return err
			}
			byRepo[repo] += size
			byOwner[owner] += size
			total += size
		}
		return rows.Err()
	})
	return byRepo, byOwner, total, err
}

// a referrer pins its dependencies only if committed after them. a cycle can't pin itself that way
// an out of order upload can leave a dangling reference that nix builds
const noReferrer = `NOT EXISTS (
	SELECT 1 FROM nix_refs r
	JOIN nix_objects p ON p.cache = r.cache AND p.store_hash = r.from_hash AND p.seq > o.seq
	WHERE r.cache = o.cache AND r.to_hash = o.store_hash
)`

func (d *QuotaStore) NixEvictable(ctx context.Context, f NixEvictFilter, usedBefore time.Time, limit int) ([]NixObjectRow, error) {
	var out []NixObjectRow
	err := d.withReadTx(ctx, func(tx *sql.Tx) error {
		where, args := `state = 'live' AND last_used_at < ?`, []any{usedBefore.Unix()}
		if f.RepoDID != "" {
			where += ` AND repo_did = ?`
			args = append(args, f.RepoDID)
		}
		if f.OwnerDID != "" {
			where += ` AND owner_did = ?`
			args = append(args, f.OwnerDID)
		}
		rows, err := tx.QueryContext(ctx, `
			SELECT `+nixObjectColumns+` FROM nix_objects o
			WHERE `+where+` AND `+noReferrer+`
			ORDER BY last_used_at, cache, store_hash
			LIMIT ?
		`, append(args, limit)...)
		if err != nil {
			return err
		}
		out, err = scanNixObjects(rows)
		return err
	})
	return out, err
}

func (d *QuotaStore) MarkNixEvicting(ctx context.Context, rows []NixObjectRow, usedBefore time.Time) ([]NixObjectRow, error) {
	var marked []NixObjectRow
	err := d.withTx(ctx, func(conn *sql.Conn) error {
		marked = nil
		for _, r := range rows {
			res, err := conn.ExecContext(ctx, `
				UPDATE nix_objects AS o SET state = 'evicting'
				WHERE cache = ? AND store_hash = ? AND state = 'live' AND gen = ? AND last_used_at < ? AND `+noReferrer,
				r.Cache, r.StoreHash, r.Gen, usedBefore.Unix())
			if err != nil {
				return err
			}
			if n, err := res.RowsAffected(); err != nil {
				return err
			} else if n == 1 {
				marked = append(marked, r)
			}
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return marked, nil
}

func (d *QuotaStore) NixEvicting(ctx context.Context) ([]NixObjectRow, error) {
	var out []NixObjectRow
	err := d.withReadTx(ctx, func(tx *sql.Tx) error {
		rows, err := tx.QueryContext(ctx, `
			SELECT `+nixObjectColumns+` FROM nix_objects WHERE state = 'evicting' ORDER BY cache, store_hash
		`)
		if err != nil {
			return err
		}
		out, err = scanNixObjects(rows)
		return err
	})
	return out, err
}

// FinishNixEviction skips a row that was uploaded again, because that bumps its gen
// one tx per chunk, and a failure leaves the rest evicting for the next pass
func (d *QuotaStore) FinishNixEviction(ctx context.Context, cache string, gone []NixObjectRow) error {
	for chunk := range slices.Chunk(gone, nixDropBatch) {
		err := d.withTx(ctx, func(conn *sql.Conn) error {
			for _, r := range chunk {
				res, err := conn.ExecContext(ctx, `
					DELETE FROM nix_objects WHERE cache = ? AND store_hash = ? AND state = 'evicting' AND gen = ?`,
					cache, r.StoreHash, r.Gen)
				if err != nil {
					return err
				}
				if n, err := res.RowsAffected(); err != nil {
					return err
				} else if n != 1 {
					continue
				}
				if _, err := conn.ExecContext(ctx, `DELETE FROM nix_refs WHERE cache = ? AND from_hash = ?`, cache, r.StoreHash); err != nil {
					return err
				}
				if _, err := conn.ExecContext(ctx, `DELETE FROM quota_allocations WHERE repo_did = ? AND kind = ? AND key = ?`,
					r.RepoDID, quota.KindNixCache, quota.NixCacheKey(cache, r.StoreHash)); err != nil {
					return err
				}
			}
			return nil
		})
		if err != nil {
			return err
		}
	}
	return nil
}

func placeholders(n int) string {
	return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}

func (d *QuotaStore) RecordNixGrant(ctx context.Context, expires time.Time, life time.Duration) error {
	return d.withTx(ctx, func(conn *sql.Conn) error {
		if _, err := conn.ExecContext(ctx, `DELETE FROM nix_grants WHERE expires_at <= ?`, d.now().Unix()); err != nil {
			return err
		}
		_, err := conn.ExecContext(ctx, `INSERT INTO nix_grants (expires_at, life) VALUES (?, ?)`,
			expires.Add(time.Second-1).Unix(), int64((life+time.Second-1)/time.Second))
		return err
	})
}

func (d *QuotaStore) LongestLiveNixGrant(ctx context.Context, now time.Time) (time.Duration, error) {
	var life int64
	err := d.withReadTx(ctx, func(tx *sql.Tx) error {
		return tx.QueryRowContext(ctx, `SELECT coalesce(max(life), 0) FROM nix_grants WHERE expires_at > ?`, now.Unix()).Scan(&life)
	})
	return time.Duration(life) * time.Second, err
}