Adding a Scheduled Task
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.
| Piece | Where | Rule |
|---|---|---|
| Handler class | coremio/cronjobs, WISECP\CronJobs | Execute has no suffix, discovery ends in Discover |
| Registration | coremio/cronjobs/registry.php | Auto-discovery; a class declaring TYPE is registered |
| Queue rows | cronjob_queue | One row per job: payload, attempts, logs, result |
| Schedule state | cronjob_runtime | Per task: last run, next run, last slot, disable switch |
| Task settings | coremio/configuration/cronjobs.php | cronjobs/tasks/{task}: type minus stage suffix, dots as hyphens |
Walkthrough
Write the Handler
- One file per class in the cron directory, named after the class.
- Declare
TYPE: a dotted string ending indiscoverorexecute. - Add
FREQUENCYonly to the handler the scheduler should trigger. - Implement the one interface method: a boolean, or the rich array.
Register It
- Nothing more for a core task; the registry scans the directory.
- A module task registers from the module's own hook file.
- Include the file yourself: the autoloader maps only the core cron namespace.
- List the registered handlers and confirm your type appears.
Dispatch the Work
- In discovery, query targets and dispatch one execute job each.
- Give every dispatch an idempotency key.
- Pass the parent job id so children link to the scan, and cap the batch.
- Snapshot into the payload anything shown later.
Verify the Run
- Open the automation dashboard and use the manual run control.
- Read the queue row: status, attempts, error log, result payload.
- If nothing appears, check the tick first: a scan with no work leaves no row.
- Add a summary formatter once the result payload has settled.
Reference
The Handler Contract
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;
}
// 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;
What Your Return Means
| Return | Row becomes | Use it when |
|---|---|---|
true | completed, no telemetry | Success with nothing worth showing |
false | Retry with backoff, then failed | Failure that a repeat might fix |
A thrown Throwable | Identical to false, message captured | You do not want to swallow it |
['success' => bool, 'result' => array] | completed or failed, result stored | You want telemetry |
['success' => true, 'status' => 'cancelled', 'result' => array] | cancelled; childless rows deleted | No work this tick |
no-targets, disabled, already-exists.
false the queue reads error, gateway_error or message.
.execute, so a scan is always .discover.
The Queue Helper
// 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;
The Results Tab
Built on demand from a static method on your handler; no HTML is stored.
// $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;
Example
A complete pair: the hourly scan, and the worker that reconciles one service.
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;
}
}
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) . ' → ' . (int) ($r['quota_after'] ?? 0) . '</dd>'
. '</dl>';
}
}
The same pair inside a module.
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.
// 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 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.
A task that removes records or sends data outside defaults to disabled and checks its own switch first.
A childless cancelled row is deleted, so counting queue rows shows gaps. The proof is the schedule state table, not the queue.
The worker arms an alarm and aborts the job when it overruns; prefer splitting long work across dispatches.
Failure retries three times by default, repeating what the handler already did. Make it idempotent or set the budget to one.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.