Adding a Scheduled Task

5 views Markdown

Run your own code on a schedule: a handler class in the queue, with retries and telemetry.

Overview

Scheduled work is a queue, not a crontab. A scheduler picks due tasks, each becomes a row in cronjob_queue, and a handler turns that row into work.

A task splits in two: discovery dispatches one job per target, execute does the work for one. Retries are per target.

Prerequisites

  • A running worker; nothing runs until the tick reaches this system.
  • Where the handler lives: the core cron directory, or the module that owns it.
  • A stable target identifier; it becomes the idempotency key against duplicates.

Structure

One class per file, named after the class.

PieceWhereRule
Handler classcoremio/cronjobs, WISECP\CronJobsExecute has no suffix, discovery ends in Discover
Registrationcoremio/cronjobs/registry.phpAuto-discovery; a class declaring TYPE is registered
Queue rowscronjob_queueOne row per job: payload, attempts, logs, result
Schedule statecronjob_runtimePer task: last run, next run, last slot, disable switch
Task settingscoremio/configuration/cronjobs.phpcronjobs/tasks/{task}: type minus stage suffix, dots as hyphens

Walkthrough

Write the Handler

  1. One file per class in the cron directory, named after the class.
  2. Declare TYPE: a dotted string ending in discover or execute.
  3. Add FREQUENCY only to the handler the scheduler should trigger.
  4. Implement the one interface method: a boolean, or the rich array.

Register It

  1. Nothing more for a core task; the registry scans the directory.
  2. A module task registers from the module's own hook file.
  3. Include the file yourself: the autoloader maps only the core cron namespace.
  4. List the registered handlers and confirm your type appears.

Dispatch the Work

  1. In discovery, query targets and dispatch one execute job each.
  2. Give every dispatch an idempotency key.
  3. Pass the parent job id so children link to the scan, and cap the batch.
  4. Snapshot into the payload anything shown later.

Verify the Run

  1. Open the automation dashboard and use the manual run control.
  2. Read the queue row: status, attempts, error log, result payload.
  3. If nothing appears, check the tick first: a scan with no work leaves no row.
  4. Add a summary formatter once the result payload has settled.

Reference

The Handler Contract

coremio/cronjobs/CronJobHandler.php
namespace WISECP\CronJobs;

interface CronJobHandler
{
    // $payload  the decoded array given to dispatch() for THIS job
    // $job      the whole queue row: id, type, attempts, max_attempts, parent_id,
    //           idempotency_key, started_at, process_logs and the rest
    public function handle(array $payload, array $job): bool|array;
}
the constants a handler may declare
// MANDATORY. Without it the class is not a task and the registry skips it.
// Last segment is the stage: '.discover' for a scan, '.execute' for atomic work.
public const TYPE = 'acme.sync.discover';

// OPTIONAL, and it means "the scheduler triggers this one directly".
// One of: 'minute' | 'hour' | 'day' | 'month'. An operator can override it per task.
public const FREQUENCY = 'hour';

// OPTIONAL, default 3. Retry budget; the delay is 2 to the power of the attempt,
// in minutes, so 2, 4, 8. Set 1 when a retry would repeat an external side effect.
public const MAX_ATTEMPTS = 1;

// OPTIONAL, default 120. Seconds before the worker aborts this job with an alarm.
// Raise it for long jobs such as a backup; the worker kills the job, not the tick.
public const MAX_RUNTIME = 300;
WISECP\CronJobs\CronJobHandler One method. Without it the registry skips the class.
CronScheduler::tick() Dispatches handlers whose slot has arrived; safe from several workers.
register:cronjobs Where a module registers its own types; the return is ignored.

What Your Return Means

ReturnRow becomesUse it when
truecompleted, no telemetrySuccess with nothing worth showing
falseRetry with backoff, then failedFailure that a repeat might fix
A thrown ThrowableIdentical to false, message capturedYou do not want to swallow it
['success' => bool, 'result' => array]completed or failed, result storedYou want telemetry
['success' => true, 'status' => 'cancelled', 'result' => array]cancelled; childless rows deletedNo work this tick
Cancelled, not a log line A no-work tick returns the cancelled signal instead of a log entry.
result.reason A short machine-readable string: no-targets, disabled, already-exists.
Failure message A throw stores its message in the process log. On bare false the queue reads error, gateway_error or message.
Stage and counters Throughput counts only .execute, so a scan is always .discover.

The Queue Helper

