diff --git a/docs/README.md b/docs/README.md index 7eb526f..0cef75f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,12 +14,13 @@ Build Discord bots with PHP, on top of [Tempest](https://tempestphp.com). - [Plugins](guides/06-plugins.md) - [Components](guides/07-components.md) - [Cache](guides/08-cache.md) +- [Scheduled tasks](guides/09-scheduled-tasks.md) ## Reference Generated from the source, so it describes what the framework actually does. -**Attributes** — [Command](reference/attributes/command.md), [SubcommandGroup](reference/attributes/subcommand-group.md), [Subcommand](reference/attributes/subcommand.md), [Option](reference/attributes/option.md), [Event](reference/attributes/event.md), [Autocomplete](reference/attributes/autocomplete.md), [Button](reference/attributes/button.md), [SelectMenu](reference/attributes/select-menu.md), [ModalSubmit](reference/attributes/modal-submit.md) +**Attributes** — [Command](reference/attributes/command.md), [SubcommandGroup](reference/attributes/subcommand-group.md), [Subcommand](reference/attributes/subcommand.md), [Option](reference/attributes/option.md), [Event](reference/attributes/event.md), [Autocomplete](reference/attributes/autocomplete.md), [Button](reference/attributes/button.md), [SelectMenu](reference/attributes/select-menu.md), [ModalSubmit](reference/attributes/modal-submit.md), [Scheduled](reference/attributes/scheduled.md) **Autocomplete** — [Autocomplete](reference/autocomplete/autocomplete.md), [ArrayAutocomplete](reference/autocomplete/array-autocomplete.md) diff --git a/docs/guides/09-scheduled-tasks.md b/docs/guides/09-scheduled-tasks.md new file mode 100644 index 0000000..ad755cc --- /dev/null +++ b/docs/guides/09-scheduled-tasks.md @@ -0,0 +1,68 @@ +# Scheduled tasks + +Some of what a bot does is not a reply to anything: sweeping rows that have run their +course, lifting blocks that have expired, polling a service with no gateway event of its +own. `#[Scheduled]` declares an invokable class as work the bot does on a timer. + +```php +use Tempcord\Attributes\Scheduled; + +#[Scheduled(everySeconds: 10)] +final readonly class SweepTemporaryMessages +{ + public function __construct(private TempMessages $messages) {} + + public function __invoke(): void + { + $this->messages->sweep(); + } +} +``` + +That is the whole registration. The class is discovered like a command or a listener, is +built by the container so it may take whatever dependencies it needs, and is put on the +event loop before the gateway opens. + +## What the framework guarantees + +A timer is less forgiving than an event listener: it fires again whether or not the last +turn finished or threw, forever. Three things are handled so you do not have to write them +into every task. + +- **A task that throws is logged and keeps its place.** Without that the exception travels + into the event loop, and the usual result is that the timer is cancelled and nothing is + ever swept again — silently, because the bot carries on answering commands. +- **A task is never started alongside itself.** If a turn is still running when the next + one is due, that turn is skipped and the skip is logged. Otherwise a task slower than its + own interval makes every following turn slower until nothing else gets a look in. +- **Each turn runs in a fiber**, so a task may `await` the REST API exactly as a command + handler does. + +## The first turn + +The first turn comes after the interval, not at boot. A scheduled task is a repeating +chore; something that must happen once at startup — a reconciliation against what changed +while the bot was down — belongs in a plugin's `boot()`, where its ordering against +everything else is visible. + +```php +final readonly class VoicePlugin implements Plugin +{ + public function __construct(private VoiceReconciler $reconciler) {} + + public function boot(Tempcord $tempcord): void + { + $this->reconciler->catchUp(); + } +} +``` + +## Choosing an interval + +`everySeconds` is a float, so sub-second intervals are allowed, and zero is refused at +discovery — it asks the loop to run the task as fast as it can, which starves the gateway +heartbeat and drops the connection. + +Prefer one cheap sweep that runs often over a clever one that runs rarely: a query over an +indexed `expires_at` costs almost nothing, and a task that runs every ten seconds needs no +reasoning about when it last ran or what it missed across a restart. diff --git a/docs/index.json b/docs/index.json index bb16faf..19fe2bc 100644 --- a/docs/index.json +++ b/docs/index.json @@ -31,6 +31,10 @@ { "title": "Cache", "slug": "guides/08-cache" + }, + { + "title": "Scheduled tasks", + "slug": "guides/09-scheduled-tasks" } ], "reference": { @@ -324,6 +328,25 @@ ], "cases": [], "methods": [] + }, + { + "name": "Scheduled", + "fqcn": "Tempcord\\Attributes\\Scheduled", + "kind": "attribute", + "target": "class", + "summary": "Declares an invokable class as work the bot does on a timer.", + "slug": "reference/attributes/scheduled", + "parameters": [ + { + "name": "everySeconds", + "type": "float", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] } ], "autocomplete": [ diff --git a/docs/reference/attributes/scheduled.md b/docs/reference/attributes/scheduled.md new file mode 100644 index 0000000..33c48a3 --- /dev/null +++ b/docs/reference/attributes/scheduled.md @@ -0,0 +1,18 @@ + + +# Scheduled + +Declares an invokable class as work the bot does on a timer. + +```php +use Tempcord\Attributes\Scheduled; +``` + +**Applies to:** class + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `everySeconds` | `float` | *required* | | + diff --git a/docs/reference/index.md b/docs/reference/index.md index 69fd048..d3fca3a 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -13,6 +13,7 @@ - [Button](attributes/button.md) — Declares a class or method as the handler for a button press. - [SelectMenu](attributes/select-menu.md) — Declares a class or method as the handler for a select menu choice. - [ModalSubmit](attributes/modal-submit.md) — Declares a class or method as the handler for a submitted modal. +- [Scheduled](attributes/scheduled.md) — Declares an invokable class as work the bot does on a timer. ## Autocomplete diff --git a/src/Attributes/Scheduled.php b/src/Attributes/Scheduled.php new file mode 100644 index 0000000..7980a1f --- /dev/null +++ b/src/Attributes/Scheduled.php @@ -0,0 +1,24 @@ +getReflection()->hasMethod('__invoke')) { + throw new RuntimeException( + 'Class [' . $class->getName() . '] should declare an __invoke method', + ); + } + + /* + * An interval of zero asks the loop to run the task as fast as it can, + * which starves everything else including the gateway heartbeat. That + * is never what someone meant to write. + */ + if ($scheduled->everySeconds <= 0) { + throw new RuntimeException( + 'Scheduled task [' . $class->getName() . '] must run at an interval greater than zero.', + ); + } + + return new ScheduledTaskDefinition( + task: $class->getName(), + everySeconds: $scheduled->everySeconds, + method: $class->getMethod('__invoke'), + ); + } +} diff --git a/src/Definitions/ScheduledTaskDefinition.php b/src/Definitions/ScheduledTaskDefinition.php new file mode 100644 index 0000000..2d6ead9 --- /dev/null +++ b/src/Definitions/ScheduledTaskDefinition.php @@ -0,0 +1,17 @@ +getAttributes(Scheduled::class) as $attribute) { + $this->discoveryItems->add($location, $this->compiler->compile($class, $attribute)); + } + } + + public function apply(): void + { + foreach ($this->discoveryItems as $task) { + $this->registry->add($task); + } + } +} diff --git a/src/Registries/ScheduledTasksRegistry.php b/src/Registries/ScheduledTasksRegistry.php new file mode 100644 index 0000000..a7e4143 --- /dev/null +++ b/src/Registries/ScheduledTasksRegistry.php @@ -0,0 +1,71 @@ + */ + private array $tasks = []; + + public function __construct( + private readonly Container $container, + ) {} + + public function add(ScheduledTaskDefinition $task): void + { + $this->tasks[] = $task; + } + + /** + * @return list + */ + public function all(): array + { + return $this->tasks; + } + + /** + * Timers do not fire until the loop runs, which is after the gateway opens, + * so this only has to happen before then. + * + * @return list + */ + public function start(LoopInterface $loop): array + { + if ($this->tasks === []) { + return []; + } + + /* + * Resolved here rather than in the constructor: discovery builds this + * registry while the container is still being assembled, before the + * initializers that provide the logger have themselves been found. + */ + $runner = $this->container->get(TaskRunner::class); + $outcomes = []; + + foreach ($this->tasks as $task) { + $loop->addPeriodicTimer( + $task->everySeconds, + static fn() => $runner->run($task), + ); + + $outcomes[] = Outcome::success( + 'Scheduled ' . $task->task . ' every ' . $task->everySeconds . 's.', + ); + } + + return $outcomes; + } +} diff --git a/src/Runtime/TaskRunner.php b/src/Runtime/TaskRunner.php new file mode 100644 index 0000000..76973c6 --- /dev/null +++ b/src/Runtime/TaskRunner.php @@ -0,0 +1,64 @@ + tasks whose previous turn has not finished */ + private array $running = []; + + public function __construct( + private readonly Container $container, + private readonly Logger $logger, + ) {} + + public function run(ScheduledTaskDefinition $task): void + { + /* + * A task that takes longer than its own interval would otherwise be + * started again alongside itself, and each turn would make the next + * one slower until nothing else got a look in. + */ + if (isset($this->running[$task->task])) { + $this->logger->warning( + 'Scheduled task ' . $task->task . ' is still busy from its last turn; skipping this one.', + ); + + return; + } + + $this->running[$task->task] = true; + + /* + * In a fiber, so a task may await the REST API, and inside a catch, so + * one that throws is logged rather than travelling up into the event + * loop and taking the process with it. + */ + async(function () use ($task): void { + try { + $task->method->invokeArgs($this->container->get($task->task), []); + } catch (Throwable $throwable) { + $this->logger->error( + 'Scheduled task ' . $task->task . ' failed: ' . $throwable->getMessage(), + ['exception' => $throwable], + ); + } finally { + unset($this->running[$task->task]); + } + })(); + } +} diff --git a/src/Tempcord.php b/src/Tempcord.php index bb117db..ad133b6 100644 --- a/src/Tempcord.php +++ b/src/Tempcord.php @@ -10,11 +10,13 @@ use Tempcord\Registries\ComponentsRegistry; use Tempcord\Registries\EventsRegistry; use Tempcord\Registries\PluginsRegistry; +use Tempcord\Registries\ScheduledTasksRegistry; use Tempcord\Runtime\CommandBinder; use Tempcord\Runtime\CommandRegistrar; use Tempcord\Runtime\ComponentBinder; use Tempcord\Runtime\Outcome; use Tempcord\Runtime\PluginBooter; +use React\EventLoop\Loop; /** * The bot itself: the Discord connection, the registries that were filled @@ -30,6 +32,7 @@ public function __construct( private readonly ComponentsRegistry $componentsRegistry, private readonly EventsRegistry $eventsRegistry, private readonly PluginsRegistry $pluginsRegistry, + private readonly ScheduledTasksRegistry $scheduledTasksRegistry, private readonly CommandRegistrar $registrar, private readonly CommandBinder $binder, private readonly ComponentBinder $componentBinder, @@ -56,8 +59,11 @@ public function registerCommands(): array * * The cache subscribes first, so a listener reading it sees the state the * event it is handling has already produced. Plugins boot last, so whatever - * they do runs against a bot whose commands and events are already bound, - * and still before the gateway opens. + * they do runs against a bot whose commands, events and timers are already + * in place, and still before the gateway opens. + * + * Scheduled tasks are only put on the loop here; none of them takes a turn + * until the loop itself runs, which is after the gateway opens. * * @return list */ @@ -68,6 +74,7 @@ public function listen(): array ...$this->binder->bindAll($this->commandsRegistry->all()), ...$this->componentBinder->bindAll($this->componentsRegistry->all()), ...$this->eventsRegistry->listen($this->discord), + ...$this->scheduledTasksRegistry->start(Loop::get()), ...$this->pluginBooter->bootAll($this->pluginsRegistry->all(), $this), ]; } diff --git a/src/TempcordInitializer.php b/src/TempcordInitializer.php index c7a8cd8..3617af6 100644 --- a/src/TempcordInitializer.php +++ b/src/TempcordInitializer.php @@ -8,6 +8,7 @@ use Tempcord\Registries\ComponentsRegistry; use Tempcord\Registries\EventsRegistry; use Tempcord\Registries\PluginsRegistry; +use Tempcord\Registries\ScheduledTasksRegistry; use Tempcord\Runtime\CommandBinder; use Tempcord\Runtime\CommandRegistrar; use Tempcord\Runtime\ComponentBinder; @@ -27,6 +28,7 @@ public function initialize(Container $container): Tempcord componentsRegistry: $container->get(ComponentsRegistry::class), eventsRegistry: $container->get(EventsRegistry::class), pluginsRegistry: $container->get(PluginsRegistry::class), + scheduledTasksRegistry: $container->get(ScheduledTasksRegistry::class), registrar: $container->get(CommandRegistrar::class), binder: $container->get(CommandBinder::class), componentBinder: $container->get(ComponentBinder::class), diff --git a/tests/Fixtures/FailingTask.php b/tests/Fixtures/FailingTask.php new file mode 100644 index 0000000..136b633 --- /dev/null +++ b/tests/Fixtures/FailingTask.php @@ -0,0 +1,15 @@ +promise()); + } +} diff --git a/tests/Fixtures/SweepTask.php b/tests/Fixtures/SweepTask.php new file mode 100644 index 0000000..61e89da --- /dev/null +++ b/tests/Fixtures/SweepTask.php @@ -0,0 +1,19 @@ +logger = new RecordingLogger(); + $this->container = new GenericContainer(); + $this->container->singleton(Logger::class, $this->logger); + } + + private function compile(string $class): ScheduledTaskDefinition + { + $reflector = new ClassReflector($class); + + /** @var Scheduled $attribute */ + $attribute = $reflector->getAttribute(Scheduled::class); + + return new ScheduledTaskCompiler()->compile($reflector, $attribute); + } + + private function registry(string ...$classes): ScheduledTasksRegistry + { + $registry = new ScheduledTasksRegistry($this->container); + + foreach ($classes as $class) { + $registry->add($this->compile($class)); + } + + return $registry; + } + + /** + * Runs the loop for long enough to see a few turns, then stops it whether + * or not anything happened, so a broken timer fails rather than hangs. + */ + private function runFor(LoopInterface $loop, float $seconds): void + { + $loop->addTimer($seconds, static fn() => $loop->stop()); + $loop->run(); + } + + public function test_a_task_is_compiled_with_its_interval(): void + { + $definition = $this->compile(SweepTask::class); + + $this->assertSame(SweepTask::class, $definition->task); + $this->assertSame(0.01, $definition->everySeconds); + } + + public function test_a_task_without_an_invoke_method_is_refused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('should declare an __invoke method'); + + $this->compile(HandlerlessTask::class); + } + + /** + * A zero interval asks the loop to run the task as fast as it can, which + * starves the gateway heartbeat and drops the connection. + */ + public function test_a_task_with_no_interval_is_refused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('greater than zero'); + + $this->compile(UnscheduledTask::class); + } + + public function test_a_scheduled_task_takes_turns_on_the_loop(): void + { + $loop = new StreamSelectLoop(); + $this->registry(SweepTask::class)->start($loop); + + $this->runFor($loop, 0.05); + + $this->assertGreaterThan(1, SweepTask::$turns); + } + + /** + * The first turn comes after the interval: a task is a repeating chore, and + * something that must happen at startup belongs in a plugin's boot. + */ + public function test_a_task_does_not_take_a_turn_before_its_first_interval(): void + { + $loop = new StreamSelectLoop(); + $this->registry(SweepTask::class)->start($loop); + + $this->assertSame(0, SweepTask::$turns); + } + + public function test_starting_reports_what_was_scheduled(): void + { + $outcomes = $this->registry(SweepTask::class)->start(new StreamSelectLoop()); + + $this->assertCount(1, $outcomes); + $this->assertStringContainsString(SweepTask::class, $outcomes[0]->message); + } + + public function test_a_bot_with_nothing_scheduled_reports_nothing(): void + { + $this->assertSame([], $this->registry()->start(new StreamSelectLoop())); + } + + /** + * A timer fires again whether or not the last turn threw. Without + * containment the exception travels into the event loop and takes the + * process with it. + */ + public function test_a_task_that_throws_is_reported_and_keeps_its_place(): void + { + $loop = new StreamSelectLoop(); + $this->registry(FailingTask::class, SweepTask::class)->start($loop); + + $this->runFor($loop, 0.05); + + $this->assertGreaterThan(1, SweepTask::$turns); + $this->assertNotSame([], array_filter( + $this->logger->messages, + static fn(string $message) => str_contains($message, 'the database went away'), + )); + } + + /** + * A task slower than its own interval must not be started alongside itself, + * or each turn makes the next one slower until nothing else gets a look in. + */ + public function test_a_task_still_busy_from_its_last_turn_is_skipped(): void + { + $loop = new StreamSelectLoop(); + $this->registry(SlowTask::class)->start($loop); + + $this->runFor($loop, 0.05); + + $this->assertSame(1, SlowTask::$started); + $this->assertNotSame([], array_filter( + $this->logger->messages, + static fn(string $message) => str_contains($message, 'still busy'), + )); + } + + /** + * Once it finishes, the task goes back to taking its turns. + */ + public function test_a_task_that_catches_up_is_scheduled_again(): void + { + $loop = new StreamSelectLoop(); + $this->registry(SlowTask::class)->start($loop); + + $this->runFor($loop, 0.03); + SlowTask::$holding->resolve(null); + $this->runFor($loop, 0.03); + + $this->assertGreaterThan(1, SlowTask::$started); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 4370c05..a3adb3c 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -13,6 +13,7 @@ use Tempcord\Registries\ComponentsRegistry; use Tempcord\Registries\EventsRegistry; use Tempcord\Registries\PluginsRegistry; +use Tempcord\Registries\ScheduledTasksRegistry; use Tempcord\Runtime\ArgumentResolver; use Tempcord\Runtime\AutocompleteResolver; use Tempcord\Runtime\AutocompleteResponder; @@ -49,6 +50,7 @@ protected function tempcord( ?ComponentsRegistry $components = null, ?EventsRegistry $events = null, ?PluginsRegistry $plugins = null, + ?ScheduledTasksRegistry $scheduledTasks = null, ?RecordingHttp $http = null, ?RecordingLogger $logger = null, ?Cache $cache = null, @@ -67,6 +69,7 @@ protected function tempcord( componentsRegistry: $components, eventsRegistry: $events ?? new EventsRegistry($container), pluginsRegistry: $plugins ?? new PluginsRegistry(), + scheduledTasksRegistry: $scheduledTasks ?? new ScheduledTasksRegistry($container), registrar: new CommandRegistrar( new CommandBuilderFactory(), new TempcordConfig('::token::', new Bitwise()), diff --git a/tools/guides/09-scheduled-tasks.md b/tools/guides/09-scheduled-tasks.md new file mode 100644 index 0000000..ad755cc --- /dev/null +++ b/tools/guides/09-scheduled-tasks.md @@ -0,0 +1,68 @@ +# Scheduled tasks + +Some of what a bot does is not a reply to anything: sweeping rows that have run their +course, lifting blocks that have expired, polling a service with no gateway event of its +own. `#[Scheduled]` declares an invokable class as work the bot does on a timer. + +```php +use Tempcord\Attributes\Scheduled; + +#[Scheduled(everySeconds: 10)] +final readonly class SweepTemporaryMessages +{ + public function __construct(private TempMessages $messages) {} + + public function __invoke(): void + { + $this->messages->sweep(); + } +} +``` + +That is the whole registration. The class is discovered like a command or a listener, is +built by the container so it may take whatever dependencies it needs, and is put on the +event loop before the gateway opens. + +## What the framework guarantees + +A timer is less forgiving than an event listener: it fires again whether or not the last +turn finished or threw, forever. Three things are handled so you do not have to write them +into every task. + +- **A task that throws is logged and keeps its place.** Without that the exception travels + into the event loop, and the usual result is that the timer is cancelled and nothing is + ever swept again — silently, because the bot carries on answering commands. +- **A task is never started alongside itself.** If a turn is still running when the next + one is due, that turn is skipped and the skip is logged. Otherwise a task slower than its + own interval makes every following turn slower until nothing else gets a look in. +- **Each turn runs in a fiber**, so a task may `await` the REST API exactly as a command + handler does. + +## The first turn + +The first turn comes after the interval, not at boot. A scheduled task is a repeating +chore; something that must happen once at startup — a reconciliation against what changed +while the bot was down — belongs in a plugin's `boot()`, where its ordering against +everything else is visible. + +```php +final readonly class VoicePlugin implements Plugin +{ + public function __construct(private VoiceReconciler $reconciler) {} + + public function boot(Tempcord $tempcord): void + { + $this->reconciler->catchUp(); + } +} +``` + +## Choosing an interval + +`everySeconds` is a float, so sub-second intervals are allowed, and zero is refused at +discovery — it asks the loop to run the task as fast as it can, which starves the gateway +heartbeat and drops the connection. + +Prefer one cheap sweep that runs often over a clever one that runs rarely: a query over an +indexed `expires_at` costs almost nothing, and a task that runs every ten seconds needs no +reasoning about when it last ran or what it missed across a restart. diff --git a/tools/src/ApiReflector.php b/tools/src/ApiReflector.php index f8a176a..6e6ca75 100644 --- a/tools/src/ApiReflector.php +++ b/tools/src/ApiReflector.php @@ -34,6 +34,7 @@ \Tempcord\Attributes\Button::class, \Tempcord\Attributes\SelectMenu::class, \Tempcord\Attributes\ModalSubmit::class, + \Tempcord\Attributes\Scheduled::class, ], 'autocomplete' => [ \Tempcord\Interfaces\Autocomplete::class,