This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / engine / cache.go
9.5 kB 333 lines
1package engine 2 3import ( 4 "bufio" 5 "bytes" 6 "context" 7 "crypto/sha256" 8 "database/sql" 9 "encoding/hex" 10 "errors" 11 "fmt" 12 "io" 13 "log/slog" 14 "os/exec" 15 "strings" 16 "sync" 17 "time" 18 19 "github.com/google/uuid" 20 "tangled.org/core/spindle/db" 21 22 "tangled.org/core/spindle/models" 23 "tangled.org/core/spindle/storage" 24) 25 26// cache log steps live below the setup step (-1) 27const ( 28 CacheRestoreStepIdx = -2 29 CacheSaveStepIdx = -3 30) 31 32type cacheStep struct { 33 name string 34 command string 35} 36 37func (s cacheStep) Name() string { return s.name } 38func (s cacheStep) Command() string { return s.command } 39func (s cacheStep) Kind() models.StepKind { return models.StepKindSystem } 40 41var ( 42 CacheRestoreStep models.Step = cacheStep{name: "restore cache", command: "restore cached paths"} 43 CacheSaveStep models.Step = cacheStep{name: "save cache", command: "persist changed paths"} 44) 45 46type CacheRunner interface { 47 RestoreCache(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, store storage.Storage, caches []ResolvedCache, wfLogger models.WorkflowLogger) error 48 SaveCache(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, store storage.Storage, caches []ResolvedCache, wfLogger models.WorkflowLogger) error 49} 50 51type ResolvedCache struct { 52 Paths []string 53 Key string 54 Hash string 55 SaveKey string 56 RestoreID string 57 RestoreKey string 58 RestoreName string 59 CompressionLevel int 60 When string 61} 62 63func (rc ResolvedCache) saveOn(failed bool) bool { 64 return rc.When == "always" || !failed 65} 66 67const CacheExitNoPaths = 42 68 69// avoids storing an empty archive when zstd is missing 70const CacheExitNoCompressor = 43 71 72func AbsolutizePaths(paths []string, workspaceRoot string) []string { 73 abs := make([]string, 0, len(paths)) 74 for _, p := range paths { 75 if !strings.HasPrefix(p, "/") { 76 p = workspaceRoot + "/" + p 77 } 78 abs = append(abs, p) 79 } 80 return abs 81} 82 83// run tar from / so absolute paths survive extraction 84// tar exits nonzero on missing paths, so only existing ones reach it 85func CacheSaveScript(paths []string, workspaceRoot string, compressionLevel int) string { 86 trimmed := make([]string, 0, len(paths)) 87 for _, p := range AbsolutizePaths(paths, workspaceRoot) { 88 trimmed = append(trimmed, strings.TrimPrefix(p, "/")) 89 } 90 tail := fmt.Sprintf(`tar -cf - -C / "$@" | %s`, CacheCompressCmd(compressionLevel)) 91 return fmt.Sprintf(`set -o pipefail 92command -v zstd >/dev/null 2>&1 || { echo "zstd not found in image; cannot save cache" >&2; exit %d; } 93set -- 94for p in %s; do [ -e "/$p" ] && set -- "$@" "$p"; done 95if [ $# -eq 0 ]; then echo "no cache paths exist; skipping" >&2; exit %d; fi 96%s`, CacheExitNoCompressor, strings.Join(trimmed, " "), CacheExitNoPaths, tail) 97} 98 99func CacheCompressCmd(level int) string { 100 if level == 0 { 101 return "zstd -T0 -5" 102 } 103 return fmt.Sprintf("zstd -T0 -%d", level) 104} 105 106// old entries might still be gzip, so detect them instead of trusting config 107func CacheDecompressCmd(br *bufio.Reader) string { 108 head, _ := br.Peek(4) 109 if bytes.HasPrefix(head, []byte{0x1f, 0x8b}) { 110 return "gzip -dc" 111 } 112 return "zstd -dc" 113} 114 115// Put keeps draining the pipe after it returns, so the guest writer never 116// blocks on a full pipe. 117type CacheUpload struct { 118 Writer *io.PipeWriter 119 done chan error 120} 121 122func NewCacheUpload(ctx context.Context, store storage.Storage, key string) *CacheUpload { 123 pr, pw := io.Pipe() 124 u := &CacheUpload{Writer: pw, done: make(chan error, 1)} 125 go func() { 126 err := store.Put(ctx, key, pr) 127 _, _ = io.Copy(io.Discard, pr) 128 u.done <- err 129 }() 130 return u 131} 132 133func (u *CacheUpload) Abort(err error) { 134 u.Writer.CloseWithError(err) 135 <-u.done 136} 137 138// storage treats EOF as a complete archive, so only a clean exec may Finish 139func (u *CacheUpload) Finish() error { 140 u.Writer.Close() 141 return <-u.done 142} 143 144type indexedCacheStore struct { 145 storage.Storage 146 index *db.DB 147 logger *slog.Logger 148 mu sync.Mutex 149 entries map[string]string // storage key -> cache entry id 150} 151 152func (s *indexedCacheStore) Get(ctx context.Context, key string) (io.ReadCloser, error) { 153 s.mu.Lock() 154 id, ok := s.entries[key] 155 s.mu.Unlock() 156 r, err := s.Storage.Get(ctx, key) 157 if err != nil { 158 if ok && errors.Is(err, storage.ErrNotExist) { 159 _ = s.index.DeleteCacheEntry(context.WithoutCancel(ctx), id) 160 } 161 return nil, err 162 } 163 if ok { 164 if err := s.index.TouchCacheEntry(ctx, id, time.Now()); err != nil { 165 s.logger.Warn("cache usage update failed", "id", id, "err", err) 166 } 167 } 168 return r, nil 169} 170 171func (s *indexedCacheStore) Put(ctx context.Context, key string, r io.Reader) error { 172 s.mu.Lock() 173 id, ok := s.entries[key] 174 s.mu.Unlock() 175 if !ok { 176 return fmt.Errorf("cache metadata missing for %q", key) 177 } 178 counted := &countingReader{r: r} 179 if err := s.Storage.Put(ctx, key, counted); err != nil { 180 return err 181 } 182 superseded, err := s.index.MarkCacheEntryReady(ctx, id, counted.n, time.Now()) 183 if err != nil { 184 _ = s.Storage.Delete(context.WithoutCancel(ctx), key) 185 return fmt.Errorf("mark cache ready: %w", err) 186 } 187 // completed saves leave the pending map so cleanup keeps their object 188 s.mu.Lock() 189 delete(s.entries, key) 190 s.mu.Unlock() 191 cleanupCtx := context.WithoutCancel(ctx) 192 for _, old := range superseded { 193 s.deleteEntry(cleanupCtx, old.StorageKey, old.ID, "replaced") 194 } 195 return nil 196} 197 198func (s *indexedCacheStore) cleanup(ctx context.Context) { 199 s.mu.Lock() 200 defer s.mu.Unlock() 201 for key, id := range s.entries { 202 s.deleteEntry(ctx, key, id, "incomplete") 203 } 204} 205 206func (s *indexedCacheStore) deleteEntry(ctx context.Context, key, id, reason string) { 207 if err := s.Storage.Delete(ctx, key); err != nil { 208 s.logger.Warn("delete "+reason+" cache failed", "key", key, "err", err) 209 return 210 } 211 if err := s.index.DeleteCacheEntry(ctx, id); err != nil { 212 s.logger.Warn("delete "+reason+" cache metadata failed", "id", id, "err", err) 213 } 214} 215 216type countingReader struct { 217 r io.Reader 218 n int64 219} 220 221func (r *countingReader) Read(p []byte) (int, error) { 222 n, err := r.r.Read(p) 223 r.n += int64(n) 224 return n, err 225} 226 227func cacheStoreForRestore(base storage.Storage, index *db.DB, l *slog.Logger, entries []ResolvedCache) storage.Storage { 228 mapped := make(map[string]string, len(entries)) 229 for _, entry := range entries { 230 if entry.RestoreKey != "" { 231 mapped[entry.RestoreKey] = entry.RestoreID 232 } 233 } 234 return &indexedCacheStore{Storage: base, index: index, logger: l, entries: mapped} 235} 236 237func prepareCacheSaves(ctx context.Context, base storage.Storage, index *db.DB, l *slog.Logger, ownerDID, repoDID, engineName string, entries []ResolvedCache) (*indexedCacheStore, error) { 238 saveStore := &indexedCacheStore{ 239 Storage: base, 240 index: index, 241 logger: l, 242 entries: make(map[string]string, len(entries)), 243 } 244 now := time.Now() 245 for i := range entries { 246 id := uuid.NewString() 247 entries[i].SaveKey = "objects/" + id 248 if err := index.InsertCacheEntry(ctx, db.CacheEntry{ 249 ID: id, 250 StorageKey: entries[i].SaveKey, 251 OwnerDID: ownerDID, 252 RepoDID: repoDID, 253 Engine: engineName, 254 CacheKey: entries[i].Key, 255 CacheHash: entries[i].Hash, 256 State: "pending", 257 CreatedAt: now, 258 LastUsedAt: now, 259 }); err != nil { 260 saveStore.cleanup(context.WithoutCancel(ctx)) 261 return nil, err 262 } 263 saveStore.entries[entries[i].SaveKey] = id 264 } 265 return saveStore, nil 266} 267 268// on a hash miss, the newest older generation still warms the build 269// unusable entries degrade to a plain miss 270func ResolveCaches(ctx context.Context, l *slog.Logger, index *db.DB, repoDID, engine, repoPath, rev string, entries []models.CacheEntry) []ResolvedCache { 271 resolved := make([]ResolvedCache, 0, len(entries)) 272 for _, entry := range entries { 273 hash := "" 274 if len(entry.Hash) > 0 && repoPath != "" { 275 sum, missing := hashKeyFiles(ctx, repoPath, rev, entry.Hash) 276 for _, m := range missing { 277 l.Warn("cache hash file not in repo", "key", entry.Key, "path", m) 278 } 279 hash = sum 280 } 281 rc := ResolvedCache{ 282 Paths: entry.Paths, 283 Key: entry.Key, 284 Hash: hash, 285 CompressionLevel: entry.CompressionLevel, 286 When: entry.When, 287 } 288 289 found, err := index.FindCacheEntry(ctx, repoDID, engine, entry.Key, hash) 290 if errors.Is(err, sql.ErrNoRows) && hash != "" { 291 found, err = index.FindFallbackCacheEntry(ctx, repoDID, engine, entry.Key, hash) 292 if err == nil { 293 rc.RestoreName = entry.Key + "-" + found.CacheHash 294 } 295 } 296 if err != nil && !errors.Is(err, sql.ErrNoRows) { 297 l.Warn("cache lookup failed; entry will save but not restore", "key", entry.Key, "err", err) 298 } else if err == nil { 299 rc.RestoreID = found.ID 300 rc.RestoreKey = found.StorageKey 301 } 302 resolved = append(resolved, rc) 303 } 304 return resolved 305} 306 307func hashKeyFiles(ctx context.Context, repoPath, rev string, paths []string) (string, []string) { 308 h := sha256.New() 309 var missing []string 310 hashed := 0 311 for _, p := range paths { 312 blob, err := gitBlobId(ctx, repoPath, rev, p) 313 if err != nil { 314 missing = append(missing, p) 315 continue 316 } 317 fmt.Fprintf(h, "%s=%s\n", p, blob) 318 hashed++ 319 } 320 if hashed == 0 { 321 return "", missing 322 } 323 return hex.EncodeToString(h.Sum(nil))[:12], missing 324} 325 326// sparse checkouts might not have the file, but the object database does 327func gitBlobId(ctx context.Context, repoPath, rev, path string) (string, error) { 328 out, err := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", rev+":"+path).Output() 329 if err != nil { 330 return "", err 331 } 332 return strings.TrimSpace(string(out)), nil 333}