Files
goclaw/internal/store/sqlitestore/cron_scheduler.go
T
Luan VuandLuvu182 50871eaaae fix(cron): prevent scheduler loop from blocking when a job hangs (#820)
* fix(cron): prevent scheduler loop from blocking when a job hangs

The cron scheduler's runLoop calls checkAndRunDueJobs() every second,
which previously used wg.Wait() to block until ALL claimed jobs complete.
If any single job hung (LLM provider timeout, agent loop stuck, network
issue), wg.Wait() would block indefinitely, preventing the scheduler
from ever checking for new due jobs — effectively killing all cron
scheduling until a container restart.

Changes:
- Remove wg.Wait() from both PG and SQLite cron schedulers — jobs now
  run as independent goroutines that don't block the check loop
- Add panic recovery to PG runLoop (safeCheckAndRunDueJobs wrapper)
  and per-job goroutines, matching the existing safego.Recover pattern
  in the SQLite scheduler
- Add 10-minute context timeout to the cron job handler so a hung
  agent run is cancelled instead of blocking forever
- Use select with context.Done() in the handler to respect the timeout
  when waiting for the scheduler outcome
- Invalidate PG job cache per-job on completion instead of after the
  (now-removed) batch wait

The SQLite scheduler already had safego.Recover on job goroutines but
still used wg.Wait() — this commit removes that blocking wait as well.

* fix(cron): make job timeout configurable + add SQLite runLoop panic recovery

- Add `cron.job_timeout` config field (Go duration string, default "10m")
  so operators can tune the per-job timeout for complex agent workloads
  without code changes
- Add `safeCheckJobs` panic recovery wrapper to SQLite cron runLoop,
  matching the PG scheduler's `safeCheckAndRunDueJobs` for consistency
- Use dynamic timeout string in error message for better diagnostics

* fix: remove unused "time" import from gateway_cron.go

* fix(cron): apply same fixes to SQLite DB scheduler (sqlitestore)

The SQLite DB-backed scheduler (used by desktop edition with SQLite
backend) had the exact same wg.Wait() blocking issue and missing
panic recovery as the PG scheduler. Apply identical fixes:

- Remove wg.Wait() — jobs run as independent goroutines
- Add safeCheckAndRunDueJobs panic recovery wrapper for runLoop
- Add per-job panic recovery and cache invalidation

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
2026-04-10 19:50:49 +07:00

378 lines
11 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cron"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (s *SQLiteCronStore) GetDueJobs(now time.Time) []store.CronJob {
s.mu.Lock()
if !s.cacheLoaded || time.Since(s.cacheTime) > s.cacheTTL {
s.refreshJobCache()
}
jobs := s.jobCache
s.mu.Unlock()
var due []store.CronJob
for i := range jobs {
if jobs[i].Enabled && jobs[i].State.NextRunAtMS != nil {
nextRun := time.UnixMilli(*jobs[i].State.NextRunAtMS)
if !nextRun.After(now) {
due = append(due, jobs[i])
}
}
}
return due
}
// refreshJobCache reloads all enabled jobs from DB. Must be called with mu held.
func (s *SQLiteCronStore) refreshJobCache() {
rows, err := s.db.QueryContext(s.baseCtx,
`SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, stateless, deliver, deliver_channel, deliver_to, wake_heartbeat,
next_run_at, last_run_at, last_status, last_error,
created_at, updated_at FROM cron_jobs WHERE enabled = 1`)
if err != nil {
return
}
defer rows.Close()
s.jobCache = nil
for rows.Next() {
job, err := scanCronRow(rows)
if err != nil {
continue
}
s.jobCache = append(s.jobCache, *job)
}
if err := rows.Err(); err != nil {
slog.Warn("cron: cache refresh iteration error", "error", err)
}
s.cacheLoaded = true
s.cacheTime = time.Now()
}
// InvalidateCache forces a cache refresh on the next GetDueJobs call.
func (s *SQLiteCronStore) InvalidateCache() {
s.mu.Lock()
s.cacheLoaded = false
s.mu.Unlock()
}
// recomputeStaleJobs fixes enabled jobs on startup:
// - Resets any jobs stuck in 'running' state from a previous crash.
// - Recomputes next_run_at for jobs where it is NULL (crashed mid-execution).
// - Advances past-due jobs (next_run_at < now) to their next future run time,
// preventing a flood of all missed jobs firing simultaneously after downtime.
//
// NOTE: After prolonged downtime, all past-due "every" jobs with the same interval
// will synchronize (all get next_run_at = now + interval). This is inherent because
// the original schedule anchor is not persisted. After the first execution cycle,
// anchor-based scheduling in executeOneJob preserves spacing going forward.
func (s *SQLiteCronStore) recomputeStaleJobs() {
// Reset stale 'running' status — jobs that were mid-execution when the app
// crashed will never self-recover, so mark them as interrupted on startup.
if res, err := s.db.ExecContext(s.baseCtx,
`UPDATE cron_jobs SET last_status = 'interrupted' WHERE last_status = 'running'`); err != nil {
slog.Warn("cron: failed to reset stale running jobs on startup", "error", err)
} else if n, _ := res.RowsAffected(); n > 0 {
slog.Info("cron: reset stale running jobs to interrupted", "count", n)
}
now := time.Now()
rows, err := s.db.QueryContext(s.baseCtx,
`SELECT id, schedule_kind, cron_expression, run_at, timezone, interval_ms
FROM cron_jobs WHERE enabled = 1 AND (next_run_at IS NULL OR next_run_at < ?)`, now)
if err != nil {
slog.Warn("cron: failed to query stale jobs", "error", err)
return
}
defer rows.Close()
var fixed int
for rows.Next() {
var id uuid.UUID
var scheduleKind string
var cronExpr, tz *string
var runAt *time.Time
var intervalMS *int64
if err := rows.Scan(&id, &scheduleKind, &cronExpr, &runAt, &tz, &intervalMS); err != nil {
continue
}
schedule := store.CronSchedule{Kind: scheduleKind}
if cronExpr != nil {
schedule.Expr = *cronExpr
}
if runAt != nil {
ms := runAt.UnixMilli()
schedule.AtMS = &ms
}
if intervalMS != nil {
schedule.EveryMS = intervalMS
}
if tz != nil {
schedule.TZ = *tz
}
next := computeNextRun(&schedule, now, s.defaultTZ)
if next == nil {
if scheduleKind == "at" {
s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET enabled = 0, updated_at = ? WHERE id = ?", now, id)
}
continue
}
s.db.ExecContext(s.baseCtx, "UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?", *next, now, id)
fixed++
}
if err := rows.Err(); err != nil {
slog.Warn("cron: recompute stale iteration error", "error", err)
}
if fixed > 0 {
slog.Info("cron: advanced stale/past-due jobs to next future run", "fixed", fixed)
}
}
func (s *SQLiteCronStore) runLoop() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case <-ticker.C:
s.safeCheckAndRunDueJobs()
}
}
}
// safeCheckAndRunDueJobs wraps checkAndRunDueJobs with panic recovery
// so a panic in any check/claim logic doesn't kill the runLoop goroutine.
func (s *SQLiteCronStore) safeCheckAndRunDueJobs() {
defer func() {
if r := recover(); r != nil {
slog.Error("cron: checkAndRunDueJobs panicked — runLoop continues", "panic", fmt.Sprint(r))
}
}()
s.checkAndRunDueJobs()
}
func (s *SQLiteCronStore) checkAndRunDueJobs() {
dueJobs := s.GetDueJobs(time.Now())
if len(dueJobs) == 0 {
return
}
s.mu.Lock()
handler := s.onJob
s.mu.Unlock()
if handler == nil {
return
}
now := time.Now()
var claimedJobs []store.CronJob
for _, job := range dueJobs {
if id, parseErr := uuid.Parse(job.ID); parseErr == nil && s.claimDueJob(id, now) {
claimedJobs = append(claimedJobs, job)
}
}
if len(claimedJobs) == 0 {
return
}
// Execute jobs in parallel without blocking the runLoop.
// Previously wg.Wait() blocked here — if any job hung (e.g. LLM timeout,
// agent loop stuck), the entire cron scheduler would stop checking for new
// due jobs. Now each job runs independently; cache is invalidated per-job.
for _, job := range claimedJobs {
go func(job store.CronJob) {
defer func() {
if r := recover(); r != nil {
slog.Error("cron: job execution panicked", "job_id", job.ID, "job_name", job.Name, "panic", fmt.Sprint(r))
}
// Invalidate cache so the next tick picks up the updated next_run_at.
s.InvalidateCache()
}()
s.executeOneJob(job, handler, true)
}(job)
}
}
// executeOneJob runs a claimed job. When reloadClaimed is true (scheduler path),
// it re-reads the job from DB to verify claim invariants (enabled + next_run_at IS NULL).
// When false (manual RunJob path), it uses the already-loaded job directly.
func (s *SQLiteCronStore) executeOneJob(job store.CronJob, handler func(job *store.CronJob) (*store.CronJobResult, error), reloadClaimed bool) {
// Preserve the original scheduled time before reload clears it.
// For "every" jobs, this anchor is used to compute the next run from the
// intended schedule time (not "now"), preventing drift and synchronization.
scheduledAtMS := job.State.NextRunAtMS
// For manual runs (reloadClaimed=false), don't use anchor — manual triggers
// should reset the schedule to now + interval, not preserve the original offset.
if !reloadClaimed {
scheduledAtMS = nil
}
if reloadClaimed {
if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
freshJob, ok := s.loadClaimedJob(id)
if !ok {
slog.Info("cron job skipped after claim state changed", "id", job.ID)
return
}
job = *freshJob
}
}
startTime := time.Now()
var lastResult *store.CronJobResult
resultStr, attempts, err := cron.ExecuteWithRetry(func() (string, error) {
r, e := handler(&job)
if e != nil {
return "", e
}
lastResult = r
if r != nil {
return r.Content, nil
}
return "", nil
}, s.retryCfg)
durationMS := time.Since(startTime).Milliseconds()
if attempts > 1 {
slog.Info("cron job retried", "id", job.ID, "attempts", attempts, "success", err == nil)
}
now := time.Now()
status := "ok"
var lastError *string
if err != nil {
status = "error"
errStr := err.Error()
lastError = &errStr
}
var inputTokens, outputTokens int
if lastResult != nil {
inputTokens = lastResult.InputTokens
outputTokens = lastResult.OutputTokens
}
logID := uuid.Must(uuid.NewV7())
var summary *string
if err == nil {
truncated := cron.TruncateOutput(resultStr)
summary = &truncated
}
if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
var agentUUID *uuid.UUID
if aid, aidErr := uuid.Parse(job.AgentID); aidErr == nil {
agentUUID = &aid
}
s.db.ExecContext(s.baseCtx,
`INSERT INTO cron_run_logs (id, job_id, agent_id, status, error, summary, duration_ms, input_tokens, output_tokens, ran_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
logID, id, agentUUID, status, lastError, summary, durationMS, inputTokens, outputTokens, now,
)
}
if job.DeleteAfterRun {
if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
s.db.ExecContext(s.baseCtx, "DELETE FROM cron_jobs WHERE id = ?", id)
}
} else if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
schedule := job.Schedule
var nextRunValue any
// For "every" (interval) jobs, compute next run from the original scheduled
// time (anchor) instead of "now". This prevents drift and synchronization
// of interval-based jobs after server restarts.
if schedule.Kind == "every" && scheduledAtMS != nil && schedule.EveryMS != nil && *schedule.EveryMS > 0 {
anchor := time.UnixMilli(*scheduledAtMS)
interval := time.Duration(*schedule.EveryMS) * time.Millisecond
// O(1) advance to the next future slot from anchor
elapsed := now.Sub(anchor)
periods := int64(elapsed / interval)
next := anchor.Add(interval * time.Duration(periods+1))
nextRunValue = next
} else {
next := computeNextRun(&schedule, now, s.defaultTZ)
if next != nil {
nextRunValue = *next
}
}
s.db.ExecContext(s.baseCtx,
`UPDATE cron_jobs SET
last_run_at = ?, last_status = ?, last_error = ?, updated_at = ?,
next_run_at = CASE WHEN enabled = 1 AND next_run_at IS NULL THEN ? ELSE next_run_at END
WHERE id = ?`,
now, status, lastError, now, nextRunValue, id,
)
}
evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status}
if err != nil {
evt.Action = "error"
evt.Error = err.Error()
}
s.emitEvent(evt)
}
func (s *SQLiteCronStore) claimDueJob(id uuid.UUID, now time.Time) bool {
res, err := s.db.ExecContext(
s.baseCtx,
`UPDATE cron_jobs
SET next_run_at = NULL
WHERE id = ? AND enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`,
id,
now,
)
if err != nil {
slog.Warn("cron: failed to claim due job", "id", id, "error", err)
return false
}
n, _ := res.RowsAffected()
return n == 1
}
func (s *SQLiteCronStore) loadClaimedJob(id uuid.UUID) (*store.CronJob, bool) {
row := s.db.QueryRowContext(
s.baseCtx,
`SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
interval_ms, payload, delete_after_run, stateless, deliver, deliver_channel, deliver_to, wake_heartbeat,
next_run_at, last_run_at, last_status, last_error,
created_at, updated_at
FROM cron_jobs
WHERE id = ? AND enabled = 1 AND next_run_at IS NULL`,
id,
)
job, err := scanCronRow(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, false
}
if err != nil {
slog.Warn("cron: failed to reload claimed job", "id", id, "error", err)
return nil, false
}
return job, true
}