v0.290.0: the clean-up guard skips same-day superseded young snapshots instead of refusing (R-824), refuses above the weekly cap; a due set-aside deletion is handed to the hub's 7-day wait (decision 74, R-823)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -52,6 +52,16 @@ func (m *Manager) abandonNow() time.Time {
|
||||
// SetOffboxClock injects the abandonment clock (tests only).
|
||||
func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
|
||||
|
||||
// OffsiteAbandonClient is the hub's set-aside deletion (decision 74; offsiteapply.HubAbandonClient).
|
||||
type OffsiteAbandonClient interface {
|
||||
Request(ctx context.Context, path string) (dueAt time.Time, err error)
|
||||
Status(ctx context.Context) (state string, err error) // none | pending | cancelled | deleted
|
||||
Cancel(ctx context.Context) error
|
||||
}
|
||||
|
||||
// SetOffsiteAbandonClient wires the hub's set-aside deletion.
|
||||
func (m *Manager) SetOffsiteAbandonClient(c OffsiteAbandonClient) { m.offsiteAbandon = c }
|
||||
|
||||
// startAbandonCountdown records the decision and the date the terminal step will run. Called by
|
||||
// resetOrphanedRepo AFTER the move-aside has succeeded — a countdown started before the store has
|
||||
// actually moved would count down to deleting a path that does not exist.
|
||||
@@ -90,6 +100,9 @@ type AbandonState struct {
|
||||
DaysLeft int // ceiling, so "0 days left" only ever means "today"
|
||||
RepoPath string // the set-aside store awaiting deletion
|
||||
PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package
|
||||
// HubPending / HubDueAt (v0.290.0, decision 74): the deletion is the HUB's, due at HubDueAt.
|
||||
HubPending bool
|
||||
HubDueAt time.Time
|
||||
// RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved
|
||||
// with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when
|
||||
// the customer decided. Derived here, once, so the banner and anything else asking cannot disagree.
|
||||
@@ -108,14 +121,25 @@ func (m *Manager) AbandonStatus() AbandonState {
|
||||
return AbandonState{}
|
||||
}
|
||||
st := AbandonState{RepoPath: t.AbandonRepoPath, PurgeRequested: t.AbandonPurgeRequested}
|
||||
if t.AbandonAt == "" {
|
||||
if t.AbandonHubDueAt != "" {
|
||||
if d, err := time.Parse(time.RFC3339, t.AbandonHubDueAt); err == nil {
|
||||
st.HubPending, st.HubDueAt = true, d
|
||||
}
|
||||
}
|
||||
at := t.AbandonAt
|
||||
if at == "" && st.HubPending {
|
||||
// Decision 74: the countdown continues at the HUB — the household sees the hub's date, and the
|
||||
// deletion it chose is still pending and still cancellable (the page and the banner stay true).
|
||||
at = t.AbandonHubDueAt
|
||||
}
|
||||
if at == "" {
|
||||
return st
|
||||
}
|
||||
due, err := time.Parse(time.RFC3339, t.AbandonAt)
|
||||
due, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
// A malformed stamp must not silently mean "never due" — that would strand the store for ever
|
||||
// with a countdown the customer can see and nothing behind it.
|
||||
m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", t.AbandonAt, err)
|
||||
m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", at, err)
|
||||
return st
|
||||
}
|
||||
st.Active, st.DueAt = true, due
|
||||
@@ -148,12 +172,23 @@ func (m *Manager) AbandonStatus() AbandonState {
|
||||
// only thing that deletes, and it has not run.
|
||||
func (m *Manager) CancelAbandon(reason string) {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested) {
|
||||
if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested && t.AbandonHubDueAt == "") {
|
||||
return // nothing running — silent, so a healthy recovery does not log about a countdown
|
||||
}
|
||||
if t.AbandonHubDueAt != "" && m.offsiteAbandon != nil {
|
||||
// Decision 74: the hub holds the request — cancel it there, or the hub deletes on schedule.
|
||||
cctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
if err := m.offsiteAbandon.Cancel(cctx); err != nil {
|
||||
cancel()
|
||||
m.logger.Printf("[ERROR] [offbox] could not cancel the hub's pending deletion of %s (%v) — the countdown is kept so the next sweep retries the cancel", t.AbandonRepoPath, err)
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonStartedAt, o.AbandonAt = "", ""
|
||||
o.AbandonPurgeRequested = false
|
||||
o.AbandonHubDueAt = ""
|
||||
}); err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] could not cancel the abandonment countdown: %v", err)
|
||||
return
|
||||
@@ -183,6 +218,33 @@ func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
|
||||
m.logger.Printf("[DEBUG] [offbox] abandonment: the set-aside store is deleted; awaiting the hub to drop the sealed package")
|
||||
return false, nil
|
||||
}
|
||||
if st.HubPending && m.offsiteAbandon != nil {
|
||||
state, err := m.offsiteAbandon.Status(ctx)
|
||||
if err != nil {
|
||||
m.logger.Printf("[DEBUG] [offbox] abandonment: hub status unreadable (retried): %v", err)
|
||||
return false, nil
|
||||
}
|
||||
switch state {
|
||||
case "deleted":
|
||||
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonPurgeRequested = true
|
||||
o.AbandonHubDueAt = ""
|
||||
}); uerr != nil {
|
||||
return true, uerr
|
||||
}
|
||||
m.logger.Printf("[INFO] [offbox] abandonment: the hub deleted the set-aside copy %s; requesting the sealed package's removal", st.RepoPath)
|
||||
if m.offboxOrphanEvent != nil {
|
||||
m.offboxOrphanEvent("offbox_abandon_completed", st.RepoPath)
|
||||
}
|
||||
return true, nil
|
||||
case "cancelled", "none":
|
||||
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonHubDueAt, o.AbandonStartedAt = "", ""
|
||||
})
|
||||
m.logger.Printf("[INFO] [offbox] abandonment: the hub's deletion of %s was cancelled — the set-aside copy is kept", st.RepoPath)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if !st.Active || st.DueAt.After(m.abandonNow()) {
|
||||
return false, nil // not due — quiet by construction on every healthy box
|
||||
}
|
||||
@@ -193,16 +255,26 @@ func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
|
||||
return false, fmt.Errorf("abandonment due with no recorded path")
|
||||
}
|
||||
if t.Pinned() {
|
||||
// Decision 69 (v0.289.0): the box's off-site key is append-only and cannot delete — by design,
|
||||
// so that a broken-into box cannot erase history. The set-aside copy STAYS; the operator removes
|
||||
// it (R-823). The schedule is closed so the sweep stops; nothing was deleted.
|
||||
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonAt = "" }); uerr != nil {
|
||||
m.logger.Printf("[WARN] [offbox] abandonment: could not close the schedule: %v", uerr)
|
||||
// Decision 74 (v0.290.0): the box's key cannot delete (decision 69), so a due abandonment is a
|
||||
// REQUEST to the hub, which deletes the set-aside copy after its own delay (7 days) unless the
|
||||
// household (a recovery here cancels it) or the operator cancels. Two-phase as before: the
|
||||
// store is gone only when the hub says "deleted"; then the sealed package is asked to go.
|
||||
if m.offsiteAbandon == nil {
|
||||
m.logger.Printf("[WARN] [offbox] abandonment DUE for %s but the hub's deletion service is not wired — nothing deleted; retried tomorrow", t.AbandonRepoPath)
|
||||
return false, nil
|
||||
}
|
||||
m.logger.Printf("[WARN] [offbox] abandonment DUE for %s, but the off-site key is append-only (decision 69) — NOTHING deleted; the set-aside copy stays until the operator removes it", t.AbandonRepoPath)
|
||||
if m.offboxOrphanEvent != nil {
|
||||
m.offboxOrphanEvent("offbox_abandon_deferred", t.AbandonRepoPath)
|
||||
due, err := m.offsiteAbandon.Request(ctx, t.AbandonRepoPath)
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] abandonment: handing the deletion of %s to the hub failed (retried tomorrow): %v", t.AbandonRepoPath, err)
|
||||
return false, err
|
||||
}
|
||||
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonAt = ""
|
||||
o.AbandonHubDueAt = due.UTC().Format(time.RFC3339)
|
||||
}); uerr != nil {
|
||||
return false, uerr
|
||||
}
|
||||
m.logger.Printf("[INFO] [offbox] abandonment: the hub deletes the set-aside copy %s at %s unless cancelled (decision 74)", t.AbandonRepoPath, due.UTC().Format(time.RFC3339))
|
||||
return false, nil
|
||||
}
|
||||
port := t.Port
|
||||
|
||||
Reference in New Issue
Block a user