package host import ( "context" "errors" "os" "strings" "github.com/carlosframework/platform/internal/hostbox" ) // Called after identity-checked event removal with namespace, slot and record // locks held. Latest slot references remain replayable; legacy consumption is // separate authority and is never collected here. func (l *servingParkLedger) collectClosedLocked(ctx context.Context, r *servingParkRecord, slot servingParkSlot) error { if err := ctx.Err(); err != nil { return err } if !r.Complete || !r.Closed || !r.EventRemoved { return nil } if r.CollectAfter.IsZero() { // Old records start their window when first observed fully complete. // Clock rollback must not move the deadline backwards on a later pass. now := l.now().UTC() if now.Before(r.ParkedAt) { return nil } r.CollectAfter = now.Add(hostbox.JournalReceiptRetention) return l.save(*r) } if l.now().Before(r.CollectAfter) || slot.ID == r.Authorization.ID || slot.Pending == r.Authorization.ID || slot.Last == r.Authorization.ID { return nil } if slot.Last == "" { return hostbox.ErrChanged } if r.Authorization.Legacy { consumed, err := l.legacyConsumption(r.Authorization.Successor) if err != nil || consumed == nil || !sameServingLegacyConsumption(*consumed, *r) { return hostbox.ErrChanged } // Distinguish collection from a crash after consumption was recorded // but before the first retirement intent was saved. if !consumed.Collected { consumed.Collected = true historical := servingParkRecord{Authorization: consumed.Authorization, Source: consumed.Source} if err := l.bindRecord(&historical, true); err != nil { return err } consumed.Authorization, consumed.Source = historical.Authorization, historical.Source if err := l.write(servingLegacyKey("legacy", r.Authorization.Successor), consumed); err != nil { return err } } } // Preparations have no authority without their retirement. Remove them // while that record still makes interrupted collection discoverable. names, err := l.state.Names() if err != nil { return err } for _, name := range names { if strings.HasPrefix(name, "reboot-"+r.Authorization.ID+"-") && strings.HasSuffix(name, ".json") { if err := ctx.Err(); err != nil { return err } if err := l.state.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } } // Every record-lock user first takes namespace.lock. No waiter can hold // this lock's old inode while another process creates its replacement. // Remove it first: a lost reply leaves the retirement discoverable for a // later pass, rather than leaving an orphan lock after record collection. if err := l.state.Remove("retirement-" + r.Authorization.ID + ".lock"); err != nil && !errors.Is(err, os.ErrNotExist) { return err } if err := ctx.Err(); err != nil { return err } if err := l.state.Remove("retirement-" + r.Authorization.ID + ".json"); err != nil { return err } return l.removeCollectedBinding(ctx, r.Authorization.ID) } // Called under namespace.lock. A binding cannot exist before its retirement, // and no retirement can be recreated while this lock is held. This removes // only private metadata, never an artifact or its namespace binding. func (l *servingParkLedger) removeCollectedBinding(ctx context.Context, id string) error { if err := ctx.Err(); err != nil { return err } if _, err := l.state.Read("retirement-" + id + ".json"); !errors.Is(err, os.ErrNotExist) { return err } err := l.state.Remove("current-retirement-" + id + ".json") if errors.Is(err, os.ErrNotExist) { return nil } return err } func (l *servingParkLedger) collectOrphanBindings(ctx context.Context) error { names, err := l.state.Names() if err != nil { return err } for _, name := range names { if !strings.HasPrefix(name, "current-retirement-") || !strings.HasSuffix(name, ".json") { continue } id := strings.TrimSuffix(strings.TrimPrefix(name, "current-retirement-"), ".json") if !stopDigest(id) { return hostbox.ErrChanged } if err := l.removeCollectedBinding(ctx, id); err != nil { return err } } return nil }