carlos / platform Public

console: the orphan sweep writes a run's run-end row before deleting it (final review I2)

A run a box finished with no follower got its row only from a housekeeping
pass over the record; a new admission that replaced it first left it to the
orphan pass, which deleted it with no row, and a run whose record let it go
never got one. sweepOrphanRuns now reads the result first and audits it, or
writes a create-only lost result and audits that. The audited marker keeps it
to one row, and a run whose marker did not land is left for a later pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cf8be950db01b4f9fbfc6ba0cfbc388c3b7162ce parent 0d8d6c5
2 files changed, +135 −4
  • internal/console/runsweep.go +58 −4
  • internal/console/runsweep_test.go +77 −0
diff --git a/internal/console/runsweep.go b/internal/console/runsweep.go
index 55c351be2..311d93b55 100644
--- a/internal/console/runsweep.go
+++ b/internal/console/runsweep.go
@@ -213,16 +213,19 @@ func (s *Server) sweepTerminalRun(ctx context.Context, c runCall, r *provision.R
// runs whose delete was cut short, runs replaced by a later admission,
// admissions that lost their CAS or whose outcome was unknown, runs of
// deleted hosts. With a request.json the directory goes once the request is
-// a day old. Without one it is a stray (request.json is written first and
-// deleted last but for the audited marker, so it is neither a create in
-// flight nor a run anyone can read): it goes once its newest object is a day
-// old, and stays when the store cannot say.
+// a day old, after its run-end row (orphanRunEnd). Without one it is a stray
+// (request.json is written first and deleted last but for the audited
+// marker, so it is neither a create in flight nor a run anyone can read): it
+// goes once its newest object is a day old, and stays when the store cannot
+// say.
func (s *Server) sweepOrphanRuns(ctx context.Context, acct, app string, named map[string]bool, now time.Time) {
rel := s.cfg.Releases
dirs, err := rel.ListDirs(ctx, runs.RunsPrefix(acct, app))
if err != nil {
return
}
+ var account *Account
+ accountRead := false
for _, dir := range dirs {
if named[dir] {
continue
@@ -237,6 +240,16 @@ func (s *Server) sweepOrphanRuns(ctx context.Context, acct, app string, named ma
if now.Sub(req.RequestedAt) <= runs.KeepFor {
continue
}
+ if !accountRead {
+ if account, err = s.store.AccountBySqid(acct); err != nil {
+ log.Printf("console: run sweep: account %s: %v", acct, err)
+ return // no run of it is deleted before its row can be written
+ }
+ accountRead = true
+ }
+ if account != nil && !s.orphanRunEnd(ctx, account, app, req.Host, id, now) {
+ continue
+ }
case errors.Is(err, os.ErrNotExist):
if !s.strayIsOld(ctx, dir, now) {
continue
@@ -250,6 +263,47 @@ func (s *Server) sweepOrphanRuns(ctx context.Context, acct, app string, named ma
}
}
+// orphanRunEnd writes an orphan run's run-end row before its objects go
+// (run spec §6). A run that ended with no follower and was replaced by a
+// later admission before a housekeeping pass, or whose record let it go,
+// has none yet. With a result the row says what it says; without one the
+// run never came back, so it gets a lost result (create-only: a result the
+// box writes first stands) and the row says that. The audited marker makes
+// this a no-op for a run that has its row. It reports whether the run may
+// be deleted.
+func (s *Server) orphanRunEnd(ctx context.Context, acct *Account, app, host, id string, now time.Time) bool {
+ rel := s.cfg.Releases
+ res, err := runs.ReadResult(ctx, rel, acct.Sqid, app, id)
+ if err != nil {
+ log.Printf("console: run sweep: %s/%s %s: read orphan result: %v", acct.Sqid, app, id, err)
+ return false
+ }
+ if res == nil {
+ last, err := runs.LastChunkSeq(ctx, rel, acct.Sqid, app, id)
+ if err != nil {
+ log.Printf("console: run sweep: %s/%s %s: list orphan chunks: %v", acct.Sqid, app, id, err)
+ return false
+ }
+ lost := runs.Result{Interrupted: runs.InterruptedLost, LastSeq: last, Ended: now.UTC()}
+ if err := runs.WriteResult(ctx, rel, acct.Sqid, app, id, lost); err != nil {
+ log.Printf("console: run sweep: %s/%s %s: orphan lost result: %v", acct.Sqid, app, id, err)
+ return false
+ }
+ if res, err = runs.ReadResult(ctx, rel, acct.Sqid, app, id); err != nil || res == nil {
+ log.Printf("console: run sweep: %s/%s %s: read orphan result back: %v", acct.Sqid, app, id, err)
+ return false
+ }
+ }
+ s.auditRunEnd(ctx, acct, app, host, id, runEndOutcome(res), res)
+ // The marker stands only once the row has had its chance; a marker the
+ // bucket refused leaves the run for the next pass.
+ if _, _, err := rel.Get(ctx, runs.AuditedKey(acct.Sqid, app, id)); err != nil {
+ log.Printf("console: run sweep: %s/%s %s: no audit marker yet, delete left for a later pass: %v", acct.Sqid, app, id, err)
+ return false
+ }
+ return true
+}
+
// strayIsOld reports whether a run directory with no request.json has had
// nothing written to it for a day. A store that cannot list modification
// times (both real stores can) leaves strays alone.
diff --git a/internal/console/runsweep_test.go b/internal/console/runsweep_test.go
index ecc9c2c23..8a3364420 100644
--- a/internal/console/runsweep_test.go
+++ b/internal/console/runsweep_test.go
@@ -448,3 +448,80 @@ func TestRunSweepPassIsBoundedByATimeout(t *testing.T) {
t.Fatal("a hung store stalled the pass")
}
}
+
+// A run a box finished with no follower, replaced by a new admission before
+// a housekeeping pass, is an orphan when the sweep comes to it. Its row is
+// written before its objects go.
+func TestRunSweepWritesTheRowOfAReplacedRunBeforeDeletingIt(t *testing.T) {
+ fx := runFixture(t)
+ id := fx.finishedRun(t)
+ fx.startRun(t) // the admission replaces the finished run on the record
+ if ends := fx.runEnds(t, id); len(ends) != 0 {
+ t.Fatalf("run-end rows before the sweep %q", ends)
+ }
+ fx.srv.runSweep(context.Background(), time.Now().Add(25*time.Hour))
+ if left := fx.left(id); len(left) != 0 {
+ t.Fatalf("the orphan survived: %q", left)
+ }
+ if ends := fx.runEnds(t, id); len(ends) != 1 || !strings.Contains(ends[0], `"result"`) {
+ t.Fatalf("run-end rows %q", ends)
+ }
+}
+
+// An orphan with no result (its record let it go before any box wrote one)
+// gets a lost result and its row says lost. A run that already has its row
+// does not get a second.
+func TestRunSweepWritesALostRowForAnOrphanWithNoResult(t *testing.T) {
+ fx := runFixture(t)
+ ctx := context.Background()
+ old := runs.Request{Schema: 1, ID: "run_cccccccccccccccccccccccccc", Account: release.OpsAccount, App: "shop", Host: runTestHost, By: "alice@keymail.dev", RequestedAt: time.Now().Add(-25 * time.Hour)}
+ fx.putRunJSON(t, runs.RequestKey(old.Account, old.App, old.ID), old)
+ fx.chunk(t, old.ID, 1, runs.StreamOut, "a")
+ rowed := old
+ rowed.ID = "run_dddddddddddddddddddddddddd"
+ fx.putRunJSON(t, runs.RequestKey(rowed.Account, rowed.App, rowed.ID), rowed)
+ if _, err := fx.rel.Put(ctx, runs.AuditedKey(rowed.Account, rowed.App, rowed.ID), []byte("result"), ""); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.runSweep(ctx, time.Now())
+ for _, id := range []string{old.ID, rowed.ID} {
+ if left := fx.left(id); len(left) != 0 {
+ t.Fatalf("%s survived: %q", id, left)
+ }
+ }
+ ends := fx.runEnds(t, old.ID)
+ if len(ends) != 1 || !strings.Contains(ends[0], `"lost"`) || !strings.Contains(ends[0], `"last_seq":1`) {
+ t.Fatalf("run-end rows %q", ends)
+ }
+ if ends := fx.runEnds(t, rowed.ID); len(ends) != 0 {
+ t.Fatalf("a run with its row got another: %q", ends)
+ }
+}
+
+// A marker the bucket refuses leaves the orphan for a later pass, so its
+// row is never lost to the delete.
+func TestRunSweepKeepsAnOrphanWhoseRowCouldNotBeMarked(t *testing.T) {
+ fx := runFixture(t)
+ ctx := context.Background()
+ old := runs.Request{Schema: 1, ID: "run_cccccccccccccccccccccccccc", Account: release.OpsAccount, App: "shop", Host: runTestHost, RequestedAt: time.Now().Add(-25 * time.Hour)}
+ fx.putRunJSON(t, runs.RequestKey(old.Account, old.App, old.ID), old)
+ refuse := true
+ fx.srv.cfg.Releases = &faultStore{Store: fx.rel, put: func(key string) (bool, error) {
+ if refuse && strings.HasSuffix(key, "/audited") {
+ return false, errors.New("access denied")
+ }
+ return false, nil
+ }}
+ fx.srv.runSweep(ctx, time.Now())
+ if left := fx.left(old.ID); len(left) == 0 {
+ t.Fatal("the orphan was deleted with no row")
+ }
+ refuse = false
+ fx.srv.runSweep(ctx, time.Now())
+ if left := fx.left(old.ID); len(left) != 0 {
+ t.Fatalf("the orphan survived: %q", left)
+ }
+ if ends := fx.runEnds(t, old.ID); len(ends) != 1 {
+ t.Fatalf("run-end rows %q", ends)
+ }
+}