fix(snapshot): make maintenance traversal fair
- commit
- 9e04e97f33366f56b9686a01052a8f48ef05065e
- author
- brice · · 2026-07-21 20:57 UTC
- committer
- brice · · 2026-07-21 20:57 UTC
- parent
- b1b3fa9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +418 -121
M internal/adapters/snapshot/maintenance.go +321 -121
| @@ -107,22 +107,33 @@ func pendingQuarantine(dir, key string) (bool, error) { | |||
| 107 | 107 | } | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | - | // Maintain reaps a bounded amount of stale state. Every scan uses ReadDir with | |
| 111 | - | // a fixed batch, and a quarantine tree is peeled one entry at a time so an | |
| 112 | - | // interrupted pass can continue after another call or restart. | |
| 113 | - | func (r *Repository) Maintain(ctx context.Context) error { | |
| 110 | + | // Maintain reaps a bounded amount of stale state. Continuation handles are | |
| 111 | + | // repository-owned, so a stable prefix cannot starve entries later in a large | |
| 112 | + | // directory. Cancellation and errors discard those handles: a restarted | |
| 113 | + | // repository (and the next fresh cycle) always begins at a safe boundary. | |
| 114 | + | func (r *Repository) Maintain(ctx context.Context) (err error) { | |
| 115 | + | r.maintenanceMu.Lock() | |
| 116 | + | defer r.maintenanceMu.Unlock() | |
| 114 | 117 | if err := ctx.Err(); err != nil { | |
| 118 | + | r.resetMaintenance() | |
| 115 | 119 | return err | |
| 116 | 120 | } | |
| 121 | + | defer func() { | |
| 122 | + | if err != nil { | |
| 123 | + | r.resetMaintenance() | |
| 124 | + | } | |
| 125 | + | }() | |
| 126 | + | if r.maintenanceCursors == nil { | |
| 127 | + | r.maintenanceCursors = make(map[string]*maintenanceCursor) | |
| 128 | + | r.maintenanceSessions = make(map[string]*sessionMaintenance) | |
| 129 | + | } | |
| 130 | + | ||
| 117 | 131 | budget := maintenanceBatch | |
| 118 | - | if err := r.removeTemps(ctx, r.dir, &budget); err != nil { | |
| 132 | + | if err := r.removeTemps(ctx, r.dir, &budget, "root-temps"); err != nil || budget == 0 { | |
| 119 | 133 | return err | |
| 120 | 134 | } | |
| 121 | - | if budget == 0 { | |
| 122 | - | return nil | |
| 123 | - | } | |
| 124 | 135 | sessions := filepath.Join(r.dir, repositorySessionsDir) | |
| 125 | - | entries, err := readDirBatch(sessions, maintenanceBatch) | |
| 136 | + | entries, _, err := r.readMaintenanceDir(sessions, maintenanceBatch, "sessions") | |
| 126 | 137 | if errors.Is(err, os.ErrNotExist) { | |
| 127 | 138 | return nil | |
| 128 | 139 | } | |
| @@ -157,11 +168,7 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 157 | 168 | } | |
| 158 | 169 | lock := r.sessionLock(entry.Name()) | |
| 159 | 170 | lock.Lock() | |
| 160 | - | if err := ctx.Err(); err != nil { | |
| 161 | - | lock.Unlock() | |
| 162 | - | return err | |
| 163 | - | } | |
| 164 | - | err := r.maintainSession(ctx, entry.Name(), &budget) | |
| 171 | + | err = r.maintainSession(ctx, entry.Name(), &budget) | |
| 165 | 172 | lock.Unlock() | |
| 166 | 173 | if err != nil { | |
| 167 | 174 | return err | |
| @@ -170,8 +177,68 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||
| 170 | 177 | return nil | |
| 171 | 178 | } | |
| 172 | 179 | ||
| 180 | + | type maintenanceCursor struct{ file *os.File } | |
| 181 | + | ||
| 182 | + | type manifestMaintenance struct { | |
| 183 | + | refs map[ports.SnapshotDigest]codec.ObjectRef | |
| 184 | + | complete bool | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | type sessionMaintenance struct { | |
| 188 | + | token string | |
| 189 | + | marked map[uint64]manifestMaintenance | |
| 190 | + | uncertain bool | |
| 191 | + | markDone bool | |
| 192 | + | manifestQueue []uint64 | |
| 193 | + | sweepQueue []string | |
| 194 | + | sweepRootDone bool | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (r *Repository) resetMaintenance() { | |
| 198 | + | for _, cursor := range r.maintenanceCursors { | |
| 199 | + | _ = cursor.file.Close() | |
| 200 | + | } | |
| 201 | + | r.maintenanceCursors = nil | |
| 202 | + | r.maintenanceSessions = nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // readMaintenanceDir advances one named directory cursor. It closes and | |
| 206 | + | // forgets the handle at EOF, so the following call begins a fresh cycle. | |
| 207 | + | func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]os.DirEntry, bool, error) { | |
| 208 | + | id := purpose + "\x00" + dir | |
| 209 | + | cursor := r.maintenanceCursors[id] | |
| 210 | + | if cursor == nil { | |
| 211 | + | file, err := os.Open(dir) | |
| 212 | + | if err != nil { | |
| 213 | + | return nil, false, err | |
| 214 | + | } | |
| 215 | + | cursor = &maintenanceCursor{file: file} | |
| 216 | + | r.maintenanceCursors[id] = cursor | |
| 217 | + | } | |
| 218 | + | entries, err := cursor.file.ReadDir(n) | |
| 219 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 220 | + | delete(r.maintenanceCursors, id) | |
| 221 | + | closeErr := cursor.file.Close() | |
| 222 | + | if closeErr != nil { | |
| 223 | + | return nil, false, closeErr | |
| 224 | + | } | |
| 225 | + | return nil, false, err | |
| 226 | + | } | |
| 227 | + | if !errors.Is(err, io.EOF) && len(entries) == n { | |
| 228 | + | return entries, false, nil | |
| 229 | + | } | |
| 230 | + | delete(r.maintenanceCursors, id) | |
| 231 | + | if closeErr := cursor.file.Close(); closeErr != nil { | |
| 232 | + | return nil, false, closeErr | |
| 233 | + | } | |
| 234 | + | return entries, true, nil | |
| 235 | + | } | |
| 236 | + | ||
| 173 | 237 | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | |
| 174 | 238 | ||
| 239 | + | // readDirBatch is used only for a mutating quarantine walk, where every | |
| 240 | + | // successful step removes the entry it selected. General maintenance scans use | |
| 241 | + | // repository-owned cursors above. | |
| 175 | 242 | func readDirBatch(dir string, n int) ([]os.DirEntry, error) { | |
| 176 | 243 | f, err := os.Open(dir) | |
| 177 | 244 | if err != nil { | |
| @@ -221,6 +288,8 @@ func (r *Repository) removeTreeStep(ctx context.Context, path string, budget *in | |||
| 221 | 288 | *budget-- | |
| 222 | 289 | return true, nil | |
| 223 | 290 | } | |
| 291 | + | // A quarantine tree contains no live entries. Reopening from its start is | |
| 292 | + | // safe here because each successful step removes that first descendant. | |
| 224 | 293 | entries, err := readDirBatch(path, 1) | |
| 225 | 294 | if err != nil { | |
| 226 | 295 | return false, err | |
| @@ -242,11 +311,16 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||
| 242 | 311 | if err := ctx.Err(); err != nil { | |
| 243 | 312 | return err | |
| 244 | 313 | } | |
| 245 | - | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget); err != nil { | |
| 314 | + | state, err := r.sessionMaintenanceState(key) | |
| 315 | + | if err != nil { | |
| 246 | 316 | return err | |
| 247 | 317 | } | |
| 318 | + | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 { | |
| 319 | + | return err | |
| 320 | + | } | |
| 321 | + | ||
| 248 | 322 | objectRoot := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 249 | - | shards, err := readDirBatch(objectRoot, maintenanceBatch) | |
| 323 | + | shards, _, err := r.readMaintenanceDir(objectRoot, maintenanceBatch, "object-temps-shards:"+key) | |
| 250 | 324 | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 251 | 325 | return fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | |
| 252 | 326 | } | |
| @@ -255,160 +329,286 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||
| 255 | 329 | return nil | |
| 256 | 330 | } | |
| 257 | 331 | if shard.IsDir() { | |
| 258 | - | if err := r.removeTemps(ctx, filepath.Join(objectRoot, shard.Name()), budget); err != nil { | |
| 332 | + | if err := r.removeTemps(ctx, filepath.Join(objectRoot, shard.Name()), budget, "object-temps:"+key+":"+shard.Name()); err != nil { | |
| 259 | 333 | return err | |
| 260 | 334 | } | |
| 261 | 335 | } | |
| 262 | 336 | } | |
| 337 | + | if *budget == 0 { | |
| 338 | + | return nil | |
| 339 | + | } | |
| 340 | + | if !state.markDone { | |
| 341 | + | if err := r.markSession(ctx, key, state); err != nil { | |
| 342 | + | return err | |
| 343 | + | } | |
| 344 | + | if !state.markDone { | |
| 345 | + | return nil | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 { | |
| 349 | + | return err | |
| 350 | + | } | |
| 351 | + | if state.uncertain { | |
| 352 | + | // An unreadable or invalid manifest may reference any existing blob. Do | |
| 353 | + | // not sweep in this cycle; retry marking from a fresh directory pass. | |
| 354 | + | r.clearSessionMaintenance(key) | |
| 355 | + | return nil | |
| 356 | + | } | |
| 357 | + | if err := r.sweepSession(ctx, key, state, budget); err != nil { | |
| 358 | + | return err | |
| 359 | + | } | |
| 360 | + | return nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, error) { | |
| 364 | + | token, exists, err := r.maintenanceToken(key) | |
| 365 | + | if err != nil { | |
| 366 | + | return nil, err | |
| 367 | + | } | |
| 368 | + | if !exists { | |
| 369 | + | r.clearSessionMaintenance(key) | |
| 370 | + | return &sessionMaintenance{token: "missing", marked: make(map[uint64]manifestMaintenance)}, nil | |
| 371 | + | } | |
| 372 | + | state := r.maintenanceSessions[key] | |
| 373 | + | if state == nil || state.token != token { | |
| 374 | + | r.clearSessionMaintenance(key) | |
| 375 | + | state = &sessionMaintenance{token: token, marked: make(map[uint64]manifestMaintenance)} | |
| 376 | + | r.maintenanceSessions[key] = state | |
| 377 | + | } | |
| 378 | + | return state, nil | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // maintenanceToken is the publication boundary. Publish and Delete share the | |
| 382 | + | // per-session lock with maintenance, and a changed HEAD on a later call makes | |
| 383 | + | // us discard partial marking/sweeping before deleting anything else. | |
| 384 | + | func (r *Repository) maintenanceToken(key string) (string, bool, error) { | |
| 385 | + | data, exists, err := readOptionalBounded(r.headPath(key)) | |
| 386 | + | if errors.Is(err, os.ErrNotExist) { | |
| 387 | + | return "", false, nil | |
| 388 | + | } | |
| 389 | + | if err != nil { | |
| 390 | + | return "", false, err | |
| 391 | + | } | |
| 392 | + | if !exists { | |
| 393 | + | return "", false, nil | |
| 394 | + | } | |
| 395 | + | return string(data), true, nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func (r *Repository) clearSessionMaintenance(key string) { | |
| 399 | + | delete(r.maintenanceSessions, key) | |
| 400 | + | prefix := "\x00" + filepath.Clean(r.sessionPath(key)) | |
| 401 | + | for id, cursor := range r.maintenanceCursors { | |
| 402 | + | if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") { | |
| 403 | + | _ = cursor.file.Close() | |
| 404 | + | delete(r.maintenanceCursors, id) | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | } | |
| 263 | 408 | ||
| 264 | - | generations, err := r.generationNumbersBatch(key) | |
| 409 | + | func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error { | |
| 410 | + | dir := filepath.Join(r.sessionPath(key), repositoryGenerations) | |
| 411 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key) | |
| 412 | + | if errors.Is(err, os.ErrNotExist) { | |
| 413 | + | state.markDone = true | |
| 414 | + | return nil | |
| 415 | + | } | |
| 265 | 416 | if err != nil { | |
| 266 | 417 | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | |
| 267 | 418 | } | |
| 268 | - | type manifestState struct { | |
| 269 | - | generation uint64 | |
| 270 | - | refs map[ports.SnapshotDigest]codec.ObjectRef | |
| 271 | - | complete bool | |
| 272 | - | } | |
| 273 | - | states := make([]manifestState, 0, len(generations)) | |
| 274 | - | complete := 0 | |
| 275 | - | for _, generation := range generations { | |
| 419 | + | for _, entry := range entries { | |
| 276 | 420 | if err := ctx.Err(); err != nil { | |
| 277 | 421 | return err | |
| 278 | 422 | } | |
| 279 | - | state := manifestState{generation: generation} | |
| 423 | + | if entry.IsDir() { | |
| 424 | + | continue | |
| 425 | + | } | |
| 426 | + | generation, ok := parseGenerationFilename(entry.Name()) | |
| 427 | + | if !ok { | |
| 428 | + | continue | |
| 429 | + | } | |
| 430 | + | item := manifestMaintenance{} | |
| 280 | 431 | data, err := readBounded(r.manifestPath(key, generation)) | |
| 281 | - | if err == nil { | |
| 282 | - | manifest, decodeErr := codec.UnmarshalManifest(data) | |
| 283 | - | if decodeErr == nil && manifest.Generation == generation && sessionKey(manifest.Name) == key { | |
| 284 | - | state.refs = manifestRefs(manifest) | |
| 285 | - | if state.refs != nil && withinGenerationBudget(len(data), state.refs) { | |
| 286 | - | _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation) | |
| 287 | - | state.complete = loadErr == nil | |
| 288 | - | } | |
| 289 | - | } | |
| 432 | + | if err != nil { | |
| 433 | + | state.uncertain = true | |
| 434 | + | state.marked[generation] = item | |
| 435 | + | continue | |
| 436 | + | } | |
| 437 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 438 | + | if err != nil || manifest.Generation != generation || sessionKey(manifest.Name) != key { | |
| 439 | + | state.uncertain = true | |
| 440 | + | state.marked[generation] = item | |
| 441 | + | continue | |
| 442 | + | } | |
| 443 | + | item.refs = manifestRefs(manifest) | |
| 444 | + | if item.refs == nil || !withinGenerationBudget(len(data), item.refs) { | |
| 445 | + | state.uncertain = true | |
| 446 | + | state.marked[generation] = item | |
| 447 | + | continue | |
| 290 | 448 | } | |
| 291 | - | states = append(states, state) | |
| 449 | + | // Valid but incomplete generations are marked too. This protects blobs | |
| 450 | + | // written before a failed or in-flight publication reaches HEAD. | |
| 451 | + | _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation) | |
| 452 | + | item.complete = loadErr == nil | |
| 453 | + | state.marked[generation] = item | |
| 292 | 454 | } | |
| 455 | + | if done { | |
| 456 | + | state.markDone = true | |
| 457 | + | state.manifestQueue = retainedManifestQueue(state.marked) | |
| 458 | + | } | |
| 459 | + | return nil | |
| 460 | + | } | |
| 293 | 461 | ||
| 294 | - | retained := make(map[uint64]bool, 2) | |
| 295 | - | for _, state := range states { | |
| 296 | - | if state.complete && complete < 2 { | |
| 297 | - | retained[state.generation] = true | |
| 298 | - | complete++ | |
| 462 | + | func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 { | |
| 463 | + | complete := make([]uint64, 0, len(marked)) | |
| 464 | + | for generation, item := range marked { | |
| 465 | + | if item.complete { | |
| 466 | + | complete = append(complete, generation) | |
| 299 | 467 | } | |
| 300 | 468 | } | |
| 301 | - | referenced := make(map[ports.SnapshotDigest]struct{}) | |
| 302 | - | for _, state := range states { | |
| 303 | - | keep := retained[state.generation] || !state.complete | |
| 304 | - | if !keep && *budget > 0 { | |
| 305 | - | path := r.manifestPath(key, state.generation) | |
| 306 | - | if err := ctx.Err(); err != nil { | |
| 307 | - | return err | |
| 308 | - | } | |
| 309 | - | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 310 | - | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 311 | - | } | |
| 312 | - | if err := ctx.Err(); err != nil { | |
| 313 | - | return err | |
| 469 | + | for i := range complete { | |
| 470 | + | for j := i + 1; j < len(complete); j++ { | |
| 471 | + | if complete[j] > complete[i] { | |
| 472 | + | complete[i], complete[j] = complete[j], complete[i] | |
| 314 | 473 | } | |
| 315 | - | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | |
| 316 | - | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 317 | - | } | |
| 318 | - | *budget-- | |
| 319 | - | continue | |
| 320 | 474 | } | |
| 321 | - | for digest := range state.refs { | |
| 322 | - | referenced[digest] = struct{}{} | |
| 475 | + | } | |
| 476 | + | if len(complete) <= 2 { | |
| 477 | + | return nil | |
| 478 | + | } | |
| 479 | + | return complete[2:] | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, state *sessionMaintenance, budget *int) error { | |
| 483 | + | for len(state.manifestQueue) != 0 && *budget > 0 { | |
| 484 | + | if err := ctx.Err(); err != nil { | |
| 485 | + | return err | |
| 323 | 486 | } | |
| 487 | + | generation := state.manifestQueue[0] | |
| 488 | + | path := r.manifestPath(key, generation) | |
| 489 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 490 | + | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 491 | + | } | |
| 492 | + | if err := ctx.Err(); err != nil { | |
| 493 | + | return err | |
| 494 | + | } | |
| 495 | + | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | |
| 496 | + | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 497 | + | } | |
| 498 | + | state.manifestQueue = state.manifestQueue[1:] | |
| 499 | + | *budget-- | |
| 324 | 500 | } | |
| 325 | - | return r.removeUnreferencedObjects(ctx, key, referenced, budget) | |
| 501 | + | return nil | |
| 326 | 502 | } | |
| 327 | 503 | ||
| 328 | - | func (r *Repository) generationNumbersBatch(key string) ([]uint64, error) { | |
| 329 | - | entries, err := readDirBatch(filepath.Join(r.sessionPath(key), repositoryGenerations), maintenanceBatch) | |
| 330 | - | if errors.Is(err, os.ErrNotExist) { | |
| 331 | - | return nil, nil | |
| 504 | + | func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error { | |
| 505 | + | referenced := retainedReferences(state.marked) | |
| 506 | + | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 507 | + | if !state.sweepRootDone { | |
| 508 | + | entries, done, err := r.readMaintenanceDir(root, maintenanceBatch, "sweep-shards:"+key) | |
| 509 | + | if errors.Is(err, os.ErrNotExist) { | |
| 510 | + | state.sweepRootDone = true | |
| 511 | + | } else if err != nil { | |
| 512 | + | return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err)) | |
| 513 | + | } else { | |
| 514 | + | for _, entry := range entries { | |
| 515 | + | if entry.IsDir() { | |
| 516 | + | state.sweepQueue = append(state.sweepQueue, entry.Name()) | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | state.sweepRootDone = done | |
| 520 | + | } | |
| 332 | 521 | } | |
| 333 | - | if err != nil { | |
| 334 | - | return nil, err | |
| 522 | + | if len(state.sweepQueue) != 0 && *budget > 0 { | |
| 523 | + | shard := state.sweepQueue[0] | |
| 524 | + | done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard) | |
| 525 | + | if err != nil { | |
| 526 | + | return err | |
| 527 | + | } | |
| 528 | + | if done { | |
| 529 | + | state.sweepQueue = state.sweepQueue[1:] | |
| 530 | + | } | |
| 335 | 531 | } | |
| 336 | - | numbers := make([]uint64, 0, len(entries)) | |
| 337 | - | for _, entry := range entries { | |
| 338 | - | if !entry.IsDir() { | |
| 339 | - | if generation, ok := parseGenerationFilename(entry.Name()); ok { | |
| 340 | - | numbers = append(numbers, generation) | |
| 532 | + | if state.sweepRootDone && len(state.sweepQueue) == 0 { | |
| 533 | + | r.clearSessionMaintenance(key) | |
| 534 | + | } | |
| 535 | + | return nil | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func retainedReferences(marked map[uint64]manifestMaintenance) map[ports.SnapshotDigest]struct{} { | |
| 539 | + | complete := make([]uint64, 0, len(marked)) | |
| 540 | + | for generation, item := range marked { | |
| 541 | + | if item.complete { | |
| 542 | + | complete = append(complete, generation) | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | for i := range complete { | |
| 546 | + | for j := i + 1; j < len(complete); j++ { | |
| 547 | + | if complete[j] > complete[i] { | |
| 548 | + | complete[i], complete[j] = complete[j], complete[i] | |
| 341 | 549 | } | |
| 342 | 550 | } | |
| 343 | 551 | } | |
| 344 | - | // Generation file names are zero-padded, but keep the explicit comparison | |
| 345 | - | // in case the representation changes. | |
| 346 | - | for i := range numbers { | |
| 347 | - | for j := i + 1; j < len(numbers); j++ { | |
| 348 | - | if numbers[j] > numbers[i] { | |
| 349 | - | numbers[i], numbers[j] = numbers[j], numbers[i] | |
| 552 | + | keep := make(map[uint64]bool, 2) | |
| 553 | + | for _, generation := range complete { | |
| 554 | + | if len(keep) == 2 { | |
| 555 | + | break | |
| 556 | + | } | |
| 557 | + | keep[generation] = true | |
| 558 | + | } | |
| 559 | + | references := make(map[ports.SnapshotDigest]struct{}) | |
| 560 | + | for generation, item := range marked { | |
| 561 | + | if !item.complete || keep[generation] { | |
| 562 | + | for digest := range item.refs { | |
| 563 | + | references[digest] = struct{}{} | |
| 350 | 564 | } | |
| 351 | 565 | } | |
| 352 | 566 | } | |
| 353 | - | return numbers, nil | |
| 567 | + | return references | |
| 354 | 568 | } | |
| 355 | 569 | ||
| 356 | - | func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, referenced map[ports.SnapshotDigest]struct{}, budget *int) error { | |
| 357 | - | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | |
| 358 | - | shards, err := readDirBatch(root, maintenanceBatch) | |
| 570 | + | func (r *Repository) sweepShard(ctx context.Context, dir string, referenced map[ports.SnapshotDigest]struct{}, budget *int, key, shard string) (bool, error) { | |
| 571 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "sweep-objects:"+key+":"+shard) | |
| 359 | 572 | if errors.Is(err, os.ErrNotExist) { | |
| 360 | - | return nil | |
| 573 | + | return true, nil | |
| 361 | 574 | } | |
| 362 | 575 | if err != nil { | |
| 363 | - | return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err)) | |
| 576 | + | return false, fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | |
| 364 | 577 | } | |
| 365 | - | for _, shard := range shards { | |
| 578 | + | for _, object := range entries { | |
| 366 | 579 | if *budget == 0 { | |
| 367 | - | return nil | |
| 580 | + | break | |
| 581 | + | } | |
| 582 | + | if err := ctx.Err(); err != nil { | |
| 583 | + | return false, err | |
| 368 | 584 | } | |
| 369 | - | if !shard.IsDir() { | |
| 585 | + | digest, ok := parseObjectDigest(object.Name()) | |
| 586 | + | if !ok || object.IsDir() { | |
| 370 | 587 | continue | |
| 371 | 588 | } | |
| 372 | - | dir := filepath.Join(root, shard.Name()) | |
| 373 | - | objects, err := readDirBatch(dir, maintenanceBatch) | |
| 374 | - | if err != nil { | |
| 375 | - | return fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | |
| 589 | + | if _, used := referenced[digest]; used { | |
| 590 | + | continue | |
| 376 | 591 | } | |
| 377 | - | for _, object := range objects { | |
| 378 | - | if *budget == 0 { | |
| 379 | - | return nil | |
| 380 | - | } | |
| 381 | - | if err := ctx.Err(); err != nil { | |
| 382 | - | return err | |
| 383 | - | } | |
| 384 | - | digest, ok := parseObjectDigest(object.Name()) | |
| 385 | - | if !ok || object.IsDir() { | |
| 386 | - | continue | |
| 387 | - | } | |
| 388 | - | if _, used := referenced[digest]; used { | |
| 389 | - | continue | |
| 390 | - | } | |
| 391 | - | path := filepath.Join(dir, object.Name()) | |
| 392 | - | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 393 | - | return fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 394 | - | } | |
| 395 | - | if err := ctx.Err(); err != nil { | |
| 396 | - | return err | |
| 397 | - | } | |
| 398 | - | if err := r.syncDirectory(dir); err != nil { | |
| 399 | - | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 400 | - | } | |
| 401 | - | *budget-- | |
| 592 | + | path := filepath.Join(dir, object.Name()) | |
| 593 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 594 | + | return false, fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 595 | + | } | |
| 596 | + | if err := ctx.Err(); err != nil { | |
| 597 | + | return false, err | |
| 402 | 598 | } | |
| 599 | + | if err := r.syncDirectory(dir); err != nil { | |
| 600 | + | return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |
| 601 | + | } | |
| 602 | + | *budget-- | |
| 403 | 603 | } | |
| 404 | - | return nil | |
| 604 | + | return done, nil | |
| 405 | 605 | } | |
| 406 | 606 | ||
| 407 | - | func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) error { | |
| 607 | + | func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int, purpose string) error { | |
| 408 | 608 | if *budget == 0 { | |
| 409 | 609 | return nil | |
| 410 | 610 | } | |
| 411 | - | entries, err := readDirBatch(dir, maintenanceBatch) | |
| 611 | + | entries, _, err := r.readMaintenanceDir(dir, maintenanceBatch, purpose) | |
| 412 | 612 | if errors.Is(err, os.ErrNotExist) { | |
| 413 | 613 | return nil | |
| 414 | 614 | } | |
| @@ -107,22 +107,33 @@ func pendingQuarantine(dir, key string) (bool, error) { | |||||
| 107 | } | 107 | } | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | - | // Maintain reaps a bounded amount of stale state. Every scan uses ReadDir with | 110 | + | // Maintain reaps a bounded amount of stale state. Continuation handles are |
| 111 | - | // a fixed batch, and a quarantine tree is peeled one entry at a time so an | 111 | + | // repository-owned, so a stable prefix cannot starve entries later in a large |
| 112 | - | // interrupted pass can continue after another call or restart. | 112 | + | // directory. Cancellation and errors discard those handles: a restarted |
| 113 | - | func (r *Repository) Maintain(ctx context.Context) error { | 113 | + | // repository (and the next fresh cycle) always begins at a safe boundary. |
| 114 | + | func (r *Repository) Maintain(ctx context.Context) (err error) { | |||
| 115 | + | r.maintenanceMu.Lock() | |||
| 116 | + | defer r.maintenanceMu.Unlock() | |||
| 114 | if err := ctx.Err(); err != nil { | 117 | if err := ctx.Err(); err != nil { | ||
| 118 | + | r.resetMaintenance() | |||
| 115 | return err | 119 | return err | ||
| 116 | } | 120 | } | ||
| 121 | + | defer func() { | |||
| 122 | + | if err != nil { | |||
| 123 | + | r.resetMaintenance() | |||
| 124 | + | } | |||
| 125 | + | }() | |||
| 126 | + | if r.maintenanceCursors == nil { | |||
| 127 | + | r.maintenanceCursors = make(map[string]*maintenanceCursor) | |||
| 128 | + | r.maintenanceSessions = make(map[string]*sessionMaintenance) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 117 | budget := maintenanceBatch | 131 | budget := maintenanceBatch | ||
| 118 | - | if err := r.removeTemps(ctx, r.dir, &budget); err != nil { | 132 | + | if err := r.removeTemps(ctx, r.dir, &budget, "root-temps"); err != nil || budget == 0 { |
| 119 | return err | 133 | return err | ||
| 120 | } | 134 | } | ||
| 121 | - | if budget == 0 { | |||
| 122 | - | return nil | |||
| 123 | - | } | |||
| 124 | sessions := filepath.Join(r.dir, repositorySessionsDir) | 135 | sessions := filepath.Join(r.dir, repositorySessionsDir) | ||
| 125 | - | entries, err := readDirBatch(sessions, maintenanceBatch) | 136 | + | entries, _, err := r.readMaintenanceDir(sessions, maintenanceBatch, "sessions") |
| 126 | if errors.Is(err, os.ErrNotExist) { | 137 | if errors.Is(err, os.ErrNotExist) { | ||
| 127 | return nil | 138 | return nil | ||
| 128 | } | 139 | } | ||
| @@ -157,11 +168,7 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 157 | } | 168 | } | ||
| 158 | lock := r.sessionLock(entry.Name()) | 169 | lock := r.sessionLock(entry.Name()) | ||
| 159 | lock.Lock() | 170 | lock.Lock() | ||
| 160 | - | if err := ctx.Err(); err != nil { | 171 | + | err = r.maintainSession(ctx, entry.Name(), &budget) |
| 161 | - | lock.Unlock() | |||
| 162 | - | return err | |||
| 163 | - | } | |||
| 164 | - | err := r.maintainSession(ctx, entry.Name(), &budget) | |||
| 165 | lock.Unlock() | 172 | lock.Unlock() | ||
| 166 | if err != nil { | 173 | if err != nil { | ||
| 167 | return err | 174 | return err | ||
| @@ -170,8 +177,68 @@ func (r *Repository) Maintain(ctx context.Context) error { | |||||
| 170 | return nil | 177 | return nil | ||
| 171 | } | 178 | } | ||
| 172 | 179 | ||||
| 180 | + | type maintenanceCursor struct{ file *os.File } | |||
| 181 | + | ||||
| 182 | + | type manifestMaintenance struct { | |||
| 183 | + | refs map[ports.SnapshotDigest]codec.ObjectRef | |||
| 184 | + | complete bool | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | type sessionMaintenance struct { | |||
| 188 | + | token string | |||
| 189 | + | marked map[uint64]manifestMaintenance | |||
| 190 | + | uncertain bool | |||
| 191 | + | markDone bool | |||
| 192 | + | manifestQueue []uint64 | |||
| 193 | + | sweepQueue []string | |||
| 194 | + | sweepRootDone bool | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (r *Repository) resetMaintenance() { | |||
| 198 | + | for _, cursor := range r.maintenanceCursors { | |||
| 199 | + | _ = cursor.file.Close() | |||
| 200 | + | } | |||
| 201 | + | r.maintenanceCursors = nil | |||
| 202 | + | r.maintenanceSessions = nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // readMaintenanceDir advances one named directory cursor. It closes and | |||
| 206 | + | // forgets the handle at EOF, so the following call begins a fresh cycle. | |||
| 207 | + | func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]os.DirEntry, bool, error) { | |||
| 208 | + | id := purpose + "\x00" + dir | |||
| 209 | + | cursor := r.maintenanceCursors[id] | |||
| 210 | + | if cursor == nil { | |||
| 211 | + | file, err := os.Open(dir) | |||
| 212 | + | if err != nil { | |||
| 213 | + | return nil, false, err | |||
| 214 | + | } | |||
| 215 | + | cursor = &maintenanceCursor{file: file} | |||
| 216 | + | r.maintenanceCursors[id] = cursor | |||
| 217 | + | } | |||
| 218 | + | entries, err := cursor.file.ReadDir(n) | |||
| 219 | + | if err != nil && !errors.Is(err, io.EOF) { | |||
| 220 | + | delete(r.maintenanceCursors, id) | |||
| 221 | + | closeErr := cursor.file.Close() | |||
| 222 | + | if closeErr != nil { | |||
| 223 | + | return nil, false, closeErr | |||
| 224 | + | } | |||
| 225 | + | return nil, false, err | |||
| 226 | + | } | |||
| 227 | + | if !errors.Is(err, io.EOF) && len(entries) == n { | |||
| 228 | + | return entries, false, nil | |||
| 229 | + | } | |||
| 230 | + | delete(r.maintenanceCursors, id) | |||
| 231 | + | if closeErr := cursor.file.Close(); closeErr != nil { | |||
| 232 | + | return nil, false, closeErr | |||
| 233 | + | } | |||
| 234 | + | return entries, true, nil | |||
| 235 | + | } | |||
| 236 | + | ||||
| 173 | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | 237 | func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") } | ||
| 174 | 238 | ||||
| 239 | + | // readDirBatch is used only for a mutating quarantine walk, where every | |||
| 240 | + | // successful step removes the entry it selected. General maintenance scans use | |||
| 241 | + | // repository-owned cursors above. | |||
| 175 | func readDirBatch(dir string, n int) ([]os.DirEntry, error) { | 242 | func readDirBatch(dir string, n int) ([]os.DirEntry, error) { | ||
| 176 | f, err := os.Open(dir) | 243 | f, err := os.Open(dir) | ||
| 177 | if err != nil { | 244 | if err != nil { | ||
| @@ -221,6 +288,8 @@ func (r *Repository) removeTreeStep(ctx context.Context, path string, budget *in | |||||
| 221 | *budget-- | 288 | *budget-- | ||
| 222 | return true, nil | 289 | return true, nil | ||
| 223 | } | 290 | } | ||
| 291 | + | // A quarantine tree contains no live entries. Reopening from its start is | |||
| 292 | + | // safe here because each successful step removes that first descendant. | |||
| 224 | entries, err := readDirBatch(path, 1) | 293 | entries, err := readDirBatch(path, 1) | ||
| 225 | if err != nil { | 294 | if err != nil { | ||
| 226 | return false, err | 295 | return false, err | ||
| @@ -242,11 +311,16 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||||
| 242 | if err := ctx.Err(); err != nil { | 311 | if err := ctx.Err(); err != nil { | ||
| 243 | return err | 312 | return err | ||
| 244 | } | 313 | } | ||
| 245 | - | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget); err != nil { | 314 | + | state, err := r.sessionMaintenanceState(key) |
| 315 | + | if err != nil { | |||
| 246 | return err | 316 | return err | ||
| 247 | } | 317 | } | ||
| 318 | + | if err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 { | |||
| 319 | + | return err | |||
| 320 | + | } | |||
| 321 | + | ||||
| 248 | objectRoot := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | 322 | objectRoot := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | ||
| 249 | - | shards, err := readDirBatch(objectRoot, maintenanceBatch) | 323 | + | shards, _, err := r.readMaintenanceDir(objectRoot, maintenanceBatch, "object-temps-shards:"+key) |
| 250 | if err != nil && !errors.Is(err, os.ErrNotExist) { | 324 | if err != nil && !errors.Is(err, os.ErrNotExist) { | ||
| 251 | return fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | 325 | return fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err)) | ||
| 252 | } | 326 | } | ||
| @@ -255,160 +329,286 @@ func (r *Repository) maintainSession(ctx context.Context, key string, budget *in | |||||
| 255 | return nil | 329 | return nil | ||
| 256 | } | 330 | } | ||
| 257 | if shard.IsDir() { | 331 | if shard.IsDir() { | ||
| 258 | - | if err := r.removeTemps(ctx, filepath.Join(objectRoot, shard.Name()), budget); err != nil { | 332 | + | if err := r.removeTemps(ctx, filepath.Join(objectRoot, shard.Name()), budget, "object-temps:"+key+":"+shard.Name()); err != nil { |
| 259 | return err | 333 | return err | ||
| 260 | } | 334 | } | ||
| 261 | } | 335 | } | ||
| 262 | } | 336 | } | ||
| 337 | + | if *budget == 0 { | |||
| 338 | + | return nil | |||
| 339 | + | } | |||
| 340 | + | if !state.markDone { | |||
| 341 | + | if err := r.markSession(ctx, key, state); err != nil { | |||
| 342 | + | return err | |||
| 343 | + | } | |||
| 344 | + | if !state.markDone { | |||
| 345 | + | return nil | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 { | |||
| 349 | + | return err | |||
| 350 | + | } | |||
| 351 | + | if state.uncertain { | |||
| 352 | + | // An unreadable or invalid manifest may reference any existing blob. Do | |||
| 353 | + | // not sweep in this cycle; retry marking from a fresh directory pass. | |||
| 354 | + | r.clearSessionMaintenance(key) | |||
| 355 | + | return nil | |||
| 356 | + | } | |||
| 357 | + | if err := r.sweepSession(ctx, key, state, budget); err != nil { | |||
| 358 | + | return err | |||
| 359 | + | } | |||
| 360 | + | return nil | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, error) { | |||
| 364 | + | token, exists, err := r.maintenanceToken(key) | |||
| 365 | + | if err != nil { | |||
| 366 | + | return nil, err | |||
| 367 | + | } | |||
| 368 | + | if !exists { | |||
| 369 | + | r.clearSessionMaintenance(key) | |||
| 370 | + | return &sessionMaintenance{token: "missing", marked: make(map[uint64]manifestMaintenance)}, nil | |||
| 371 | + | } | |||
| 372 | + | state := r.maintenanceSessions[key] | |||
| 373 | + | if state == nil || state.token != token { | |||
| 374 | + | r.clearSessionMaintenance(key) | |||
| 375 | + | state = &sessionMaintenance{token: token, marked: make(map[uint64]manifestMaintenance)} | |||
| 376 | + | r.maintenanceSessions[key] = state | |||
| 377 | + | } | |||
| 378 | + | return state, nil | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // maintenanceToken is the publication boundary. Publish and Delete share the | |||
| 382 | + | // per-session lock with maintenance, and a changed HEAD on a later call makes | |||
| 383 | + | // us discard partial marking/sweeping before deleting anything else. | |||
| 384 | + | func (r *Repository) maintenanceToken(key string) (string, bool, error) { | |||
| 385 | + | data, exists, err := readOptionalBounded(r.headPath(key)) | |||
| 386 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 387 | + | return "", false, nil | |||
| 388 | + | } | |||
| 389 | + | if err != nil { | |||
| 390 | + | return "", false, err | |||
| 391 | + | } | |||
| 392 | + | if !exists { | |||
| 393 | + | return "", false, nil | |||
| 394 | + | } | |||
| 395 | + | return string(data), true, nil | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func (r *Repository) clearSessionMaintenance(key string) { | |||
| 399 | + | delete(r.maintenanceSessions, key) | |||
| 400 | + | prefix := "\x00" + filepath.Clean(r.sessionPath(key)) | |||
| 401 | + | for id, cursor := range r.maintenanceCursors { | |||
| 402 | + | if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") { | |||
| 403 | + | _ = cursor.file.Close() | |||
| 404 | + | delete(r.maintenanceCursors, id) | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 263 | 408 | ||||
| 264 | - | generations, err := r.generationNumbersBatch(key) | 409 | + | func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error { |
| 410 | + | dir := filepath.Join(r.sessionPath(key), repositoryGenerations) | |||
| 411 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key) | |||
| 412 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 413 | + | state.markDone = true | |||
| 414 | + | return nil | |||
| 415 | + | } | |||
| 265 | if err != nil { | 416 | if err != nil { | ||
| 266 | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | 417 | return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err)) | ||
| 267 | } | 418 | } | ||
| 268 | - | type manifestState struct { | 419 | + | for _, entry := range entries { |
| 269 | - | generation uint64 | |||
| 270 | - | refs map[ports.SnapshotDigest]codec.ObjectRef | |||
| 271 | - | complete bool | |||
| 272 | - | } | |||
| 273 | - | states := make([]manifestState, 0, len(generations)) | |||
| 274 | - | complete := 0 | |||
| 275 | - | for _, generation := range generations { | |||
| 276 | if err := ctx.Err(); err != nil { | 420 | if err := ctx.Err(); err != nil { | ||
| 277 | return err | 421 | return err | ||
| 278 | } | 422 | } | ||
| 279 | - | state := manifestState{generation: generation} | 423 | + | if entry.IsDir() { |
| 424 | + | continue | |||
| 425 | + | } | |||
| 426 | + | generation, ok := parseGenerationFilename(entry.Name()) | |||
| 427 | + | if !ok { | |||
| 428 | + | continue | |||
| 429 | + | } | |||
| 430 | + | item := manifestMaintenance{} | |||
| 280 | data, err := readBounded(r.manifestPath(key, generation)) | 431 | data, err := readBounded(r.manifestPath(key, generation)) | ||
| 281 | - | if err == nil { | 432 | + | if err != nil { |
| 282 | - | manifest, decodeErr := codec.UnmarshalManifest(data) | 433 | + | state.uncertain = true |
| 283 | - | if decodeErr == nil && manifest.Generation == generation && sessionKey(manifest.Name) == key { | 434 | + | state.marked[generation] = item |
| 284 | - | state.refs = manifestRefs(manifest) | 435 | + | continue |
| 285 | - | if state.refs != nil && withinGenerationBudget(len(data), state.refs) { | 436 | + | } |
| 286 | - | _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation) | 437 | + | manifest, err := codec.UnmarshalManifest(data) |
| 287 | - | state.complete = loadErr == nil | 438 | + | if err != nil || manifest.Generation != generation || sessionKey(manifest.Name) != key { |
| 288 | - | } | 439 | + | state.uncertain = true |
| 289 | - | } | 440 | + | state.marked[generation] = item |
| 441 | + | continue | |||
| 442 | + | } | |||
| 443 | + | item.refs = manifestRefs(manifest) | |||
| 444 | + | if item.refs == nil || !withinGenerationBudget(len(data), item.refs) { | |||
| 445 | + | state.uncertain = true | |||
| 446 | + | state.marked[generation] = item | |||
| 447 | + | continue | |||
| 290 | } | 448 | } | ||
| 291 | - | states = append(states, state) | 449 | + | // Valid but incomplete generations are marked too. This protects blobs |
| 450 | + | // written before a failed or in-flight publication reaches HEAD. | |||
| 451 | + | _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation) | |||
| 452 | + | item.complete = loadErr == nil | |||
| 453 | + | state.marked[generation] = item | |||
| 292 | } | 454 | } | ||
| 455 | + | if done { | |||
| 456 | + | state.markDone = true | |||
| 457 | + | state.manifestQueue = retainedManifestQueue(state.marked) | |||
| 458 | + | } | |||
| 459 | + | return nil | |||
| 460 | + | } | |||
| 293 | 461 | ||||
| 294 | - | retained := make(map[uint64]bool, 2) | 462 | + | func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 { |
| 295 | - | for _, state := range states { | 463 | + | complete := make([]uint64, 0, len(marked)) |
| 296 | - | if state.complete && complete < 2 { | 464 | + | for generation, item := range marked { |
| 297 | - | retained[state.generation] = true | 465 | + | if item.complete { |
| 298 | - | complete++ | 466 | + | complete = append(complete, generation) |
| 299 | } | 467 | } | ||
| 300 | } | 468 | } | ||
| 301 | - | referenced := make(map[ports.SnapshotDigest]struct{}) | 469 | + | for i := range complete { |
| 302 | - | for _, state := range states { | 470 | + | for j := i + 1; j < len(complete); j++ { |
| 303 | - | keep := retained[state.generation] || !state.complete | 471 | + | if complete[j] > complete[i] { |
| 304 | - | if !keep && *budget > 0 { | 472 | + | complete[i], complete[j] = complete[j], complete[i] |
| 305 | - | path := r.manifestPath(key, state.generation) | |||
| 306 | - | if err := ctx.Err(); err != nil { | |||
| 307 | - | return err | |||
| 308 | - | } | |||
| 309 | - | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 310 | - | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 311 | - | } | |||
| 312 | - | if err := ctx.Err(); err != nil { | |||
| 313 | - | return err | |||
| 314 | } | 473 | } | ||
| 315 | - | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | |||
| 316 | - | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 317 | - | } | |||
| 318 | - | *budget-- | |||
| 319 | - | continue | |||
| 320 | } | 474 | } | ||
| 321 | - | for digest := range state.refs { | 475 | + | } |
| 322 | - | referenced[digest] = struct{}{} | 476 | + | if len(complete) <= 2 { |
| 477 | + | return nil | |||
| 478 | + | } | |||
| 479 | + | return complete[2:] | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, state *sessionMaintenance, budget *int) error { | |||
| 483 | + | for len(state.manifestQueue) != 0 && *budget > 0 { | |||
| 484 | + | if err := ctx.Err(); err != nil { | |||
| 485 | + | return err | |||
| 323 | } | 486 | } | ||
| 487 | + | generation := state.manifestQueue[0] | |||
| 488 | + | path := r.manifestPath(key, generation) | |||
| 489 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 490 | + | return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 491 | + | } | |||
| 492 | + | if err := ctx.Err(); err != nil { | |||
| 493 | + | return err | |||
| 494 | + | } | |||
| 495 | + | if err := r.syncDirectory(filepath.Dir(path)); err != nil { | |||
| 496 | + | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 497 | + | } | |||
| 498 | + | state.manifestQueue = state.manifestQueue[1:] | |||
| 499 | + | *budget-- | |||
| 324 | } | 500 | } | ||
| 325 | - | return r.removeUnreferencedObjects(ctx, key, referenced, budget) | 501 | + | return nil |
| 326 | } | 502 | } | ||
| 327 | 503 | ||||
| 328 | - | func (r *Repository) generationNumbersBatch(key string) ([]uint64, error) { | 504 | + | func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error { |
| 329 | - | entries, err := readDirBatch(filepath.Join(r.sessionPath(key), repositoryGenerations), maintenanceBatch) | 505 | + | referenced := retainedReferences(state.marked) |
| 330 | - | if errors.Is(err, os.ErrNotExist) { | 506 | + | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) |
| 331 | - | return nil, nil | 507 | + | if !state.sweepRootDone { |
| 508 | + | entries, done, err := r.readMaintenanceDir(root, maintenanceBatch, "sweep-shards:"+key) | |||
| 509 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 510 | + | state.sweepRootDone = true | |||
| 511 | + | } else if err != nil { | |||
| 512 | + | return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err)) | |||
| 513 | + | } else { | |||
| 514 | + | for _, entry := range entries { | |||
| 515 | + | if entry.IsDir() { | |||
| 516 | + | state.sweepQueue = append(state.sweepQueue, entry.Name()) | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | state.sweepRootDone = done | |||
| 520 | + | } | |||
| 332 | } | 521 | } | ||
| 333 | - | if err != nil { | 522 | + | if len(state.sweepQueue) != 0 && *budget > 0 { |
| 334 | - | return nil, err | 523 | + | shard := state.sweepQueue[0] |
| 524 | + | done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard) | |||
| 525 | + | if err != nil { | |||
| 526 | + | return err | |||
| 527 | + | } | |||
| 528 | + | if done { | |||
| 529 | + | state.sweepQueue = state.sweepQueue[1:] | |||
| 530 | + | } | |||
| 335 | } | 531 | } | ||
| 336 | - | numbers := make([]uint64, 0, len(entries)) | 532 | + | if state.sweepRootDone && len(state.sweepQueue) == 0 { |
| 337 | - | for _, entry := range entries { | 533 | + | r.clearSessionMaintenance(key) |
| 338 | - | if !entry.IsDir() { | 534 | + | } |
| 339 | - | if generation, ok := parseGenerationFilename(entry.Name()); ok { | 535 | + | return nil |
| 340 | - | numbers = append(numbers, generation) | 536 | + | } |
| 537 | + | ||||
| 538 | + | func retainedReferences(marked map[uint64]manifestMaintenance) map[ports.SnapshotDigest]struct{} { | |||
| 539 | + | complete := make([]uint64, 0, len(marked)) | |||
| 540 | + | for generation, item := range marked { | |||
| 541 | + | if item.complete { | |||
| 542 | + | complete = append(complete, generation) | |||
| 543 | + | } | |||
| 544 | + | } | |||
| 545 | + | for i := range complete { | |||
| 546 | + | for j := i + 1; j < len(complete); j++ { | |||
| 547 | + | if complete[j] > complete[i] { | |||
| 548 | + | complete[i], complete[j] = complete[j], complete[i] | |||
| 341 | } | 549 | } | ||
| 342 | } | 550 | } | ||
| 343 | } | 551 | } | ||
| 344 | - | // Generation file names are zero-padded, but keep the explicit comparison | 552 | + | keep := make(map[uint64]bool, 2) |
| 345 | - | // in case the representation changes. | 553 | + | for _, generation := range complete { |
| 346 | - | for i := range numbers { | 554 | + | if len(keep) == 2 { |
| 347 | - | for j := i + 1; j < len(numbers); j++ { | 555 | + | break |
| 348 | - | if numbers[j] > numbers[i] { | 556 | + | } |
| 349 | - | numbers[i], numbers[j] = numbers[j], numbers[i] | 557 | + | keep[generation] = true |
| 558 | + | } | |||
| 559 | + | references := make(map[ports.SnapshotDigest]struct{}) | |||
| 560 | + | for generation, item := range marked { | |||
| 561 | + | if !item.complete || keep[generation] { | |||
| 562 | + | for digest := range item.refs { | |||
| 563 | + | references[digest] = struct{}{} | |||
| 350 | } | 564 | } | ||
| 351 | } | 565 | } | ||
| 352 | } | 566 | } | ||
| 353 | - | return numbers, nil | 567 | + | return references |
| 354 | } | 568 | } | ||
| 355 | 569 | ||||
| 356 | - | func (r *Repository) removeUnreferencedObjects(ctx context.Context, key string, referenced map[ports.SnapshotDigest]struct{}, budget *int) error { | 570 | + | func (r *Repository) sweepShard(ctx context.Context, dir string, referenced map[ports.SnapshotDigest]struct{}, budget *int, key, shard string) (bool, error) { |
| 357 | - | root := filepath.Join(r.sessionPath(key), repositoryObjectsDir) | 571 | + | entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "sweep-objects:"+key+":"+shard) |
| 358 | - | shards, err := readDirBatch(root, maintenanceBatch) | |||
| 359 | if errors.Is(err, os.ErrNotExist) { | 572 | if errors.Is(err, os.ErrNotExist) { | ||
| 360 | - | return nil | 573 | + | return true, nil |
| 361 | } | 574 | } | ||
| 362 | if err != nil { | 575 | if err != nil { | ||
| 363 | - | return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err)) | 576 | + | return false, fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) |
| 364 | } | 577 | } | ||
| 365 | - | for _, shard := range shards { | 578 | + | for _, object := range entries { |
| 366 | if *budget == 0 { | 579 | if *budget == 0 { | ||
| 367 | - | return nil | 580 | + | break |
| 581 | + | } | |||
| 582 | + | if err := ctx.Err(); err != nil { | |||
| 583 | + | return false, err | |||
| 368 | } | 584 | } | ||
| 369 | - | if !shard.IsDir() { | 585 | + | digest, ok := parseObjectDigest(object.Name()) |
| 586 | + | if !ok || object.IsDir() { | |||
| 370 | continue | 587 | continue | ||
| 371 | } | 588 | } | ||
| 372 | - | dir := filepath.Join(root, shard.Name()) | 589 | + | if _, used := referenced[digest]; used { |
| 373 | - | objects, err := readDirBatch(dir, maintenanceBatch) | 590 | + | continue |
| 374 | - | if err != nil { | |||
| 375 | - | return fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err)) | |||
| 376 | } | 591 | } | ||
| 377 | - | for _, object := range objects { | 592 | + | path := filepath.Join(dir, object.Name()) |
| 378 | - | if *budget == 0 { | 593 | + | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 379 | - | return nil | 594 | + | return false, fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) |
| 380 | - | } | 595 | + | } |
| 381 | - | if err := ctx.Err(); err != nil { | 596 | + | if err := ctx.Err(); err != nil { |
| 382 | - | return err | 597 | + | return false, err |
| 383 | - | } | |||
| 384 | - | digest, ok := parseObjectDigest(object.Name()) | |||
| 385 | - | if !ok || object.IsDir() { | |||
| 386 | - | continue | |||
| 387 | - | } | |||
| 388 | - | if _, used := referenced[digest]; used { | |||
| 389 | - | continue | |||
| 390 | - | } | |||
| 391 | - | path := filepath.Join(dir, object.Name()) | |||
| 392 | - | if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 393 | - | return fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 394 | - | } | |||
| 395 | - | if err := ctx.Err(); err != nil { | |||
| 396 | - | return err | |||
| 397 | - | } | |||
| 398 | - | if err := r.syncDirectory(dir); err != nil { | |||
| 399 | - | return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 400 | - | } | |||
| 401 | - | *budget-- | |||
| 402 | } | 598 | } | ||
| 599 | + | if err := r.syncDirectory(dir); err != nil { | |||
| 600 | + | return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err)) | |||
| 601 | + | } | |||
| 602 | + | *budget-- | |||
| 403 | } | 603 | } | ||
| 404 | - | return nil | 604 | + | return done, nil |
| 405 | } | 605 | } | ||
| 406 | 606 | ||||
| 407 | - | func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int) error { | 607 | + | func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int, purpose string) error { |
| 408 | if *budget == 0 { | 608 | if *budget == 0 { | ||
| 409 | return nil | 609 | return nil | ||
| 410 | } | 610 | } | ||
| 411 | - | entries, err := readDirBatch(dir, maintenanceBatch) | 611 | + | entries, _, err := r.readMaintenanceDir(dir, maintenanceBatch, purpose) |
| 412 | if errors.Is(err, os.ErrNotExist) { | 612 | if errors.Is(err, os.ErrNotExist) { | ||
| 413 | return nil | 613 | return nil | ||
| 414 | } | 614 | } | ||
M internal/adapters/snapshot/maintenance_test.go +90 -0
| @@ -2,10 +2,15 @@ package snapshot | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/sha256" | |
| 5 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 10 | + | "strings" | |
| 8 | 11 | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 14 | ) | |
| 10 | 15 | ||
| 11 | 16 | func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) { | |
| @@ -268,6 +273,91 @@ func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) { | |||
| 268 | 273 | } | |
| 269 | 274 | } | |
| 270 | 275 | ||
| 276 | + | func TestRepositoryMaintainDoesNotStarveLaterTemporaryEntries(t *testing.T) { | |
| 277 | + | repo := NewRepository(privateDir(t)) | |
| 278 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| 279 | + | t.Fatal(err) | |
| 280 | + | } | |
| 281 | + | // The live entries precede the stale entries in directory insertion order. | |
| 282 | + | // A fresh ReadDir from the start on every call would never reach the latter. | |
| 283 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 284 | + | if err := os.WriteFile(filepath.Join(repo.dir, "live-"+string(rune('a'+i))), []byte("live"), 0o600); err != nil { | |
| 285 | + | t.Fatal(err) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | for i := 0; i < maintenanceBatch+1; i++ { | |
| 289 | + | if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-later-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil { | |
| 290 | + | t.Fatal(err) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | for i := 0; i < 4; i++ { | |
| 294 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 295 | + | t.Fatal(err) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | entries, err := os.ReadDir(repo.dir) | |
| 299 | + | if err != nil { | |
| 300 | + | t.Fatal(err) | |
| 301 | + | } | |
| 302 | + | for _, entry := range entries { | |
| 303 | + | if strings.HasPrefix(entry.Name(), ".tmp-") { | |
| 304 | + | t.Fatalf("stale entry %q was starved", entry.Name()) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) { | |
| 310 | + | repo := NewRepository(privateDir(t)) | |
| 311 | + | var newest ports.SnapshotPublication | |
| 312 | + | for generation := uint64(1); generation <= maintenanceBatch+2; generation++ { | |
| 313 | + | publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation))) | |
| 314 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | newest = publication | |
| 318 | + | } | |
| 319 | + | key := sessionKey("named") | |
| 320 | + | stale := sha256.Sum256([]byte("stale object")) | |
| 321 | + | stalePath := repo.objectPath(key, stale) | |
| 322 | + | if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { | |
| 323 | + | t.Fatal(err) | |
| 324 | + | } | |
| 325 | + | if err := os.WriteFile(stalePath, []byte("stale object"), 0o600); err != nil { | |
| 326 | + | t.Fatal(err) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // The first call has classified only one manifest batch. It must not sweep | |
| 330 | + | // even an otherwise stale object, nor a blob owned by a later manifest. | |
| 331 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 332 | + | t.Fatal(err) | |
| 333 | + | } | |
| 334 | + | if _, err := os.Lstat(stalePath); err != nil { | |
| 335 | + | t.Fatalf("stale object swept before complete mark pass: %v", err) | |
| 336 | + | } | |
| 337 | + | for _, object := range newest.Objects { | |
| 338 | + | if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil { | |
| 339 | + | t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | for i := 0; i < 600; i++ { | |
| 344 | + | if err := repo.Maintain(context.Background()); err != nil { | |
| 345 | + | t.Fatal(err) | |
| 346 | + | } | |
| 347 | + | if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) { | |
| 348 | + | break | |
| 349 | + | } | |
| 350 | + | if i == 599 { | |
| 351 | + | t.Fatal("stale object was not eventually swept") | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} { | |
| 355 | + | if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil { | |
| 356 | + | t.Fatalf("newest complete manifest %d removed: %v", generation, err) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 271 | 361 | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | |
| 272 | 362 | repo := NewRepository(privateDir(t)) | |
| 273 | 363 | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |
| @@ -2,10 +2,15 @@ package snapshot | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/sha256" | |||
| 5 | "errors" | 6 | "errors" | ||
| 7 | + | "fmt" | |||
| 6 | "os" | 8 | "os" | ||
| 7 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "strings" | |||
| 8 | "testing" | 11 | "testing" | ||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | ) | 14 | ) | ||
| 10 | 15 | ||||
| 11 | func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) { | 16 | func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) { | ||
| @@ -268,6 +273,91 @@ func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) { | |||||
| 268 | } | 273 | } | ||
| 269 | } | 274 | } | ||
| 270 | 275 | ||||
| 276 | + | func TestRepositoryMaintainDoesNotStarveLaterTemporaryEntries(t *testing.T) { | |||
| 277 | + | repo := NewRepository(privateDir(t)) | |||
| 278 | + | if err := os.Mkdir(repo.dir, 0o700); err != nil { | |||
| 279 | + | t.Fatal(err) | |||
| 280 | + | } | |||
| 281 | + | // The live entries precede the stale entries in directory insertion order. | |||
| 282 | + | // A fresh ReadDir from the start on every call would never reach the latter. | |||
| 283 | + | for i := 0; i < maintenanceBatch+1; i++ { | |||
| 284 | + | if err := os.WriteFile(filepath.Join(repo.dir, "live-"+string(rune('a'+i))), []byte("live"), 0o600); err != nil { | |||
| 285 | + | t.Fatal(err) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | for i := 0; i < maintenanceBatch+1; i++ { | |||
| 289 | + | if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-later-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil { | |||
| 290 | + | t.Fatal(err) | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | for i := 0; i < 4; i++ { | |||
| 294 | + | if err := repo.Maintain(context.Background()); err != nil { | |||
| 295 | + | t.Fatal(err) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | entries, err := os.ReadDir(repo.dir) | |||
| 299 | + | if err != nil { | |||
| 300 | + | t.Fatal(err) | |||
| 301 | + | } | |||
| 302 | + | for _, entry := range entries { | |||
| 303 | + | if strings.HasPrefix(entry.Name(), ".tmp-") { | |||
| 304 | + | t.Fatalf("stale entry %q was starved", entry.Name()) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) { | |||
| 310 | + | repo := NewRepository(privateDir(t)) | |||
| 311 | + | var newest ports.SnapshotPublication | |||
| 312 | + | for generation := uint64(1); generation <= maintenanceBatch+2; generation++ { | |||
| 313 | + | publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation))) | |||
| 314 | + | if err := repo.Publish(context.Background(), publication); err != nil { | |||
| 315 | + | t.Fatal(err) | |||
| 316 | + | } | |||
| 317 | + | newest = publication | |||
| 318 | + | } | |||
| 319 | + | key := sessionKey("named") | |||
| 320 | + | stale := sha256.Sum256([]byte("stale object")) | |||
| 321 | + | stalePath := repo.objectPath(key, stale) | |||
| 322 | + | if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { | |||
| 323 | + | t.Fatal(err) | |||
| 324 | + | } | |||
| 325 | + | if err := os.WriteFile(stalePath, []byte("stale object"), 0o600); err != nil { | |||
| 326 | + | t.Fatal(err) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // The first call has classified only one manifest batch. It must not sweep | |||
| 330 | + | // even an otherwise stale object, nor a blob owned by a later manifest. | |||
| 331 | + | if err := repo.Maintain(context.Background()); err != nil { | |||
| 332 | + | t.Fatal(err) | |||
| 333 | + | } | |||
| 334 | + | if _, err := os.Lstat(stalePath); err != nil { | |||
| 335 | + | t.Fatalf("stale object swept before complete mark pass: %v", err) | |||
| 336 | + | } | |||
| 337 | + | for _, object := range newest.Objects { | |||
| 338 | + | if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil { | |||
| 339 | + | t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | for i := 0; i < 600; i++ { | |||
| 344 | + | if err := repo.Maintain(context.Background()); err != nil { | |||
| 345 | + | t.Fatal(err) | |||
| 346 | + | } | |||
| 347 | + | if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) { | |||
| 348 | + | break | |||
| 349 | + | } | |||
| 350 | + | if i == 599 { | |||
| 351 | + | t.Fatal("stale object was not eventually swept") | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} { | |||
| 355 | + | if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil { | |||
| 356 | + | t.Fatalf("newest complete manifest %d removed: %v", generation, err) | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 271 | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | 361 | func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) { | ||
| 272 | repo := NewRepository(privateDir(t)) | 362 | repo := NewRepository(privateDir(t)) | ||
| 273 | if err := os.Mkdir(repo.dir, 0o700); err != nil { | 363 | if err := os.Mkdir(repo.dir, 0o700); err != nil { | ||
M internal/adapters/snapshot/repository.go +7 -0
| @@ -25,6 +25,13 @@ type Repository struct { | |||
| 25 | 25 | dir string | |
| 26 | 26 | locks sync.Map // map[string]*sync.Mutex | |
| 27 | 27 | hooks repositoryHooks | |
| 28 | + | ||
| 29 | + | // maintenanceMu owns continuation state for bounded maintenance. Keeping | |
| 30 | + | // directory handles on the repository makes successive calls advance rather | |
| 31 | + | // than repeatedly reading the first batch of a large directory. | |
| 32 | + | maintenanceMu sync.Mutex | |
| 33 | + | maintenanceCursors map[string]*maintenanceCursor | |
| 34 | + | maintenanceSessions map[string]*sessionMaintenance | |
| 28 | 35 | } | |
| 29 | 36 | ||
| 30 | 37 | // repositoryHooks makes each persistence boundary fault-injectable. Hooks run | |
| @@ -25,6 +25,13 @@ type Repository struct { | |||||
| 25 | dir string | 25 | dir string | ||
| 26 | locks sync.Map // map[string]*sync.Mutex | 26 | locks sync.Map // map[string]*sync.Mutex | ||
| 27 | hooks repositoryHooks | 27 | hooks repositoryHooks | ||
| 28 | + | ||||
| 29 | + | // maintenanceMu owns continuation state for bounded maintenance. Keeping | |||
| 30 | + | // directory handles on the repository makes successive calls advance rather | |||
| 31 | + | // than repeatedly reading the first batch of a large directory. | |||
| 32 | + | maintenanceMu sync.Mutex | |||
| 33 | + | maintenanceCursors map[string]*maintenanceCursor | |||
| 34 | + | maintenanceSessions map[string]*sessionMaintenance | |||
| 28 | } | 35 | } | ||
| 29 | 36 | ||||
| 30 | // repositoryHooks makes each persistence boundary fault-injectable. Hooks run | 37 | // repositoryHooks makes each persistence boundary fault-injectable. Hooks run | ||