Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
68 changes: 68 additions & 0 deletions docs/guides/09-scheduled-tasks.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
{
"title": "Cache",
"slug": "guides/08-cache"
},
{
"title": "Scheduled tasks",
"slug": "guides/09-scheduled-tasks"
}
],
"reference": {
Expand Down Expand Up @@ -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": [
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/attributes/scheduled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- Generated from the source by `composer docs`. Do not edit by hand. -->

# 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* | |

1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions src/Attributes/Scheduled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Tempcord\Attributes;

use Attribute;

/**
* Declares an invokable class as work the bot does on a timer.
*
* Sweeping rows that have run their course, expiring caches, polling something
* that has no gateway event — anything that has to happen whether or not
* anyone is interacting with the bot.
*
* The first turn comes after the interval, not at boot: a task is a repeating
* chore, and something that must happen once at startup belongs in a plugin's
* boot method where its ordering against everything else is visible.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Scheduled
{
public function __construct(
public float $everySeconds,
) {}
}
37 changes: 37 additions & 0 deletions src/Compiler/ScheduledTaskCompiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Tempcord\Compiler;

use RuntimeException;
use Tempcord\Attributes\Scheduled;
use Tempcord\Definitions\ScheduledTaskDefinition;
use Tempest\Reflection\ClassReflector;

final readonly class ScheduledTaskCompiler
{
public function compile(ClassReflector $class, Scheduled $scheduled): ScheduledTaskDefinition
{
if (!$class->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'),
);
}
}
17 changes: 17 additions & 0 deletions src/Definitions/ScheduledTaskDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Tempcord\Definitions;

use Tempest\Reflection\MethodReflector;

/**
* A piece of recurring work paired with how often it runs.
*/
final readonly class ScheduledTaskDefinition
{
public function __construct(
public string $task,
public float $everySeconds,
public MethodReflector $method,
) {}
}
35 changes: 35 additions & 0 deletions src/Discoveries/ScheduledTasksDiscovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Tempcord\Discoveries;

use Tempcord\Attributes\Scheduled;
use Tempcord\Compiler\ScheduledTaskCompiler;
use Tempcord\Registries\ScheduledTasksRegistry;
use Tempest\Discovery\Discovery;
use Tempest\Discovery\DiscoveryLocation;
use Tempest\Discovery\IsDiscovery;
use Tempest\Reflection\ClassReflector;

final class ScheduledTasksDiscovery implements Discovery
{
use IsDiscovery;

public function __construct(
private readonly ScheduledTasksRegistry $registry,
private readonly ScheduledTaskCompiler $compiler = new ScheduledTaskCompiler(),
) {}

public function discover(DiscoveryLocation $location, ClassReflector $class): void
{
foreach ($class->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);
}
}
}
71 changes: 71 additions & 0 deletions src/Registries/ScheduledTasksRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace Tempcord\Registries;

use React\EventLoop\LoopInterface;
use Tempcord\Definitions\ScheduledTaskDefinition;
use Tempcord\Runtime\Outcome;
use Tempcord\Runtime\TaskRunner;
use Tempest\Container\Container;
use Tempest\Container\Singleton;

/**
* Holds every discovered scheduled task and puts it on the event loop.
*/
#[Singleton]
final class ScheduledTasksRegistry
{
/** @var list<ScheduledTaskDefinition> */
private array $tasks = [];

public function __construct(
private readonly Container $container,
) {}

public function add(ScheduledTaskDefinition $task): void
{
$this->tasks[] = $task;
}

/**
* @return list<ScheduledTaskDefinition>
*/
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<Outcome>
*/
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;
}
}
Loading
Loading