FILE / ScuroNeko/Laniakea
runners.go
Исходный файл и его история в репозитории.
(fix): harden concurrent lifecycle (tests): add regression coverage (doc): update v1.2 guidance
201 lines
7.0 KiB
Go
201 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|