Background components

Scheduler

The scheduler runs your methods on a schedule: every few seconds, every night at three, on a cron expression. Marking the method with an attribute is enough — there is no wrapper script to write and no line to add to the system crontab.

Attribute #[Scheduled]Enabled by #[EnableScheduler]Command call schedule

What the scheduler is and why

The scheduler is an application component that calls annotated methods itself at the right moments.

The problem. Routine work exists in almost every service: purge expired records, send reminders, roll up a daily report, poll an external source. The classic answer is system cron plus a wrapper script per task. The price:

  • the schedule lives outside the project, in server configuration, and never reaches code review;
  • every script boots PHP and the application afresh — container, configuration, connections;
  • nobody watches for overlap: if the previous run is still going, cron happily starts a second;
  • the finest granularity is a minute, and no finer.

The solution. Winter keeps the schedule next to the code. A method is marked with an attribute, the scheduler starts once, and it calls tasks inside an already loaded application — with a ready container and open connections. Overlap is impossible, and the interval can be under a second.

The mechanism mirrors Spring’s @Scheduled: the same three ways to set the cadence — fixedRate, fixedDelay and cron — with the same semantics. If you have used it, what follows is mostly the details of cron expressions and behaviour during long runs.

Quick start

Three steps: mark the method, enable the component, run it.

main/Task/Cleanup.php
<?php

namespace Main\Task;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
use Psr\Log\LoggerInterface;

class Cleanup
{
  #[Autowired] private SessionRepository $sessions;
  #[Autowired] private LoggerInterface $logger;

  #[Scheduled(fixedRate: 300)]          // every 5 minutes
  public function purgeExpiredSessions(): void
  {
      $removed = $this->sessions->deleteExpired();
      $this->logger->info('sessions purged', ['count' => $removed]);
  }
}

Enable the component in the application manifest:

bootstrap.php
#[EnableWeb]
#[EnableScheduler]
final class Application extends WinterApplication { /* ... */ }

Check the task was found, then run:

bash
php call schedule list

|   TASK                              TRIGGER
|   Main\Task\Cleanup::purgeExpiredSessions   fixedRate 300s
|   1 task(s) defined.

php call run        # the scheduler comes up with the application

The class needs no registration — the same scan that finds controllers finds it.

Requirements for the method

The scheduler builds the object through the container and calls the method with no arguments. Four conditions follow:

Requirement Why
The method is public Otherwise it is invisible to the walk
The method is not static The object is built by the container; the call is on an instance
No required parameters There is nothing to pass in
The class is instantiable Not abstract, not an interface

Dependencies are injected as usual — through the constructor or #[Autowired]; everything on the Dependency injection page applies.

Breaking any condition is an error at startup, not a silent skip:

text
ScheduleConfigException:
#[Scheduled] MainTaskCleanup::purge() must be a non-static method.

Configuration errors fail the whole scan, so you see them immediately — including on php call schedule list.

Triggers

The cadence is set by exactly one of three parameters. Two or more is an error; none is too.

Parameter What it means
fixedRate Run every N seconds, counting from the start of the previous run
fixedDelay Wait N seconds after the end of the previous run
cron Run on a calendar schedule

fixedRate — once every N seconds

Sets a frequency. A task starting at 12:00:00 with fixedRate: 60 goes again at 12:01:00 — whether it took a second or twenty.

php
#[Scheduled(fixedRate: 60)]
public function pollExchangeRates(): void { /* ... */ }

Suits polling at a steady rate: metrics, exchange rates, the state of an external service.

fixedDelay — a pause between runs

Sets a rest interval. The pause is counted from the end of the previous run, so the actual period is “work + pause”.

php
#[Scheduled(fixedDelay: 10)]
public function drainQueue(): void { /* ... */ }

Suits work where the system needs breathing room between heavy passes — draining a queue, processing a batch of files.

The difference is clearest in numbers. A run takes 3 seconds; the interval is 5 in both cases:

text
fixedRate  5:  start ─3s─ end ──2s── start      period 5 s
fixedDelay 5:  start ─3s─ end ────5s──── start  period 8 s

cron — by the calendar

For when the schedule is expressed not as a frequency but as a time of day or a day of the week.

php
#[Scheduled(cron: '0 3 * * *')]        // every day at 03:00
public function nightlyReport(): void { /* ... */ }

#[Scheduled(cron: '30 6,18 * * 1-5')] // at 06:30 and 18:30 on weekdays
public function shiftSummary(): void { /* ... */ }

An expression has exactly five fields:

text
minute   hour   day of month   month   day of week
0-59     0-23   1-31           1-12    0-7  (0 and 7 are Sunday)
Form Example Meaning
Any value * Every minute / hour / day
An exact value 30 At exactly 30
A list 6,18 At 6 and at 18
A range 1-5 Monday to Friday
A step */5 Every five

