Search by

therealworld / scheduler-module

therealworld

The task scheduler can handle recurring tasks regularly in OXID7.

Package info

bitbucket.org/therealworld/scheduler-module

Homepage

Issues

Type:oxideshop-module

pkg:composer/therealworld/scheduler-module

Statistics

Installs: 1 177

Dependents: 2

Suggesters: 0


README

Deutsche Fassung: README.de.md

vendor-logo the-real-world.de

description

A task scheduler for the shop. Other modules ship tasks (exports, imports, clean-ups, ...); the scheduler finds them, keeps them in a list in the backend and runs them either on a crontab schedule — triggered by a server cron job calling OXID's bin/cron.php — or manually from the backend. Long tasks run in steps, so they fit into the PHP runtime limit and carry on where they stopped.

The module also takes over OXID's "upcoming prices" job (switching oxupdateprice to oxprice), which the core otherwise runs on every frontend request — see Changes to the shop core.

  • Composer package: therealworld/scheduler-module
  • OXID module id: trwscheduler

Requirements and installation

  • OXID eShop CE >=v7.4, PHP ^8.3
  • therealworld/tools-plugin >=v3.9.0
  • therealworld/tools-module >=v3.4 (module trwtools must be active, see dependencies.yaml)
  • dragonmantank/cron-expression ^3.3

The task table and the log table are created by migrations, so the module needs install, migrate and activate. With the clirun-plugin this is one command:

composer require therealworld/scheduler-module
vendor/bin/oe-console trw:module:setup trwscheduler

Without the clirun-plugin:

vendor/bin/oe-eshop-db_migrate migrations:migrate trwscheduler
vendor/bin/oe-console oe:module:activate trwscheduler

Upgrading to v2.4

v2.4 stores tasks under their fully qualified class name (see Task identity) and brings two new settings. After the update, refresh the module and convert the existing tasks once with this SQL statement (no migration; running it twice changes nothing):

vendor/bin/oe-console trw:module:refresh trwscheduler
UPDATE `trwschedulertasks`
   SET `OXTASK` = `OXNAMESPACECLASSNAME`
 WHERE `OXTASK` <> ''
   AND INSTR(`OXTASK`, CHAR(92)) = 0
   AND RIGHT(`OXNAMESPACECLASSNAME`, CHAR_LENGTH(`OXTASK`) + 1) = CONCAT(CHAR(92), `OXTASK`);

Without the update the module keeps working: unconverted rows are found via OXNAMESPACECLASSNAME and converted whenever look for new tasks saves them again.

Upgrading to v2.5

v2.5 adds the download of export files from the log (see Backend). No migration, just refresh the module:

vendor/bin/oe-console trw:module:refresh trwscheduler

ISchedulerTask gained the method getPathForDownloadFiles() for this. Tasks extending AbstractSchedulerTask inherit its default (no downloads); a class implementing the interface directly has to add it.

Getting started

  1. In the module settings, group Cronjob, switch on cron job processing (bTRWSchedulerCronjobActive). Without it no task is ever run by the cron job.
  2. Set up a server cron job that calls OXID's cron script, ideally every minute:

    * * * * * php /path/to/shop/source/bin/cron.php
    

    Each call runs one step of one task at most. The crontab of a task only says from when on it is due; how soon it actually runs depends on how often this cron job fires.

  3. Backend Service → Task-Scheduler: click look for new tasks. The scheduler scans the active modules and adds every task it does not know yet — inactive.
  4. Activate the tasks you want, check their crontab, and decide per task between Cronjob yes (scheduled) and Cronjob no (manual only).

Activate at least Update Upcoming Prices if the shop uses prepared prices (oxupdateprice): with this module active, nothing else switches them.

How a run works

On every call of bin/cron.php the scheduler:

  1. releases a stale lock (see iTRWSchedulerLockTimeout) and logs that it did;
  2. answers busy if another run still holds the lock;
  3. picks one task: first an active scheduled task that is still in progress from an earlier call, otherwise the active scheduled task whose next start is the longest overdue;
  4. runs one step of it within the runtime budget;
  5. if the task reports further steps, it stays in progress (shown as paused) and continues on the next call; otherwise its next start is calculated from its crontab.

If a task throws, the error is logged (shop log and scheduler log), the run is discarded and the task starts over at step 0 on its next regular date — one broken task no longer blocks all others. The lock is released in any case.

Runtime budget per step: the setting for the console or the browser, capped at PHP's max_execution_time (if one is set), minus 5 seconds reserved for writing the log and the response, but at least 5 seconds.

