package laniakea import ( "context" "fmt" "time" ) // RunnerFn is the legacy function type for a runner. It receives a pointer to // the Bot and returns an error if execution fails. New blocking or I/O work // should use ContextRunnerFn so runtime cancellation can stop the callback. // // Subject to change in v2: runner callbacks may require context.Context. type RunnerFn[T AppData] func(*Bot[T]) error // ContextRunnerFn is a cancelable runner function. // The runtime context is canceled when polling or webhook execution stops. type ContextRunnerFn[T AppData] func(context.Context, *Bot[T]) error // Runner represents a configurable background or one-time task to be // executed by a Bot. // // Runners are configured using builder methods Async and Every. Once the // bot's runtime has started executing the runner, it should not be modified. // // Execution semantics: // - every=0, async=true: Run once in a goroutine (non-blocking, default). // - every=0, async=false: Run once synchronously (blocks runtime startup). // - every>0, async=true: Run repeatedly in a goroutine with the given interval. // - every>0, async=false: Invalid configuration — skipped with a warning. type Runner[T AppData] struct { name string // Human-readable name for logging async bool // If true, runs in a goroutine; else, runs synchronously every time.Duration // Interval between periodic executions; zero means one-shot fn RunnerFn[T] // The function to execute ctxFn ContextRunnerFn[T] } func executeRunnerWithContext[T AppData](ctx context.Context, runner Runner[T], bot *Bot[T]) (err error) { defer func() { if recovered := recover(); recovered != nil { err = fmt.Errorf("runner %q panicked: %v", runner.name, recovered) } }() if runner.ctxFn != nil { return runner.ctxFn(ctx, bot) } if runner.fn == nil { return fmt.Errorf("runner %q has no function", runner.name) } return runner.fn(bot) } // NewRunner creates a new Runner with the given name and function. // // The default configuration is async=true and every=0, i.e. a one-shot // goroutine that fires once when the bot runtime starts. Use Async and Every // to customize this. Do not call builder methods concurrently or after the // bot runtime has begun executing runners. // // Subject to change in v2: NewContextRunner may become the primary constructor. func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] { return Runner[T]{name: name, fn: fn, async: true, every: 0} } // NewContextRunner creates a runner whose callback observes runtime cancellation. // It is the preferred constructor for I/O, blocking, and periodic work. func NewContextRunner[T AppData](name string, fn ContextRunnerFn[T]) Runner[T] { return Runner[T]{name: name, ctxFn: fn, async: true, every: 0} } // Async sets whether the runner executes synchronously or asynchronously. // If true, the runner runs in a goroutine (non-blocking). // If false, the runner blocks the caller during execution. // // Note: periodic runners (Every > 0) require async=true and are skipped with // a warning when async=false. func (r Runner[T]) Async(async bool) Runner[T] { r.async = async return r } // Every sets the interval between repeated executions of a periodic runner. // // A zero value (the default) keeps the runner one-shot. A positive value // schedules the runner to fire repeatedly with the given interval and // requires async=true; periodic sync runners are skipped with a warning. func (r Runner[T]) Every(timeout time.Duration) Runner[T] { r.every = timeout return r } // ExecRunners executes all runners registered on the Bot with context-based lifecycle management. // // Execution semantics by configuration: // - every=0, async=true: Runs once in a goroutine; runtime shutdown waits for it. // - every=0, async=false: Runs once synchronously; warns if slower than 2 seconds. // - every>0, async=true: Runs in a loop with the configured interval until ctx.Done(). // - every>0, async=false: Skipped with a warning (invalid configuration). // // Background runners listen for ctx.Done() and gracefully shut down when the context is canceled. // // This method is typically called once during bot startup from RunWithContext or // RunWebhookWithContext. func (bot *Bot[T]) ExecRunners(ctx context.Context) { bot.logger.Infoln("Executing runners...") for _, runner := range bot.runners { if runner.every > 0 && !runner.async { bot.logger.Warnf("Runner %q is periodic but sync; skipping (use Async(true))\n", runner.name) continue } if runner.every == 0 && runner.async { // One-time async: non-blocking startup; runtime shutdown waits for completion. bot.runnerOnceWG.Add(1) go func(r Runner[T]) { defer bot.runnerOnceWG.Done() startedAt := time.Now() err := executeRunnerWithContext(ctx, r, bot) bot.safeEmitEvent(ctx, RunnerFinishedEvent{ Name: r.name, Duration: time.Since(startedAt), Err: err, }) if err != nil { bot.safeEmitEvent(ctx, ErrorEvent{ Plugin: "bot", HandlerKind: HandlerRunnerKind, HandlerName: r.name, Err: err, UserFacing: false, }) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } }(runner) } else if runner.every == 0 && !runner.async { // One-time sync: block until done t := time.Now() err := executeRunnerWithContext(ctx, runner, bot) elapsed := time.Since(t) bot.safeEmitEvent(ctx, RunnerFinishedEvent{ Name: runner.name, Duration: elapsed, Err: err, }) if err != nil { bot.safeEmitEvent(ctx, ErrorEvent{ Plugin: "bot", HandlerKind: HandlerRunnerKind, HandlerName: runner.name, Err: err, UserFacing: false, }) bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err) } if elapsed > time.Second*2 { bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) } } else if runner.every > 0 && runner.async { // Background loop: periodic execution with graceful shutdown bot.runnerBgWG.Add(1) go func(r Runner[T]) { defer bot.runnerBgWG.Done() ticker := time.NewTicker(r.every) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: } // When both ctx.Done() and ticker.C are ready at the same // time, Go's select picks one at random. Re-check ctx so a // late tick after cancellation does not fire one extra // invocation past shutdown. if ctx.Err() != nil { return } startedAt := time.Now() err := executeRunnerWithContext(ctx, r, bot) bot.safeEmitEvent(ctx, RunnerFinishedEvent{ Name: r.name, Duration: time.Since(startedAt), Err: err, }) if err != nil { bot.safeEmitEvent(ctx, ErrorEvent{ Plugin: "bot", HandlerKind: HandlerRunnerKind, HandlerName: r.name, Err: err, UserFacing: false, }) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) } } }(runner) } } }