The macros @yearly, @monthly, @weekly, @daily (also @midnight) and @hourly are supported.

What the expression does not have

This is not the full syntax found in other systems. Verified:

  • No seconds — there are exactly five fields. * * * * * * is a parse error. For intervals under a minute use fixedRate.
  • Names do not workMON, JAN are rejected; fields are numeric only.
  • No @reboot.
  • Precision is one minute, and the time is the server’s timezone.

Delaying the first run

initialDelay postpones the first run after the application starts. Useful so that a heavy task does not begin while the service is still warming up.

php
#[Scheduled(fixedRate: 300, initialDelay: 60)]
public function warmCache(): void { /* ... */ }

It does not combine with cron — that is a configuration error, not something ignored.

Several schedules on one method

The attribute is repeatable: when one job has to happen for different reasons, attach several.

php
#[Scheduled(cron: '0 9 * * 1-5')]   // weekday mornings
#[Scheduled(cron: '0 12 * * 6,0')]  // weekend noon
public function sendDigest(): void { /* ... */ }

Each attribute becomes an independent entry in the schedule.

How tasks execute

A task never overlaps itself. While a run is in progress the next one does not start, whatever the schedule says. You do not write locks for it.

Missed firings do not accumulate. If a run overran and swallowed several ticks, the task starts once when it is free, not once per missed tick. The scheduler catches up with the schedule rather than paying off a debt.

Tasks do not hold each other up. Every run goes in its own coroutine, so a slow task does not shift the timing of the others.

An exception inside a task does not kill the scheduler. It goes to the log at error level and the schedule keeps running:

text
Scheduled MainTaskCleanup::purgeExpiredSessions threw: SQLSTATE[08006] ...

A construction failure does not stop the schedule either

If the container could not build the task class, a message goes to the log suggesting you check the constructor and #[Autowired], and the task keeps ticking idly — the method simply is not called. That is visible only in the log, so after adding a task it is worth confirming it ran at least once.

Control from the console

bash
php call schedule list        # which tasks were found and at what cadence
php call schedule start       # run in the current terminal
php call schedule start -d    # run in the background
php call schedule stop        # graceful stop (SIGTERM)
php call schedule status      # state and task count
php call schedule status -v   # the same plus CPU and memory usage

list works with no running application — it is a static analysis of the project, handy for checking after a schedule edit.

Starting it separately with call schedule start is for when the scheduler is deployed on its own, without the web tier. If it is declared in the manifest, php call run brings it up with the rest of the application and no separate command is needed.

From code

The scheduler is a process, so the same control surface is available from the application:

php
use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler;

Scheduler::dispatch();                 // start in the background, returns the PID
Scheduler::status()?->activity;        // is it working right now
Scheduler::stop();                     // graceful stop

If you declared your own scheduler through #[EnableScheduler(MyScheduler::class)], call these on your class. What status() returns and which other operations exist is in Control from code on the processes page.

One scheduler per host — and none per cluster

A second start on the same machine is blocked: the attempt receives ProcessAlreadyRunningException. But there is no protection between machines. If the application is rolled out to three pods, a scheduler comes up in each, and the nightly report goes out three times.

The framework provides no cross-host coordination. Deploy the scheduler as a single instance — a separate headless deployment — or take a lock yourself, through a shared Redis for instance.

Examples

Draining a queue with breathing room

main/Task/OutboxSender.php
<?php

namespace Main\Task;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;

class OutboxSender
{
  #[Autowired] private OutboxRepository $outbox;
  #[Autowired] private Mailer $mailer;

  #[Scheduled(fixedDelay: 5, initialDelay: 10)]
  public function send(): void
  {
      foreach ($this->outbox->takePending(limit: 50) as $letter) {
          $this->mailer->send($letter);
          $this->outbox->markSent($letter->id);
      }
  }
}

fixedDelay fits better than a rate here: while there are many letters the passes follow one another with a five-second pause; when the queue is empty there is no load.

A nightly routine in several steps

main/Task/NightlyMaintenance.php
<?php

namespace Main\Task;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Schedule\Scheduled;
use Psr\Log\LoggerInterface;

class NightlyMaintenance
{
  #[Autowired] private ReportService $reports;
  #[Autowired] private ArchiveService $archive;
  #[Autowired] private LoggerInterface $logger;

  #[Scheduled(cron: '0 2 * * *')]
  public function archiveOldOrders(): void
  {
      $moved = $this->archive->moveOlderThan('-1 year');
      $this->logger->info('orders archived', ['count' => $moved]);
  }

  #[Scheduled(cron: '30 2 * * *')]
  public function buildDailyReport(): void
  {
      $this->reports->buildFor(new \DateTimeImmutable('yesterday'));
  }