Backend: Service → Task-Scheduler

  • Task list — per task: title, active/inactive, Cron/manual, the crontab, the next run, and whether it is running or paused. Active, scheduled-or-manual and crontab are saved in place. A task without a default crontab is always manual.
  • start manually — for active manual tasks. In the browser a long task runs step by step: with bTRWSchedulerManualAutoReload the page reloads itself for the next step, otherwise use manual reload. If a step does not come back in time, the page says so; the processed part is saved and the task resumes where it stopped when it is started again. Manual starts are blocked while a task is running; the settings stay editable.
  • Reset — for a running task: releases the lock and takes the task out of progress. Data a task has already changed is not undone.
  • upload — for manual tasks that work on files (the task defines the target directory); the files already waiting are listed. Only files of the types csv, tsv, txt, xml, json, zip, gz are accepted (SchedulerController::UPLOAD_EXTENSIONS); anything else, and any name containing php, phtml, phar & co., is rejected with a message in the log.
  • download — links in log messages of tasks that write files (e.g. exports). The files usually lie outside the document root and have no public URL; the link delivers them to logged-in admins only, and only files directly in the download directory the task names. The scheduler adds the session token only when it shows the log, so links from cron runs in the database log work too. If the file is gone, the log says so.
  • Next potential Cronjob-Task — which task the cron job would pick next.
  • show Database-Log (paged, newest first) and clean Database-Log.
  • look for new tasks and remove all inactive tasks (deletes the list entries; a task that is still present is found again by the next search).

Settings

GroupSettingDefaultEffect
CronjobbTRWSchedulerCronjobActive — enable cron job processingoffmaster switch for scheduled runs. Off: the cron job finds nothing to do; manual runs still work
RuntimesiTRWSchedulerRunTimeBrowser — maximum execution time for browsers (s)30budget per step of a manual run in the backend
RuntimesiTRWSchedulerRunTimeConsole — maximum execution time for console scripts (s)150budget per step of a cron run
RuntimesbTRWSchedulerCheckStillRunning — check if the task scheduler is already runningonruns one task at a time (lock). Off: runs may overlap
RuntimesiTRWSchedulerLockTimeout — release a stuck lock automatically after (minutes)60a run killed from outside (memory or time limit, host) leaves its lock behind; after this age the lock counts as stale. Never shorter than one step may take. 0 = never
Manual RunbTRWSchedulerManualAutoReload — start a necessary reload automaticallyonsee start manually
File optionsiTRWSchedulerFileAgeForImport — minimum age of imported files (minutes)360an import task only touches files older than this, so it does not read a file still being uploaded. Applies to import tasks that ask for it: a module's import task that reads files from an upload directory can call SchedulerConfig::getFileAgeForImport() and skip younger files (see Helpers). 0 = no check
Log outputbTRWSchedulerLogWriteToDb — write the log into the databaseonkeeps the log of each run for the backend; off: the log ends with the request
Log outputsTRWSchedulerLogLevel — log thresholdinfoerror, warning, notice, info, debug. One threshold for what is written and what the log view shows. Use debug for troubleshooting only — the lines are written at the end of each run and cost runtime
Log outputiTRWSchedulerLogLoadMaxEntries — maximum log entries per page200page size of the log view; 0 or more than 5000 is capped at 5000
Log outputiTRWSchedulerLogKeepDays — keep log entries for (days)90the scheduler deletes older log lines of the shop by itself, at most once a day as part of a run. 0 = never delete

Logging

Each run collects its messages (task started, paused at step x/y, continued, stopped, aborted, stale lock released, new task installed) and writes them in one go at the end of the run into the table trwschedulermonolog, filtered by the log threshold. Entries older than iTRWSchedulerLogKeepDays days are deleted by the scheduler itself: at the end of a run (cron or manual), at most once a day, for the current shop only. clean Database-Log still empties the whole table. Errors from a crashing task additionally go to the shop log.

Built-in tasks

TaskTitleDefault crontabWhat it does
TaskUpdateUpcomingPricesUpdate Upcoming Prices10 2 * * *switches prepared prices (oxupdateprice) to the current price, forced
TaskDummyHello World!10 2 * * *writes one log line; for testing the setup

Changes to the shop core

  • ShopControl::executeMaintenanceTasks() is switched off. In the core it runs on every request and only updates upcoming prices; with this module that is the job of the task Update Upcoming Prices alone.
  • Maintenance::execute() (called by bin/cron.php) runs the scheduler instead of the core's price update.

Task identity and shops

  • A task is identified by its fully qualified class name (oxtask, e.g. TheRealWorld\SchedulerModule\SchedulerTasks\TaskDummy). Two modules may therefore ship task classes with the same short name. Title key and log channel stay with the short name.
  • Code that addresses a task from outside (SchedulerHelper::deleteTask(), SchedulerHelper::getTaskOxId()) passes the fully qualified name. A short name is still accepted as long as exactly one task of the shop carries it; if it is ambiguous, nothing happens.
  • Existing entries are converted by the one-off v2.4 SQL update. Entries whose class cannot be resolved stay unchanged; they are still found and are lifted by the next look for new tasks once their class is back.
  • Task list, cron selection, running task and remove all inactive tasks only cover the current shop (oxshopid); newly found tasks are created for it. The shop comes from the OXID context — on the console for bin/cron.php as well; in CE it is always shop 1. Entries from before v2.4 carry shop 1.

