carlos / platform Public

console: the hourly store sweep refuses what purgeOne refuses: the control plane, email estate and an estate it cannot read

One purge rule, never a second deleter with weaker guards (the box purge's
weaker guard was the old I2 defect). Before any purge step, in both the
lifetimes and the pre-cutover branch, the sweep asks mailEstate for the app
(a headless app's account is its sqid alone); an error or any estate leaves
the app and its marker untouched and logs once an hour per app, as the
control-plane refusal now does too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 516293fa1403e3cdb681d922fc9e6055e747383c parent 286ef25
2 files changed, +71 −24
  • internal/console/lifetimes_purge.go +19 −3
  • internal/console/lifetimes_purge_test.go +52 −21
diff --git a/internal/console/lifetimes_purge.go b/internal/console/lifetimes_purge.go
index c4eae65f0..bc1fa6e6b 100644
--- a/internal/console/lifetimes_purge.go
+++ b/internal/console/lifetimes_purge.go
@@ -152,8 +152,9 @@ const trashSweepEvery = time.Hour
// console: every app whose marker is past release.TrashCooloff, whether or
// not this console has a row for it (a headless app is purged here once
// boxes stop). At most once per trashSweepEvery by now. Each app is purged
-// under its lock with the marker re-read there; with lifetimes off it is the
-// legacy purge the boxes ran.
+// under its lock with the marker re-read there, after purgeOne's own
+// refusals (the control plane, email estate or an unreadable one); with
+// lifetimes off it is the legacy purge the boxes ran.
func (s *Server) trashSweep(ctx context.Context, now time.Time) {
if s.cfg.HostClaim || s.cfg.Releases == nil {
return
@@ -204,10 +205,25 @@ func (s *Server) sweepTrashedApp(ctx context.Context, a release.TrashedApp, now
return
}
if life != nil && life.ControlPlane {
- log.Printf("console: trash sweep: %s/%s is marked as the control plane; not purging it", a.Account, a.App)
+ s.lifetimeLogHourly("trash-sweep "+a.Account+"/"+a.App, "console: trash sweep: %s/%s is marked as the control plane; not purging it", a.Account, a.App)
return
}
}
+ // purgeOne's email refusal, before any purge step: one purge rule, and
+ // never a second deleter with weaker guards. A headless app has no row,
+ // so its account is the sqid alone (mailEstate reads by sqid).
+ owner := &Account{Sqid: a.Account}
+ if acct != nil {
+ owner = &Account{ID: acct.ID, Sqid: acct.Sqid}
+ }
+ switch estate, err := s.mailEstate(ctx, owner, a.App); {
+ case err != nil:
+ s.lifetimeLogHourly("trash-sweep "+a.Account+"/"+a.App, "console: trash sweep: read email estate %s/%s: %v; not purging", a.Account, a.App, err)
+ return
+ case estate:
+ s.lifetimeLogHourly("trash-sweep "+a.Account+"/"+a.App, "console: trash sweep: %s/%s has email estate; not purging it. Delete it from the Trash page", a.Account, a.App)
+ return
+ }
if lifetimesUnit(ctx) {
err = s.purgeAppWithLifetimes(ctx, a.Account, a.App, tomb.DeletedAt, now)
} else {
diff --git a/internal/console/lifetimes_purge_test.go b/internal/console/lifetimes_purge_test.go
index 86c6d1402..dbb5c3114 100644
--- a/internal/console/lifetimes_purge_test.go
+++ b/internal/console/lifetimes_purge_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -96,27 +97,57 @@ func TestConsolePurgeRetiresEveryLifetime(t *testing.T) {
}
requirePurged(t, l, true)
})
- t.Run("the hourly store sweep of an app with no console row", func(t *testing.T) {
- l := newLtOn(t)
- l.mustCreate(t, ltOne)
- old := time.Now().Add(-31 * 24 * time.Hour)
- if err := release.TrashApp(ctx, l.fx.rel, "bab", ltApp, "paul@keymail.dev", old); err != nil {
- t.Fatal(err)
- }
- if err := l.fx.st.TrashAppRow(OpsAccountID, ltApp, old); err != nil {
- t.Fatal(err)
- }
- if err := l.fx.st.PurgeAppRow(OpsAccountID, ltApp); err != nil {
- t.Fatal(err)
- }
- if !l.fx.srv.lifetimesIteration(ctx, "expiry pass", true, false, func(ctx context.Context) { l.fx.srv.expiryPass(ctx, time.Now()) }) {
- t.Fatal("the expiry pass did not run")
- }
- requireRetired(t, l.fx.rel, ltApp, ltOne, 1)
- if keys := appKeys(t, l.fx, ltApp); len(keys) != 0 {
- t.Fatalf("the bucket still holds %v", keys)
- }
- })
+ for _, estate := range []string{"none", "email", "unreadable"} {
+ t.Run("the hourly store sweep of an app with no console row, email estate "+estate, func(t *testing.T) {
+ l := newLtOn(t)
+ l.mustCreate(t, ltOne)
+ old := time.Now().Add(-31 * 24 * time.Hour)
+ if err := release.TrashApp(ctx, l.fx.rel, "bab", ltApp, "paul@keymail.dev", old); err != nil {
+ t.Fatal(err)
+ }
+ if err := l.fx.st.TrashAppRow(OpsAccountID, ltApp, old); err != nil {
+ t.Fatal(err)
+ }
+ if err := l.fx.st.PurgeAppRow(OpsAccountID, ltApp); err != nil {
+ t.Fatal(err)
+ }
+ switch estate {
+ case "email":
+ now := time.Now()
+ putMailRecord(t, l.fx, ltApp, &mail.Record{
+ Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now},
+ Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now},
+ })
+ case "unreadable":
+ l.bucket.setHook(failNth("get", mail.SenderKey("bab", ltApp, mail.SenderAppID), 1, true))
+ }
+ if !l.fx.srv.lifetimesIteration(ctx, "expiry pass", true, false, func(ctx context.Context) { l.fx.srv.expiryPass(ctx, time.Now()) }) {
+ t.Fatal("the expiry pass did not run")
+ }
+ l.bucket.setHook(nil)
+ if estate == "none" {
+ requireRetired(t, l.fx.rel, ltApp, ltOne, 1)
+ if keys := appKeys(t, l.fx, ltApp); len(keys) != 0 {
+ t.Fatalf("the bucket still holds %v", keys)
+ }
+ return
+ }
+ // Refused before any purge step: the marker, the record and its
+ // live slot all stand, and no retirement was begun.
+ if tomb, err := release.ReadAppTombstone(ctx, l.fx.rel, "bab", ltApp); err != nil || tomb == nil {
+ t.Fatalf("marker = %+v %v, want it untouched", tomb, err)
+ }
+ if raw, _ := rawOf(t, l.fx.rel, ltApp, ltOne); raw == nil {
+ t.Fatal("the sweep removed a record of an app it must refuse")
+ }
+ if slot, _ := slotOf(t, l.fx.rel, ltOne); slot == nil || slot.State != provision.SlotLive {
+ t.Fatalf("slot = %+v, want it live", slot)
+ }
+ if rows := retireRows(t, l.fx.st, ltOne); len(rows) != 0 {
+ t.Fatalf("retirements = %+v, want none", rows)
+ }
+ })
+ }
t.Run("the guards run first and a refused purge retires nothing", func(t *testing.T) {
l := newLtOn(t)
purgeWorld(t, l)