fix: a crawl whose store runs out of time still ends failed

The store keeps FinishTimeout, the final status write and the conga job
end get their own 30 s bound and prune its own store-sized bound, so the
row no longer stays running with the error lost until RecoverStale; adds
the code-only crawl.WithFinishTimeout seam.

quick-261104-xae, review WR-03.
This commit is contained in:
Jakub Zych
2026-11-05 00:22:07 +01:00
parent 2a288a9570
commit c71b58eea7
3 changed files with 114 additions and 15 deletions

View File

@@ -45,7 +45,8 @@ func (CrawlArgs) Kind() string { return CrawlKind }
// The crawl job's time bounds (P33-60). JobTimeout is the job's deadline;
// FinishTimeout bounds storing what a crawl found, on a context the job's
// cancellation cannot reach; the job writes heartbeat_at every
// cancellation cannot reach; the final status is then written on a
// 30-second bound of its own. The job writes heartbeat_at every
// HeartbeatEvery, and a running crawl without a heartbeat for StaleAfter
// is ended by the next Start or delete (RecoverStale).
const (
@@ -58,6 +59,33 @@ const (
// heartbeatWrite bounds one heartbeat update.
const heartbeatWrite = 10 * time.Second
// finalWriteTimeout bounds a crawl's final status write and the end of its
// conga record, on a context of their own, so a store that used up its
// FinishTimeout bound still ends the crawl (33-REVIEW-GAPFIX WR-03).
const finalWriteTimeout = 30 * time.Second
type finishTimeoutKey struct{}
// WithFinishTimeout returns a context whose crawls bound storing their
// results by d instead of FinishTimeout. It is the test seam of the store
// bound, settable only from Go code (like WithRenderer and
// fetchguard.WithTransport): no config key, setting, request or MCP call
// reaches it.
func WithFinishTimeout(ctx context.Context, d time.Duration) context.Context {
return context.WithValue(ctx, finishTimeoutKey{}, d)
}
// finishTimeoutFrom returns the store bound of ctx: its WithFinishTimeout
// value when that is positive, else FinishTimeout.
func finishTimeoutFrom(ctx context.Context) time.Duration {
if ctx != nil {
if d, ok := ctx.Value(finishTimeoutKey{}).(time.Duration); ok && d > 0 {
return d
}
}
return FinishTimeout
}
// The crawl limits used until the Site audit settings say otherwise.
const (
DefaultMaxPages = 5000
@@ -194,8 +222,10 @@ func LoadCrawl(db *gorm.DB, id uint) (models.Crawl, error) {
//
// The job's context ends on a cancel (River cancels a running job's
// context) or at the job timeout; what was fetched is then stored on a
// context detached from it with its own FinishTimeout bound, so a
// cancelled or timed-out crawl keeps its results and never stays running.
// context detached from it with its own FinishTimeout bound, and the final
// status is written on another detached context bounded by
// finalWriteTimeout, so a cancelled or timed-out crawl keeps its results
// and a crawl whose store ran out of time still ends: none stays running.
// While it runs, the job writes heartbeat_at every HeartbeatEvery.
func Run(ctx context.Context, svc *classes.Service, id uint) error {
gdb, err := svc.GormDB()
@@ -222,9 +252,7 @@ func Run(ctx context.Context, svc *classes.Service, id uint) error {
stop := heartbeat(ctx, gdb, id)
defer stop()
res, runErr := run(ctx, svc, db, row)
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), FinishTimeout)
defer cancel()
return finish(fctx, svc, gdb.WithContext(fctx), row, res, runErr, ctx.Err())
return finish(context.WithoutCancel(ctx), svc, gdb, row, res, runErr, ctx.Err(), finishTimeoutFrom(ctx))
}
// heartbeat writes the crawl's heartbeat_at every HeartbeatEvery until the
@@ -354,11 +382,19 @@ func finalStatus(requested bool, res *Result, runErr, ctxErr error) (string, str
return models.CrawlFinished, ""
}
// finish stores what a crawl found and writes its final state. ctx is the
// detached, bounded context of Run, ctxErr the job context's error (nil
// while it was alive). A LoadLimits failure falls back to the default
// limits.
func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Crawl, res *Result, runErr, ctxErr error) error {
// finish stores what a crawl found and writes its final state. parent is
// Run's context detached from the job's cancellation, gdb the undecorated
// database, ctxErr the job context's error (nil while it was alive). The
// cancel check, the limits and the store run on a context bounded by
// storeBound; a store that runs past it fails the crawl with an error that
// names the bound and the store error. The final status write
// and the end of the conga record then run on a fresh context bounded by
// finalWriteTimeout, and Prune on one of its own. A LoadLimits failure
// falls back to the default limits.
func finish(parent context.Context, svc *classes.Service, gdb *gorm.DB, row models.Crawl, res *Result, runErr, ctxErr error, storeBound time.Duration) error {
storeCtx, cancelStore := context.WithTimeout(parent, storeBound)
defer cancelStore()
db := gdb.WithContext(storeCtx)
status, errText := finalStatus(cancelRequested(db, row.ID), res, runErr, ctxErr)
limits, err := LoadLimits(db)
if err != nil {
@@ -384,9 +420,14 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C
id := row.ID
return SaveIssues(tx, &id, findings, cms)
}
if err := lagoon.Transaction(ctx, db, store); err != nil {
if err := lagoon.Transaction(storeCtx, db, store); err != nil {
status, errText = models.CrawlFailed, err.Error()
if errors.Is(storeCtx.Err(), context.DeadlineExceeded) {
errText = "storing the crawl results took longer than " + storeBound.String() + ": " + err.Error()
}
}
ctx, cancelWrite := context.WithTimeout(parent, finalWriteTimeout)
defer cancelWrite()
updates := map[string]any{"status": status, "error": errText, "finished_at": time.Now().UTC()}
if res != nil {
updates["pages"] = len(res.Order)
@@ -395,7 +436,7 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C
updates["render_pages"] = res.Render.Pages
updates["render_note"] = res.Render.Note
}
if err := db.Model(&models.Crawl{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
if err := gdb.WithContext(ctx).Model(&models.Crawl{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return err
}
if m, err := conga.From(svc.App()); err == nil && row.JobID != nil {
@@ -412,7 +453,12 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C
if status == models.CrawlFailed {
pluginLog.Warn("golem15.seo: crawl failed", "crawl", row.ID, "error", errText)
}
if err := Prune(db, limits.Keep); err != nil {
// Prune deletes old crawls with their pages and links, so it keeps a
// store-sized bound; it runs after the final state is written, so it
// never holds a crawl running.
pruneCtx, cancelPrune := context.WithTimeout(parent, storeBound)
defer cancelPrune()
if err := Prune(gdb.WithContext(pruneCtx), limits.Keep); err != nil {
pluginLog.Warn("golem15.seo: prune crawls", "error", err)
}
return nil