Limits

  • One step of one task per cron call. Several due tasks are worked off one after the other, so the cron interval sets the throughput.
  • New tasks only appear after look for new tasks; they are never added on module activation.
  • The crontab of a task can only be as precise as the server cron job that triggers the scheduler.

For developers

Writing a task

A module provides a task by placing a class in <module>/src/SchedulerTasks/:

  • file name Task*.php, class name = file name, one class per file, PSR-4 autoloadable;
  • not abstract, implements TheRealWorld\SchedulerModule\Core\ISchedulerTask — usually by extending AbstractSchedulerTask;
  • title: a translation key equal to the upper-cased short class name (e.g. TASKMYIMPORT) in the module's translations/<lang>/ language file. Use the frontend translations: on the console the admin language files are not loaded;
  • $sDefaultCrontab: the default schedule (* * * * * format). Empty = manual-only task;
  • install(): called once when the task is first found.
namespace MyVendor\MyModule\SchedulerTasks;

use TheRealWorld\SchedulerModule\Core\AbstractSchedulerTask;

class TaskMyImport extends AbstractSchedulerTask
{
    protected string $sDefaultCrontab = '*/15 * * * *';

    public function run(int $iCurrentStep = 0, int $iCurrentSupStep = 0, bool $bRunManually = false): bool
    {
        $iOffset = $iCurrentSupStep;
        foreach ($this->loadRecordsFrom($iOffset) as $aRecord) { // your own code
            $this->importRecord($aRecord);                       // your own code
            $iOffset++;
            if ($this->isRunTimeOver()) {
                // pause: the scheduler calls run() again with this sub step
                $this->iNextSubStep = $iOffset;
                return true;
            }
        }

        // next step 0 and sub step 0 = the run is finished
        $this->iNextStep = 0;
        $this->iNextSubStep = 0;

        return true;
    }
}

Steps: after run() the scheduler reads getNextStep() and getNextStep(true). If either is greater than 0, the task stays in progress and run() is called again with these values on the next cron call or reload; 0/0 ends the run. The return value of run() is not evaluated. isRunTimeOver() checks the deadline the scheduler stored in the session and never fires when there is none.

Log via ToolsLog::setLogEntry($sMessage, $sChannel, $sLevel) from the tools-plugin; the entries end up in the scheduler log.

Manual file tasks: override getPathForManualFiles() (target directory of the upload) and getManualFileList() (files waiting). The upload form appears for active manual tasks without a default crontab. The overview asks every task through SchedulerTask::getManualFilesPath() / getManualFiles(), which catch an Error thrown by a task (e.g. a typed $aPathConfig read before initialisation), log it and show no upload field for that task instead of breaking the whole page. Still give such properties a default (protected array $aPathConfig = [];).

Tasks with files to download: override getPathForDownloadFiles() (directory relative to the shop root like the manual files, e.g. ../var/<module>/export/) and put $this->getDownloadLink($sFileName) as the href into the log message. The link is relative and already HTML-escaped; SchedulerController::render() adds the admin self link with the token when it shows the log, SchedulerController::downloadTaskFile() delivers the file. The task is looked up by its fully qualified class name, the file only by its name without a path.

public function getPathForDownloadFiles(): string
{
    return MyConfig::getPathConfig()['exportPath'];
}

// ...
ToolsLog::setLogEntry(
    sprintf('Export %s created: <a href="%s">download</a>', $sFileName, $this->getDownloadLink($sFileName)),
    __CLASS__ . ' - ' . __FUNCTION__,
    ToolsLog::LOG_LEVEL_INFO
);

Helpers

  • AbstractArticleExportTask: fixed five-step run for "collect → CSV → zip → send" exports (resetData() at step 0, then generateData(), exportData(), zipData(), sendData() for steps 1–4; initExport() before each step).
  • SchedulerConfig::getFileAgeForImport(): the minimum file age setting for import tasks.

Internals

  • Tables (migrations in migration/data/, table oxmigrations_trwscheduler): trwschedulertasks (task list, state, current step, crontab, next start) and trwschedulermonolog (run log, taken over from the tools-module). Since v2.4 trwschedulertasks.OXTASK holds the fully qualified class name.
  • Class extensions: OxidEsales\Eshop\Core\ShopControl, OxidEsales\Eshop\Application\Model\Maintenance.
  • Admin controller SchedulerController (controller key schedulercontroller, lowercase as in menu.xml, and so is every cl= in the template and the reload URL), template @trwscheduler/admin/scheduler, block extension of the admin headitem.html.twig for the JS.
  • Assets: sources in resources/ (Vite, SCSS, vanilla JS), build output in assets/src/.