  #[Scheduled(cron: '0 3 * * 1')]     // on Mondays
  public function buildWeeklyReport(): void
  {
      $this->reports->buildWeekly();
  }
}

The steps are spread out in time deliberately: the scheduler guarantees no ordering between different tasks, so the “archive before report” dependency is expressed as an interval rather than as adjacency in the code. If the order is mandatory, merge the steps into one method.

A schedule that does not come from attributes

When the schedule has to live in a database and change without a deployment, override task discovery in your own scheduler:

main/DbScheduler.php
<?php

namespace Main;

use Flytachi\Winter\Kernel\Schedule\ScheduledTask;
use Flytachi\Winter\Kernel\Schedule\Stereotype\Scheduler;
use Flytachi\Winter\Kernel\Schedule\Trigger\CronTrigger;

class DbScheduler extends Scheduler
{
  /** @return ScheduledTask[] */
  protected function discover(): array
  {
      $tasks = [];
      foreach ($this->rules->all() as $rule) {
          $tasks[] = new ScheduledTask(
              className:  $rule->class,
              methodName: $rule->method,
              trigger:    new CronTrigger($rule->expression),
          );
      }

      return $tasks;
  }
}
php
#[EnableScheduler(\Main\DbScheduler::class)]

SIGHUP makes the scheduler re-read the schedule without stopping — for this scenario that is the main way to apply changes.

Together with #[Async]

Both attributes can sit on one method, and the combination works. But it changes the scheduler’s behaviour more than it looks, so it deserves its own section.

php
#[Scheduled(fixedRate: 60)]
#[Async]
public function syncCatalog(): void { /* ... */ }

What happens

The scheduler takes the task object from the container — and receives the substituted class, the one #[Async] creates. So the method call goes into an executor and returns immediately, before the work has even begun.

To the scheduler that looks like a task that finished instantly.

text
without #[Async]:  start ──────── work ──────── end   → next tick
with    #[Async]:  start ─ dispatch ─ end             → next tick
                            └── the work proceeds on its own ────►

What you gain

  • A scheduler tick no longer waits for the task. A long run can no longer delay its neighbours — though the scheduler starts them independently anyway.
  • A dedicated pool per task. Naming an executor limits this particular work without touching the rest: #[Async('pool.sync')].

What you lose

Overlap protection. This is the big one. A plain #[Scheduled] guarantees the task will not start again until the previous run finished. With #[Async] the scheduler considers the run over the moment it was dispatched — and on the next tick happily starts it again while the previous one is still working.

A task with fixedRate: 60 that takes three minutes accumulates three concurrent runs. After that, as many as the executor can bear.

The meaning of fixedDelay. The pause counts from “the end”, and the end is now the dispatch. So fixedDelay stops differing from fixedRate: the promise to “wait N seconds after the work finishes” no longer holds.

Rules for combining them

Only with a bounded pool

On the shared executor the number of concurrent runs is limited by nothing: the scheduler will keep dispatching until the worker runs out of memory. When combining #[Scheduled] with #[Async], always name your own pool with a limit.

php
#[Bean(name: 'pool.sync')]
public function syncPool(): ExecutorService
{
  return Executors::newFixedExecutor(
      concurrency: 2,
      queue:       0,                      // no queue — reject the excess
      onReject:    RejectPolicy::DISCARD,  // a skip beats a growing backlog
  );
}
php
#[Scheduled(fixedRate: 60)]
#[Async('pool.sync')]
public function syncCatalog(): void { /* ... */ }

Choosing the reject policy here is choosing the behaviour under overload:

Policy What happens when a run cannot keep up
DISCARD The extra start is skipped — closest to plain #[Scheduled]
CALLER_RUNS The scheduler runs the task itself and waits — overlap is again impossible
ABORT A RejectedExecutionException goes to the log — visible proof the schedule is falling behind

When it is appropriate

Worth combining when the task is idempotent and overlap does no harm, while keeping up with the schedule matters: mailing independent recipients, refreshing a cache, polling several sources.

Not worth it when overlap is unacceptable — rolling up a report, a migration, any work over shared state. Plain #[Scheduled] already gives you exactly the guarantee you would otherwise have to build a lock for.

Check that `#[EnableAsync]` is on

Without it #[Async] has no effect, and the combination silently becomes an ordinary scheduled task — with all of its guarantees. That is safe behaviour, but it differs from what you intended and shows no sign of itself.

Next

  • Components — where #[EnableScheduler] is declared
  • Processes — the scheduler is built as a process and inherits its capabilities
  • Daemons — when there is more work than one runner can manage
  • Dependency injection — how a task receives services
  • Async calls — executor pools and reject policies
  • Logging — where task errors go