Skip to content

feat(scheduling): declare recurring work with #[Scheduled] - #16

Merged
mikield merged 1 commit into
masterfrom
feat/scheduled-tasks
Sep 2, 2026
Merged

mikield merged 1 commit into
masterfrom
feat/scheduled-tasks

Conversation

@mikield

@mikield mikield commented Sep 2, 2026

Copy link
Copy Markdown
Member

Work that is not a reply to anything — sweeping rows that have run their course, expiring caches, polling a service with no gateway event of its own — had no home in the framework. Every bot reaches for Loop::get()->addPeriodicTimer() inside a plugin's boot() and then writes the same three safeguards, or more often does not write them.

#[Scheduled(everySeconds: 10)]
final readonly class SweepTemporaryMessages
{
    public function __construct(private TempMessages $messages) {}

    public function __invoke(): void
    {
        $this->messages->sweep();
    }
}

Discovered like a command or a listener, built by the container, put on the loop before the gateway opens.

Why this is not just sugar over addPeriodicTimer

A timer is less forgiving than an event listener — it fires again whether or not the last turn finished or threw, forever:

  • A task that throws is logged and keeps its place. Otherwise the exception travels into the event loop; the usual result is a cancelled timer and nothing ever swept again — silently, because the bot carries on answering commands and looks healthy.
  • A task is never started alongside itself. A turn still running when the next is due is skipped, and the skip logged. Without that, 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. This mirrors EventDispatcher.

Deliberate choices worth arguing with

  • The first turn comes after the interval, not at boot. A scheduled task is a repeating chore; one-off startup work (reconciling against what changed while the bot was down) belongs in a plugin's boot() where its ordering against everything else is visible. I left out an immediately: flag rather than guess.
  • A zero interval is refused at discovery, since it asks the loop to run the task as fast as it can and starves the gateway heartbeat.
  • start() takes the loop as an argument rather than reaching for Loop::get() itself, so the tests drive a StreamSelectLoop they own and the overlap and containment behaviour is exercised for real rather than mocked.

Adds a reference page and a guide; composer docs output is committed.

🤖 Generated with Claude Code

Work that is not a reply to anything — sweeping rows that have run their
course, expiring caches, polling a service with no gateway event — had no
home in the framework, so every bot reached for addPeriodicTimer inside a
plugin's boot and wrote the same three safeguards, or more often did not.

A timer is less forgiving than an event listener: it fires again whether or
not the last turn finished or threw, forever. So a task that throws is
logged and keeps its place rather than cancelling its own timer, a task
still busy from its last turn is skipped rather than started alongside
itself, and each turn runs in a fiber so it may await the REST API.

The first turn comes after the interval rather than at boot: a task is a
repeating chore, and one-off startup work belongs in a plugin where its
ordering against everything else is visible.
@mikield
mikield merged commit 7e5e5c4 into master Sep 2, 2026
3 of 4 checks passed
@mikield
mikield deleted the feat/scheduled-tasks branch September 2, 2026 01:24
github-actions Bot pushed a commit that referenced this pull request Sep 2, 2026
## [0.10.0](v0.9.0...v0.10.0) (2026-09-02)

### Features

* **scheduling:** declare recurring work with #[Scheduled] ([#16](#16)) ([7e5e5c4](7e5e5c4))
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 0.10.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

mikield added a commit that referenced this pull request Sep 2, 2026
Reverts #16, released as `v0.10.0`.

I added `#[Scheduled]` to the core without checking what was already
planned. [Tempcord/tasks](https://github.com/Tempcord/tasks) already
declares `#[Task]` with interval **and** cron scheduling, a console
command and per-task statistics. A second attribute in the core is a
competing way to say the same thing, and would leave the plugin unable
to be installed alongside the framework it extends.

Nothing depends on the attribute one release later, so this is the
cheapest moment to take it back out.

What was worth having moves into the plugin along with its tests: each
turn in a fiber so a task may `await` the REST API, a skip when the
previous turn has not finished, and containment that logs a throwing
task and leaves it in the schedule.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
github-actions Bot pushed a commit that referenced this pull request Sep 2, 2026
## [0.11.0](v0.10.0...v0.11.0) (2026-09-02)

### Features

* **messaging:** write to a member without risking the caller ([#17](#17)) ([ccfa40f](ccfa40f))

### Reverts

* take scheduling back out of the core ([#18](#18)) ([63bd82e](63bd82e)), closes [#16](#16)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant