Files
base/plugins/scheduler/scheduler.go
zeekay 6ffd5c83f9 logger: switch attr-helpers to variadic positional pairs
Every non-handler call site that wrapped a value in
slog.String/Int/Bool/Any/Float64/Uint64 was passing an Attr through the
variadic any... slot of (*slog.Logger).Info/Warn/Error/Debug; the same
slot accepts the bare "key", value pair with identical semantics. Drop
the wrapper so the surface is one level less indirect and so the next
two commits can swap (*slog.Logger) for a small Logger interface
without rewriting every attr expression as well.

23 files, 156 call sites collapsed; no behavior change. Five files —
tools/logger/log.go, tools/logger/batch_handler.go +_test, and
core/log_printer.go +_test — are the handler-implementation layer and
legitimately reference slog.Attr/Level/Group; they were skipped.
2026-05-15 03:50:49 -07:00

516 lines
13 KiB
Go

// Package scheduler implements a scheduled function execution plugin for Base.
//
// It provides deferred and time-based function execution with exactly-once
// semantics, retry support, and post-commit scheduling (functions scheduled
// inside hooks run only after the hook's DB transaction commits).
//
// The scheduler uses an internal Base collection "_scheduled_functions" with fields:
// - functionName (text, required)
// - args (json)
// - scheduledAt (date, required)
// - status (select: pending, running, completed, failed, cancelled)
// - result (json)
// - error (text)
// - retryCount (number)
// - createdAt (autodate)
// - completedAt (date)
//
// Example:
//
// scheduler.MustRegister(app, scheduler.Config{
// PollInterval: 1 * time.Second,
// MaxConcurrent: 10,
// RetryCount: 3,
// RetryDelay: 5 * time.Second,
// })
package scheduler
import (
"encoding/json"
"fmt"
"sync"
"time"
"github.com/hanzoai/dbx"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/types"
)
const (
// CollectionName is the internal collection used to persist scheduled functions.
CollectionName = "_scheduled_functions"
StatusPending = "pending"
StatusRunning = "running"
StatusCompleted = "completed"
StatusFailed = "failed"
StatusCancelled = "cancelled"
)
// ExecuteFunc is the callback signature for executing a scheduled function.
// It receives the function name and JSON-decoded args, and returns a result or error.
type ExecuteFunc func(functionName string, args any) (any, error)
// Config defines the configuration for the scheduler plugin.
type Config struct {
// OnExecute is called to execute a scheduled function.
// If nil, scheduled functions will fail with "no executor configured".
OnExecute ExecuteFunc
// PollInterval controls how frequently the scheduler checks for due functions.
// Defaults to 1 second.
PollInterval time.Duration
// RetryDelay is the base delay between retries of a failed function.
// Defaults to 5 seconds.
RetryDelay time.Duration
// MaxConcurrent limits the number of concurrently executing scheduled functions.
// Defaults to 10.
MaxConcurrent int
// RetryCount is the maximum number of retry attempts for a failed function.
// Defaults to 3.
RetryCount int
}
// MustRegister registers the scheduler plugin in the provided app instance
// and panics if it fails.
//
// Example:
//
// scheduler.MustRegister(app, scheduler.Config{
// PollInterval: 1 * time.Second,
// MaxConcurrent: 10,
// RetryCount: 3,
// RetryDelay: 5 * time.Second,
// })
func MustRegister(app core.App, config Config) {
if err := Register(app, config); err != nil {
panic(err)
}
}
// Register registers the scheduler plugin in the provided app instance.
func Register(app core.App, config Config) error {
p := &plugin{app: app, config: config}
if p.config.PollInterval <= 0 {
p.config.PollInterval = 1 * time.Second
}
if p.config.MaxConcurrent <= 0 {
p.config.MaxConcurrent = 10
}
if p.config.RetryCount < 0 {
p.config.RetryCount = 3
}
if p.config.RetryDelay <= 0 {
p.config.RetryDelay = 5 * time.Second
}
p.sem = make(chan struct{}, p.config.MaxConcurrent)
p.app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
if err := p.ensureCollection(); err != nil {
return fmt.Errorf("scheduler: failed to ensure collection: %w", err)
}
return nil
})
p.app.OnServe().BindFunc(func(e *core.ServeEvent) error {
if err := e.Next(); err != nil {
return err
}
p.startPoller()
return nil
})
p.app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
p.stopPoller()
return e.Next()
})
return nil
}
type plugin struct {
app core.App
config Config
sem chan struct{}
mu sync.Mutex
stopCh chan struct{}
pollDone chan struct{}
}
// ensureCollection creates the _scheduled_functions collection if it doesn't exist.
func (p *plugin) ensureCollection() error {
_, err := p.app.FindCollectionByNameOrId(CollectionName)
if err == nil {
return nil // already exists
}
collection := core.NewBaseCollection(CollectionName)
collection.System = true
collection.Fields.Add(
&core.TextField{
Name: "functionName",
Required: true,
},
&core.JSONField{
Name: "args",
MaxSize: 1048576, // 1MB
},
&core.DateField{
Name: "scheduledAt",
Required: true,
},
&core.SelectField{
Name: "status",
Values: []string{StatusPending, StatusRunning, StatusCompleted, StatusFailed, StatusCancelled},
Required: true,
MaxSelect: 1,
},
&core.JSONField{
Name: "result",
MaxSize: 1048576,
},
&core.TextField{
Name: "error",
},
&core.NumberField{
Name: "retryCount",
},
&core.AutodateField{
Name: "createdAt",
OnCreate: true,
},
&core.DateField{
Name: "completedAt",
},
)
return p.app.Save(collection)
}
// startPoller begins the background polling goroutine.
func (p *plugin) startPoller() {
p.mu.Lock()
defer p.mu.Unlock()
if p.stopCh != nil {
return // already running
}
p.stopCh = make(chan struct{})
p.pollDone = make(chan struct{})
go p.pollLoop()
}
// stopPoller signals the polling goroutine to stop and waits for it.
func (p *plugin) stopPoller() {
p.mu.Lock()
stopCh := p.stopCh
pollDone := p.pollDone
p.stopCh = nil
p.pollDone = nil
p.mu.Unlock()
if stopCh != nil {
close(stopCh)
<-pollDone
}
}
// pollLoop runs the polling loop in a separate goroutine.
func (p *plugin) pollLoop() {
defer close(p.pollDone)
ticker := time.NewTicker(p.config.PollInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.processDueFunctions()
}
}
}
// processDueFunctions finds and executes all functions that are due.
func (p *plugin) processDueFunctions() {
now := types.NowDateTime()
records, err := p.findDueFunctions(now)
if err != nil {
p.app.Logger().Error(
"scheduler: failed to find due functions",
"error", err.Error(),
)
return
}
for _, record := range records {
// claim the record via optimistic locking (status pending -> running)
if !p.claimFunction(record) {
continue
}
// acquire semaphore slot
p.sem <- struct{}{}
go func(r *core.Record) {
defer func() { <-p.sem }()
p.executeFunction(r)
}(record)
}
}
// findDueFunctions queries for pending functions whose scheduledAt <= now.
func (p *plugin) findDueFunctions(now types.DateTime) ([]*core.Record, error) {
records := []*core.Record{}
err := p.app.RecordQuery(CollectionName).
AndWhere(dbx.HashExp{"status": StatusPending}).
AndWhere(dbx.NewExp("[[scheduledAt]] <= {:now}", dbx.Params{"now": now.String()})).
OrderBy("scheduledAt ASC").
Limit(int64(p.config.MaxConcurrent)).
All(&records)
if err != nil {
return nil, err
}
return records, nil
}
// claimFunction atomically transitions a function from pending to running.
// Returns true if the claim succeeded (exactly-once semantics).
func (p *plugin) claimFunction(record *core.Record) bool {
// Use a direct SQL update with WHERE status='pending' for atomicity.
result, err := p.app.NonconcurrentDB().NewQuery(
"UPDATE {{" + CollectionName + "}} SET [[status]] = {:running} WHERE [[id]] = {:id} AND [[status]] = {:pending}",
).Bind(dbx.Params{
"running": StatusRunning,
"id": record.Id,
"pending": StatusPending,
}).Execute()
if err != nil {
p.app.Logger().Error(
"scheduler: failed to claim function",
"id", record.Id,
"error", err.Error(),
)
return false
}
rows, err := result.RowsAffected()
if err != nil {
return false
}
return rows == 1
}
// executeFunction runs the scheduled function and updates its record status.
func (p *plugin) executeFunction(record *core.Record) {
functionName := record.GetString("functionName")
var args any
rawArgs := record.Get("args")
if rawArgs != nil {
// try to decode raw JSON args
switch v := rawArgs.(type) {
case types.JSONRaw:
if len(v) > 0 {
_ = json.Unmarshal(v, &args)
}
case string:
if v != "" {
_ = json.Unmarshal([]byte(v), &args)
}
default:
args = v
}
}
executor := p.config.OnExecute
if executor == nil {
p.markFailed(record, "no executor configured")
return
}
result, err := executor(functionName, args)
if err != nil {
retryCount := int(record.GetFloat("retryCount"))
if retryCount < p.config.RetryCount {
p.scheduleRetry(record, retryCount+1, err)
return
}
p.markFailed(record, err.Error())
return
}
p.markCompleted(record, result)
}
// markCompleted updates the record to completed status with the result.
func (p *plugin) markCompleted(record *core.Record, result any) {
now := types.NowDateTime()
var resultJSON types.JSONRaw
if result != nil {
raw, err := json.Marshal(result)
if err == nil {
resultJSON = raw
}
}
_, err := p.app.NonconcurrentDB().NewQuery(
"UPDATE {{" + CollectionName + "}} SET [[status]] = {:status}, [[result]] = {:result}, [[completedAt]] = {:completedAt} WHERE [[id]] = {:id}",
).Bind(dbx.Params{
"status": StatusCompleted,
"result": string(resultJSON),
"completedAt": now.String(),
"id": record.Id,
}).Execute()
if err != nil {
p.app.Logger().Error(
"scheduler: failed to mark function completed",
"id", record.Id,
"error", err.Error(),
)
}
}
// markFailed updates the record to failed status with the error message.
func (p *plugin) markFailed(record *core.Record, errMsg string) {
now := types.NowDateTime()
_, err := p.app.NonconcurrentDB().NewQuery(
"UPDATE {{" + CollectionName + "}} SET [[status]] = {:status}, [[error]] = {:error}, [[completedAt]] = {:completedAt} WHERE [[id]] = {:id}",
).Bind(dbx.Params{
"status": StatusFailed,
"error": errMsg,
"completedAt": now.String(),
"id": record.Id,
}).Execute()
if err != nil {
p.app.Logger().Error(
"scheduler: failed to mark function failed",
"id", record.Id,
"error", err.Error(),
)
}
}
// scheduleRetry reschedules a failed function for retry with exponential backoff.
func (p *plugin) scheduleRetry(record *core.Record, retryCount int, origErr error) {
delay := p.config.RetryDelay * time.Duration(retryCount)
nextRun, _ := types.ParseDateTime(time.Now().UTC().Add(delay))
_, err := p.app.NonconcurrentDB().NewQuery(
"UPDATE {{"+CollectionName+"}} SET [[status]] = {:status}, [[scheduledAt]] = {:scheduledAt}, [[retryCount]] = {:retryCount}, [[error]] = {:error} WHERE [[id]] = {:id}",
).Bind(dbx.Params{
"status": StatusPending,
"scheduledAt": nextRun.String(),
"retryCount": retryCount,
"error": origErr.Error(),
"id": record.Id,
}).Execute()
if err != nil {
p.app.Logger().Error(
"scheduler: failed to schedule retry",
"id", record.Id,
"error", err.Error(),
)
}
}
// ScheduleAfter creates a new scheduled function to execute after delayMs milliseconds.
//
// If the calling context is inside a transaction, the record is inserted but the
// function won't be picked up until after the transaction commits (the record
// becomes visible to the poller only after commit).
//
// Returns the scheduled function record ID.
func ScheduleAfter(app core.App, delayMs int64, functionName string, args any) (string, error) {
scheduledAt, _ := types.ParseDateTime(time.Now().UTC().Add(time.Duration(delayMs) * time.Millisecond))
return createScheduledRecord(app, functionName, args, scheduledAt)
}
// ScheduleAt creates a new scheduled function to execute at the specified time.
//
// Returns the scheduled function record ID.
func ScheduleAt(app core.App, at time.Time, functionName string, args any) (string, error) {
scheduledAt, _ := types.ParseDateTime(at.UTC())
return createScheduledRecord(app, functionName, args, scheduledAt)
}
// CancelScheduled cancels a pending scheduled function by ID.
// Returns an error if the function is not found or is not in pending status.
func CancelScheduled(app core.App, id string) error {
_, err := app.NonconcurrentDB().NewQuery(
"UPDATE {{" + CollectionName + "}} SET [[status]] = {:status} WHERE [[id]] = {:id} AND [[status]] = {:pending}",
).Bind(dbx.Params{
"status": StatusCancelled,
"id": id,
"pending": StatusPending,
}).Execute()
return err
}
// ListScheduled returns all scheduled function records matching the optional status filter.
// If status is empty, all records are returned.
func ListScheduled(app core.App, status string) ([]*core.Record, error) {
query := app.RecordQuery(CollectionName).OrderBy("scheduledAt ASC")
if status != "" {
query = query.AndWhere(dbx.HashExp{"status": status})
}
records := []*core.Record{}
if err := query.All(&records); err != nil {
return nil, err
}
return records, nil
}
// createScheduledRecord inserts a new scheduled function record.
func createScheduledRecord(app core.App, functionName string, args any, scheduledAt types.DateTime) (string, error) {
collection, err := app.FindCollectionByNameOrId(CollectionName)
if err != nil {
return "", fmt.Errorf("scheduler: collection %q not found: %w", CollectionName, err)
}
record := core.NewRecord(collection)
record.Set("functionName", functionName)
record.Set("scheduledAt", scheduledAt)
record.Set("status", StatusPending)
record.Set("retryCount", 0)
if args != nil {
argsJSON, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("scheduler: failed to marshal args: %w", err)
}
record.Set("args", types.JSONRaw(argsJSON))
}
if err := app.Save(record); err != nil {
return "", fmt.Errorf("scheduler: failed to save scheduled function: %w", err)
}
return record.Id, nil
}