coremio/helpers/CronJobQueue.php
// Enqueue. Returns the new row id, or the EXISTING row id when the idempotency
// key is already taken. A return value greater than zero is not proof of an insert.
public static function dispatch(string $type, array $payload = [], array $opts = []): int;

// Registration. Called for you by the registry for core handlers; a module calls it itself.
public static function register(string $type, string $handlerClass): void;

// Run one claimed row. $job is the full queue row, not an id.
public static function process(array $job): bool;

// Introspection.
public static function resolve_handler(string $type): ?string;
public static function registered_handlers(): array;
public static function resolve_task_type(string $task): string;

// Housekeeping, both already wired into the platform's own tasks.
public static function recover_stale(int $minutes = 5): void;
public static function cleanup(array $retention = []): array;
CronJobQueue::dispatch() Third argument is the options below; the payload is JSON encoded, no objects.
opts.idempotency_key Unique across the table; present already means no insert, old id returned.
opts.parent_id Links an execute job to its discovery job; deleting the parent cascades.
opts.priority Integer, default 5, lower first; equal priorities run oldest first.
opts.scheduled_at Holds the job until a future moment; null means the next tick.
opts.title The queue-list label, 255 characters, stored rather than resolved later.
opts.max_attempts Overrides the handler constant for this dispatch; without either, 3.
CronJobQueue::resolve_task_type() Turns a task name into a handler type; never swap hyphens for dots.

The Results Tab

Built on demand from a static method on your handler; no HTML is stored.

optional, on the handler class
// $resultPayload is exactly the 'result' array your handle() returned.
// Called only when the admin opens the tab, resolved through the registry.
public static function renderSummary(array $resultPayload): string;
Read the payload, never the database The row is historical; current state shows today under yesterday's date.
Labels come from the language files Write both language files; a missing key should show as the key.
Escape everything you print The payload holds user and remote text; the formatter returns raw markup.

Example

A complete pair: the hourly scan, and the worker that reconciles one service.

coremio/cronjobs/AcmeQuotaSyncDiscover.php
namespace WISECP\CronJobs;

class AcmeQuotaSyncDiscover implements CronJobHandler
{
    public const TYPE      = 'acme.quotasync.discover';
    public const FREQUENCY = 'hour';

    private const BATCH = 200;

    public function handle(array $payload, array $job): bool|array
    {
        // The operator switch. Reading it on the first line keeps a disabled task
        // from doing anything at all, which is required for destructive tasks.
        if ((int) (\Config::get('cronjobs/tasks/acme-quotasync/enabled') ?? 0) !== 1)
            return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'disabled']];

        $stmt = \WDB::select('id,name,owner_id')->from('users_products');
        $stmt->where('status', '=', 'active', '&&');
        $stmt->where('module', '=', 'AcmeCloud');
        $stmt->limit(self::BATCH);
        $rows = $stmt->build() ? $stmt->fetch_assoc() : [];

        if (!$rows)
            return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'no-targets']];

        $parentId   = (int) ($job['id'] ?? 0);
        $dispatched = [];

        foreach ($rows as $row) {
            $sid = (int) ($row['id'] ?? 0);
            if ($sid <= 0) continue;

            // The key carries the hour slot as well as the service: a fixed key would
            // still exist next hour (completed rows keep theirs until retention runs)
            // and every later dispatch would silently return the old row instead.
            $key = 'acmeq_' . $sid . '_' . date('YmdH');

            $childId = \CronJobQueue::dispatch(self::stage_execute(), [
                'service_id'    => $sid,
                'service_label' => trim((string) ($row['name'] ?? '')),
                'owner_id'      => (int) ($row['owner_id'] ?? 0),
            ], ['idempotency_key' => $key, 'parent_id' => $parentId]);

            $dispatched[] = ['service_id' => $sid, 'child_job_id' => $childId ?: null];
        }

        return ['success' => true, 'result' => ['items' => $dispatched, 'count' => count($dispatched)]];
    }

    private static function stage_execute(): string
    {
        return AcmeQuotaSync::TYPE;
    }
}
coremio/cronjobs/AcmeQuotaSync.php
namespace WISECP\CronJobs;

class AcmeQuotaSync implements CronJobHandler
{
    // No FREQUENCY: this one is never scheduled, only dispatched by the scan above.
    public const TYPE         = 'acme.quotasync.execute';
    public const MAX_ATTEMPTS = 2;

