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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user