mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 21:03:59 +00:00
292e63dfa3
## Summary - **Flood after restart**: `recomputeStaleJobs()` now advances past-due jobs (not just NULL) to their next future run time, preventing all missed jobs from firing simultaneously on first tick - **Interval drift**: Anchor-based scheduling computes next run from original scheduled time instead of execution end time, preserving per-job offsets - **O(1) advance**: Replaced O(N) loop with modular arithmetic to prevent CPU starvation after prolonged downtime with short-interval jobs - **SQLite RunJob fix**: Added `next_run_at = NULL` claim + `reloadClaimed` param to match PG store behavior — manual runs were silently skipped - **Manual RunJob consistency**: Nil-anchor guard ensures manual triggers use `now + interval` (not anchor) across all stores - **Zombie at-job fix**: JSON store Start() now disables past-due one-time `at` jobs instead of leaving them enabled with nil NextRunAtMS All fixes applied across JSON, PostgreSQL, and SQLite store implementations. 4 new unit tests covering flood prevention, anchor arithmetic, RunJob scheduling, and at-job disabling.
143 lines
4.1 KiB
Go
143 lines
4.1 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func (s *SQLiteCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) {
|
|
job, ok := s.GetJob(ctx, jobID)
|
|
if !ok {
|
|
return false, "", fmt.Errorf("job %s not found", jobID)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
handler := s.onJob
|
|
s.mu.Unlock()
|
|
|
|
if handler == nil {
|
|
return false, "", fmt.Errorf("no job handler configured")
|
|
}
|
|
|
|
// Claim the job for manual execution by clearing next_run_at (match PG behavior).
|
|
// executeOneJob with reloadClaimed=false skips loadClaimedJob, using the
|
|
// already-loaded job directly — allowing manual runs on disabled jobs.
|
|
if id, parseErr := uuid.Parse(jobID); parseErr == nil {
|
|
if _, execErr := s.db.ExecContext(ctx,
|
|
"UPDATE cron_jobs SET last_status = 'running', next_run_at = NULL, updated_at = ? WHERE id = ?",
|
|
time.Now(), id); execErr != nil {
|
|
slog.Warn("cron: failed to mark job running", "job", jobID, "error", execErr)
|
|
}
|
|
}
|
|
s.InvalidateCache()
|
|
|
|
s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID})
|
|
s.executeOneJob(*job, handler, false)
|
|
|
|
s.mu.Lock()
|
|
s.cacheLoaded = false
|
|
s.mu.Unlock()
|
|
return true, "", nil
|
|
}
|
|
|
|
func (s *SQLiteCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)"
|
|
|
|
// Tenant isolation via JOIN with cron_jobs.
|
|
var tenantJoin, tenantWhere string
|
|
var tenantArgs []any
|
|
if !store.IsCrossTenant(ctx) {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil {
|
|
tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id"
|
|
tenantWhere = " AND j.tenant_id = ?"
|
|
tenantArgs = append(tenantArgs, tid)
|
|
}
|
|
}
|
|
|
|
var total int
|
|
var rows *sql.Rows
|
|
var err error
|
|
|
|
if jobID != "" {
|
|
id, parseErr := uuid.Parse(jobID)
|
|
if parseErr != nil {
|
|
return nil, 0
|
|
}
|
|
countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = ?%s", tenantJoin, tenantWhere)
|
|
countArgs := append(tenantArgs, id)
|
|
s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
|
|
|
|
dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = ?%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?",
|
|
cols, tenantJoin, tenantWhere)
|
|
dataArgs := append(tenantArgs, id, limit, offset)
|
|
rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
|
|
} else {
|
|
countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere)
|
|
s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total)
|
|
|
|
dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT ? OFFSET ?",
|
|
cols, tenantJoin, tenantWhere)
|
|
dataArgs := append(tenantArgs, limit, offset)
|
|
rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...)
|
|
}
|
|
if err != nil {
|
|
return nil, 0
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []store.CronRunLogEntry
|
|
for rows.Next() {
|
|
var jobUUID uuid.UUID
|
|
var status string
|
|
var errStr, summary *string
|
|
var ranAt time.Time
|
|
var durationMS int64
|
|
var inputTokens, outputTokens int
|
|
if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil {
|
|
continue
|
|
}
|
|
result = append(result, store.CronRunLogEntry{
|
|
Ts: ranAt.UnixMilli(),
|
|
JobID: jobUUID.String(),
|
|
Status: status,
|
|
Error: derefStr(errStr),
|
|
Summary: derefStr(summary),
|
|
DurationMS: durationMS,
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
})
|
|
}
|
|
if rErr := rows.Err(); rErr != nil {
|
|
slog.Warn("cron: run log iteration error", "error", rErr)
|
|
}
|
|
return result, total
|
|
}
|
|
|
|
func (s *SQLiteCronStore) Status() map[string]any {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var count int64
|
|
s.db.QueryRow("SELECT COUNT(*) FROM cron_jobs WHERE enabled = 1").Scan(&count)
|
|
return map[string]any{
|
|
"enabled": s.running,
|
|
"jobs": count,
|
|
}
|
|
}
|