oficina / sheets Public

fix: age decides a stale import, not the fact of a boot

Third gpt-5.6-sol pass. It confirmed the roleFor fix complete, walking
every route that decides access to a sheet, and found one new HIGH in
the sweep I had just added.

**"Stale by construction" was not.** sweepStaleImports discarded every
filling sheet at startup, on the reasoning that the process which had
been filling it was gone. That holds for a clean restart and fails
everywhere two instances overlap — a rolling replacement whose old
process is still draining, a duplicate start, or a test building a
second App over one database. There, a starting instance deletes an
import another one is still running, and the sheet vanishes from under
the person doing it mid-write. My own test demonstrated the mechanism
while asserting it was safe.

So age decides it now: twice rastrillo's 15-minute jobTimeout, after
which no live job can exist under any scheduling. An abandoned import
lingers up to half an hour before being reclaimed, which costs nothing
— it was already invisible — and erring towards leaving one alone is
the right way to be wrong here.

The test grew the case that matters: a second instance starting must
NOT discard a young import. It fails if the age check goes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6YuJ2QG6koRKjNf3ddr1Q
Paul Campbell pushed by paul@keymail.dev 8ca7d2cf7ee8298fdbf8bf40722cb39ae69b29cb parent a0ece74
3 files changed, +72 −28
  • internal/sheets/app.go +6 −4
  • internal/sheets/handlers_import.go +27 −12
  • internal/sheets/handlers_import_hidden_test.go +39 −12
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index ebac371..cbce2d0 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -103,10 +103,12 @@ func New(cfg Config) *App {
panic("sheets: wiring job handlers: " + err.Error())
}
a.jobPages = h
- // Any sheet still marked mid-import belongs to a process that is
- // gone, so this is the moment to reclaim them — before this one
- // starts an import of its own and the "stale by construction"
- // reasoning stops holding. See sweepStaleImports.
+ // Reclaim imports abandoned by a process that is not coming back.
+ // Only ones old enough to have no live job under any scheduling —
+ // two instances CAN overlap (a draining rolling replacement, a
+ // duplicate start), and a sweep that assumed otherwise would delete
+ // a running import out from under the person doing it. See
+ // sweepStaleImports.
a.sweepStaleImports()
return a
}
diff --git a/internal/sheets/handlers_import.go b/internal/sheets/handlers_import.go
index 4ab6cb4..fa83cb5 100644
--- a/internal/sheets/handlers_import.go
+++ b/internal/sheets/handlers_import.go
@@ -179,27 +179,42 @@ func (a *App) runImport(ctx context.Context, s SheetSummary, data []byte, progre
return nil
}
-// sweepStaleImports discards every sheet left mid-import by a previous
-// process.
+// staleImportAge is how long a sheet must have been filling before a
+// sweep will touch it.
//
-// At startup, a sheet still marked FillingSince is stale BY
-// CONSTRUCTION, with no timeout to guess at: the job that was filling
-// it was a goroutine in a process that is no longer running (the jobs
-// package is in-memory and unpersisted by design), so nothing is coming
-// back to finish it. That makes this the one sweep that cannot race
-// live work — within a running process every filling sheet has a live
-// job, and this only ever runs before there is one.
+// It is twice rastrillo's own jobTimeout (15 minutes), after which the
+// jobs registry marks a running job Failed and its context expires. So
+// an import older than this has no live job behind it under any
+// scheduling, and the doubling is slack for a machine that was asleep
+// or a clock that moved.
+//
+// An age check rather than "everything, at startup". The first version
+// of this swept unconditionally on the reasoning that a filling sheet
+// at process start belongs to a process that is gone — which is true of
+// a clean restart and false of every case where two instances overlap:
+// a rolling replacement where the old process is still draining, a
+// duplicate start, or a test that builds a second App over one
+// database. In those, an unconditional sweep deletes an import that is
+// still running, and the importer's own sheet vanishes from under them
+// mid-write.
+const staleImportAge = 30 * time.Minute
+
+// sweepStaleImports discards sheets left mid-import by a process that is
+// no longer coming back.
//
// Hidden was already the safe state, so this is reclamation rather than
// correctness: without it a deploy mid-import leaves the row, its
-// grants and its sealed cells behind for good.
+// grants and its sealed cells behind for good. That is also why erring
+// towards leaving one alone is the right way to be wrong.
func (a *App) sweepStaleImports() {
if a.db == nil {
return
}
+ cutoff := time.Now().UTC().Add(-staleImportAge)
var stale []store.Sheet
- if err := a.db.Where("filling_since IS NOT NULL AND deleted_at IS NULL").
- Find(&stale).Error; err != nil {
+ if err := a.db.Where(
+ "filling_since IS NOT NULL AND filling_since < ? AND deleted_at IS NULL",
+ cutoff).Find(&stale).Error; err != nil {
return
}
for _, s := range stale {
diff --git a/internal/sheets/handlers_import_hidden_test.go b/internal/sheets/handlers_import_hidden_test.go
index 7d7f4e9..4b689cc 100644
--- a/internal/sheets/handlers_import_hidden_test.go
+++ b/internal/sheets/handlers_import_hidden_test.go
@@ -158,37 +158,64 @@ func TestAdmitsIgnoresGrantsToSheetsYouCannotOpen(t *testing.T) {
}
}
-// TestStaleImportsAreSweptAtBoot covers the reclamation path. A sheet
-// still marked filling when a process starts belongs to a process that
-// is gone — the jobs registry is in-memory — so nothing is coming back
-// to finish it.
+// TestStaleImportsAreSweptAtBoot covers the reclamation path, and the
+// line it must not cross.
+//
+// A second App over one database is what a restart looks like — and
+// also what a rolling replacement and a duplicate start look like,
+// which is the whole reason age is what decides this rather than the
+// mere fact of construction. An import too young to be abandoned has to
+// survive somebody else's boot.
func TestStaleImportsAreSweptAtBoot(t *testing.T) {
app := newTestApp(t)
member := testMember(t, app)
- s, err := app.createFillingSheet("interrupted by a deploy", member)
+ old, err := app.createFillingSheet("interrupted by a deploy", member)
if err != nil {
t.Fatalf("createFillingSheet: %v", err)
}
+ stale := time.Now().UTC().Add(-staleImportAge - time.Minute)
+ if err := app.db.Model(&store.Sheet{}).Where("id = ?", old.ID).
+ Update("filling_since", stale).Error; err != nil {
+ t.Fatalf("backdate: %v", err)
+ }
- // A second App over the same database is what a restart looks like.
+ // This one started moments ago, so a live job may well still be
+ // filling it in another instance.
+ fresh, err := app.createFillingSheet("still going", member)
+ if err != nil {
+ t.Fatalf("createFillingSheet: %v", err)
+ }
+
+ // A second App over the same database.
restarted := New(Config{DB: app.db, Sealer: app.sealer, Admit: app.admit})
- var sheet store.Sheet
- if err := restarted.db.Where("id = ?", s.ID).First(&sheet).Error; err != nil {
- t.Fatalf("reload sheet: %v", err)
- }
- if sheet.DeletedAt == nil {
+ if deletedAt(t, restarted, old.ID) == nil {
t.Error("a stale import survived a restart undiscarded")
}
var grants int64
- if err := restarted.db.Model(&Grant{}).Where("sheet_id = ?", s.ID).
+ if err := restarted.db.Model(&Grant{}).Where("sheet_id = ?", old.ID).
Count(&grants).Error; err != nil {
t.Fatalf("count grants: %v", err)
}
if grants != 0 {
t.Errorf("a stale import kept %d grant(s) through a restart", grants)
}
+
+ // The guard that matters: a second instance starting must not
+ // delete an import the first one is still running.
+ if deletedAt(t, restarted, fresh.ID) != nil {
+ t.Error("a running import was discarded by another instance starting up")
+ }
+}
+
+func deletedAt(t *testing.T, app *App, id uint64) *time.Time {
+ t.Helper()
+ var sheet store.Sheet
+ if err := app.db.Where("id = ?", id).First(&sheet).Error; err != nil {
+ t.Fatalf("reload sheet %d: %v", id, err)
+ }
+ return sheet.DeletedAt
}
// TestAFileOfExactlyTheAdvertisedSizeIsAccepted pins the limit the form