    public function handle(array $payload, array $job): bool|array
    {
        $sid = (int) ($payload['service_id'] ?? 0);
        if ($sid <= 0) return false;              // a payload this broken will not fix itself

        $service = \Services::get($sid);
        if (!$service)
            return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'service-gone', 'service_id' => $sid]];

        $remote = \Modules::getInstance('Servers', 'AcmeCloud')->quota_of($sid);
        $before = (int) ($service['options']['quota'] ?? 0);
        $after  = (int) ($remote['quota'] ?? 0);

        if ($before === $after)
            return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'in-sync', 'service_id' => $sid]];

        // Options round-trip through Services::set(), which JSON encodes the array
        // for you. There is no set_options() helper: read, merge, write the whole map.
        $options = $service['options'] ?? [];
        $options['quota'] = $after;
        \Services::set($sid, ['options' => $options]);

        // Everything renderSummary() will need is snapshotted here, while it is true.
        return ['success' => true, 'result' => [
            'service_id'    => $sid,
            'service_label' => (string) ($payload['service_label'] ?? ''),
            'quota_before'  => $before,
            'quota_after'   => $after,
        ]];
    }

    public static function renderSummary(array $r): string
    {
        $esc = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
        $L   = static fn (string $k): string => (string) \Language::gc('admin/automation/' . $k);

        $sid  = (int) ($r['service_id'] ?? 0);
        $href = (string) \LinkGenerator::admin('services-1', ['detail'], '', ['id' => $sid]);
        $name = trim((string) ($r['service_label'] ?? ''));

        return '<dl class="row small mb-0">'
            . '<dt class="col-sm-4 text-muted fw-normal">' . $esc($L('telemetry-acme-service')) . '</dt>'
            . '<dd class="col-sm-8 mb-1"><a href="' . $esc($href) . '" target="_blank" rel="noopener">'
            . $esc($name !== '' ? $name : (string) $sid) . '</a></dd>'
            . '<dt class="col-sm-4 text-muted fw-normal">' . $esc($L('telemetry-acme-quota')) . '</dt>'
            . '<dd class="col-sm-8 mb-1">' . (int) ($r['quota_before'] ?? 0) . ' &rarr; ' . (int) ($r['quota_after'] ?? 0) . '</dd>'
            . '</dl>';
    }
}

The same pair inside a module.

coremio/modules/Addons/Acme/hooks.php
use WISECP\Modules\Addons\Acme\CronJobs\QuotaSyncDiscover;
use WISECP\Modules\Addons\Acme\CronJobs\QuotaSyncExecute;

// Fired while the registry loads, after the core handlers are in place.
Hook::add('register:cronjobs', 1, function () {
    // Required: the autoloader resolves WISECP\CronJobs, but not a nested
    // namespace inside a module directory, so the file is included by hand.
    require_once __DIR__ . DS . 'cronjobs' . DS . 'QuotaSyncDiscover.php';
    require_once __DIR__ . DS . 'cronjobs' . DS . 'QuotaSyncExecute.php';

    CronJobQueue::register(QuotaSyncDiscover::TYPE, QuotaSyncDiscover::class);
    CronJobQueue::register(QuotaSyncExecute::TYPE, QuotaSyncExecute::class);
});

Driving the queue by hand.

driving the queue yourself
// Is the type registered at all? An unregistered type fails the job, not the boot.
$handler = CronJobQueue::resolve_handler('acme.quotasync.execute');

// Enqueue one job. Mind the return: an existing key gives you the OLD row's id.
$id = CronJobQueue::dispatch('acme.quotasync.execute',
    ['service_id' => 5001],
    ['idempotency_key' => 'acmeq_manual_5001', 'priority' => 1]);

// Run it here and now, bypassing the worker. process() wants the ROW, not the id.
$job = CronJobQueue::get($id);
$ok  = $job ? CronJobQueue::process($job) : false;

// Read back what the handler reported; this is the Results tab's own source.
$row    = CronJobQueue::get($id, 'status,attempts,result_payload');
$result = Utility::jdecode((string) ($row['result_payload'] ?? ''), true) ?: [];

Pitfalls

A fixed key stops the second run

A completed row keeps its key for seven days and returns the old row's id. Put the time slot into the key when a target can legitimately run twice.

Destructive tasks ship switched off

A task that removes records or sends data outside defaults to disabled and checks its own switch first.

Empty minutes are not a stopped scheduler

A childless cancelled row is deleted, so counting queue rows shows gaps. The proof is the schedule state table, not the queue.

Long jobs are killed after two minutes

The worker arms an alarm and aborts the job when it overruns; prefer splitting long work across dispatches.

Retry repeats the side effect

Failure retries three times by default, repeating what the handler already did. Make it idempotent or set the budget to one.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.