diff --git a/docs/README.md b/docs/README.md index 0cef75f..7eb526f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,13 +14,12 @@ 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), [Scheduled](reference/attributes/scheduled.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) **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 deleted file mode 100644 index ad755cc..0000000 --- a/docs/guides/09-scheduled-tasks.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 19fe2bc..bb16faf 100644 --- a/docs/index.json +++ b/docs/index.json @@ -31,10 +31,6 @@ { "title": "Cache", "slug": "guides/08-cache" - }, - { - "title": "Scheduled tasks", - "slug": "guides/09-scheduled-tasks" } ], "reference": { @@ -328,25 +324,6 @@ ], "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 deleted file mode 100644 index 33c48a3..0000000 --- a/docs/reference/attributes/scheduled.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# 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 d3fca3a..69fd048 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -13,7 +13,6 @@ - [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 deleted file mode 100644 index 7980a1f..0000000 --- a/src/Attributes/Scheduled.php +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 2d6ead9..0000000 --- a/src/Definitions/ScheduledTaskDefinition.php +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index a7e4143..0000000 --- a/src/Registries/ScheduledTasksRegistry.php +++ /dev/null @@ -1,71 +0,0 @@ - */ - 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 deleted file mode 100644 index 76973c6..0000000 --- a/src/Runtime/TaskRunner.php +++ /dev/null @@ -1,64 +0,0 @@ - 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 ad133b6..bb117db 100644 --- a/src/Tempcord.php +++ b/src/Tempcord.php @@ -10,13 +10,11 @@ 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 @@ -32,7 +30,6 @@ 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, @@ -59,11 +56,8 @@ 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, 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. + * they do runs against a bot whose commands and events are already bound, + * and still before the gateway opens. * * @return list */ @@ -74,7 +68,6 @@ 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 3617af6..c7a8cd8 100644 --- a/src/TempcordInitializer.php +++ b/src/TempcordInitializer.php @@ -8,7 +8,6 @@ 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; @@ -28,7 +27,6 @@ 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 deleted file mode 100644 index 136b633..0000000 --- a/tests/Fixtures/FailingTask.php +++ /dev/null @@ -1,15 +0,0 @@ -promise()); - } -} diff --git a/tests/Fixtures/SweepTask.php b/tests/Fixtures/SweepTask.php deleted file mode 100644 index 61e89da..0000000 --- a/tests/Fixtures/SweepTask.php +++ /dev/null @@ -1,19 +0,0 @@ -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 a3adb3c..4370c05 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -13,7 +13,6 @@ 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; @@ -50,7 +49,6 @@ protected function tempcord( ?ComponentsRegistry $components = null, ?EventsRegistry $events = null, ?PluginsRegistry $plugins = null, - ?ScheduledTasksRegistry $scheduledTasks = null, ?RecordingHttp $http = null, ?RecordingLogger $logger = null, ?Cache $cache = null, @@ -69,7 +67,6 @@ 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 deleted file mode 100644 index ad755cc..0000000 --- a/tools/guides/09-scheduled-tasks.md +++ /dev/null @@ -1,68 +0,0 @@ -# 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 6e6ca75..f8a176a 100644 --- a/tools/src/ApiReflector.php +++ b/tools/src/ApiReflector.php @@ -34,7 +34,6 @@ \Tempcord\Attributes\Button::class, \Tempcord\Attributes\SelectMenu::class, \Tempcord\Attributes\ModalSubmit::class, - \Tempcord\Attributes\Scheduled::class, ], 'autocomplete' => [ \Tempcord\Interfaces\Autocomplete::class,