Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Cron presenters

Cron presenters#

In this CMS a scheduled task is a presenter like any other — it is triggered by an HTTP request carrying a token and it returns JSON. The code lives in app/UI/Cron/, the base class in app/Core/Base/Cron/BasePresenter.php.

/cron/<module>/<presenter>/<action>?token=xxx

Today that is 72 actions across 9 modules (grep -rn "public function action" app/UI/Cron --include="*Presenter.php" | wc -l). Their list is generated from the code into this manual.

What the base class does for you#

Step What happens
initializeSetting() loads the settings (needed for the token)
verifyToken() compares ?token= against 1/cron.token using hash_equals(); a mismatch → 403
the concurrency lock CronLockManager::acquire() under the name cron.<module>.<presenter>.<action> in lower case (cron.eshop.productimport.run), see Concurrency
operation tryCall() wraps the action<Action>() method in an operation with the source cron; the startup with the settings, the token and the lock stays outside
shutdown() releases the lock

Plus two response helpers: sendSuccess($data) → {"status":"ok", …} and sendError($message, $code) → {"status":"error", …}.

An empty cron.token in the settings locks EVERY task

verifyToken() rejects the request even when the expected token is empty — otherwise empty settings would mean public endpoints. On a new environment the token therefore has to be set before the crons are wired up; until then everything returns 403 and it looks like a wrong URL.

The token travels in the query string, so it gets logged

It shows up in the web server's access log and in shell history. Do not treat it as a secret on a par with a password — it is protection against an accidental run, not against an attacker with access to the logs.

Procedure: creating a new task#

  1. The presenter goes into app/UI/Cron/<Module>/Presenters/XxxPresenter.php, extends App\UI\Cron\BasePresenter, with only a manager in the constructor.
  2. The action is a few lines — call the manager and send the result:
/**
 * Recomputes the denormalised price column on all adverts.
 *
 * Run periodically, e.g. every 10–15 minutes.
 *
 * URL: /cron/bazaar/advert/recount-prices?token=xxx
 */
public function actionRecountPrices(): void
{
    $this->sendSuccess($this->advertManager->recountPrices());
}
  1. Write Frekvence: or Spouštět in the docblock — that is where the "How often" column in the manual comes from. Without it the manual build emits a warning.
  2. Write URL: in the docblock too — the generator takes the address from there.
  3. For a non-periodic task simply write "manually"; inventing a cron expression for it is worse than nothing.
  4. Add the task to the deployment checklist — otherwise the generator reports at build time that it is missing from the plan.
  5. Verify by running it — with the correct token and without one.

Logic in a cron presenter cannot be called from anywhere else, nor tested

A cron presenter injects managers only — never the service layer, generators or EntityManagerInterface. Whatever lives in the presenter exists for the cron alone; the administration will then need the same thing and it gets written twice.

Concurrency#

The lock has two layers and CronLockManager::acquire() takes both:

Layer What it does Why
MySQL GET_LOCK() a named lock held by the process connection until release() frees it or the process ends (even by a crash) it decides: it never expires under a live run, however long it takes
a row in cms_cron_locks INSERT IGNORE on the name key with a ttl (300 s by default); a row past its TTL is deleted before the insert visibility for isLocked(), "at most once per" guards, and a process running the old code during a deployment

A second instance gets {"status":"skipped","reason":"already_running"}. An unexpired row with no GET_LOCK() holder (a crash within the TTL, old code) is honoured as well. release() deletes only a lock this instance actually holds, so it never deletes another run's lock.

The GET_LOCK() name contains the database name (the lock is valid for the whole DB server, and two sites on one server would otherwise block each other) and is hashed above 64 characters.

The lock is fail-open

When GET_LOCK() fails or returns NULL, acquire() returns true and the task runs without a lock. That is deliberate, the lock must not block the cron. When the cms_cron_locks table is missing, GET_LOCK() still holds, but "at most once per" guards stop working and the clean-up runs on every start.

A long task blocks its following runs

If a reindex takes an hour and the scheduler is set to ten minutes, five runs are discarded as skipped. Choose the frequency by the actual running time, not by how often it would be nice.

A connection pooler silently loses the lock

GET_LOCK() belongs to a connection. Behind ProxySQL or another pooler that swaps connections between queries, and after a reconnect in the middle of a run, the lock is gone and only the row with its TTL remains: a task longer than the TTL without refresh() can then start twice.

On top of the base lock, three patterns repeat across the tasks, all through CronLockManager (acquire($name, $ttl), refresh(), release(), isLocked()):

Pattern What for How Example
a lock shared with an administration button a manual and a cron run must not meet the admin action takes the same lock name as the cron feeds (cron.eshop.feed.generate), regenerating image variants
an own lock with renewal the task runs longer than 300 s own name and TTL, refresh() after every batch, release() in finally product import (TTL 900 s)
an "at most once per" guard a clean-up that must not run more often even when someone hooks it into a more frequent cron acquire(GUARD_NAME, GUARD_TTL) and on success do not release the lock; it expires by itself and the next run takes it over clean-up of logs, temporary files, orphaned notifications, cancelled accounts (20 h), payment activation probe (10 min)

Procedure: adding an "at most once per" guard#

  1. A constant GUARD_NAME (a name without the cron. prefix, so it does not meet the base lock) and GUARD_TTL a little shorter than the period — 20 h for a daily task, not 24 h.
  2. At the start of the action acquire(GUARD_NAME, GUARD_TTL); on false return success with skipped: ran_recently.
  3. On an exception release the lock, and only then sendError().
  4. Address parameters (?days=) do not bypass the guard — that is deliberate.

A guard with a TTL equal to the period skips every other run

A daily crontab fires a few seconds earlier than the previous run finished 24 hours before, the lock is still valid and the task ends skipped. Hence 20 h.

A guard not released after an error blocks the task for a whole period

Without release() in the catch, the clean-up after one failure runs again only the next day — and if it keeps failing, it never runs, although the cron "is running".

The operation in a cron job#

A Cron action is one operation: the Doctrine identity map is emptied at its start and its end, not inside. The outcome is taken from the response the action sent: ok is a success, skipped is "unknown" (part of the batch may have run) and error is a failure. Any other exception is a failure and is rethrown. While debugging (outside production mode) the action ends with an exception when unwritten work or an open transaction remains at the end, see contract violations.

A long batch keeps the whole action in memory

Nothing empties the identity map between batches, only the end of the action does. Call finishBatch() from UnitOfWorkReset after each batch, otherwise the action holds every loaded entity.

Batches#

Tasks work in batches; the size lives in the module's settings (7/payment.batchSize, 7/schedule.batchSize, …), typically 10.

A smaller batch more often beats a big one rarely

An interruption in the middle of a big batch means part of the work is done twice — which is fine for idempotent operations and not fine for the rest.

Two kinds of error#

Processing an item can fail in two ways and telling them apart is mandatory:

Kind Behaviour Example
transient the settled flag is not advanced, the item is retried next time — for queues with failure tracking with growing delays a save failure, a temporarily unavailable service
permanent the item is settled without a result and recorded in the errors for manual resolution an orphaned reference, a missing source record

Without that distinction one bad item starves the whole queue

A permanently broken item retried forever eats into the batch on every run and the others never get their turn. That is why it is settled with a note and the run moves on — see Invoicing, the section on automation.

A settled permanent error means the result is never produced

A paid order without an invoice warns nobody. The error report has to be checked, otherwise "the task completed successfully" is misleading.

A failing queue item#

A cron queue that takes items in batches (order and complaint expiry, documents from payments and catching up on documents, the invoice schedule, expiry warnings for adverts and companies) uses the shared record cms_system_cron_failures and CronFailureManager. A row is keyed by the pair (queue, item id) and holds the failure count, a shortened last error and next_attempt_at.

Step Call
before selecting a batch findParkedIds($queue) — ids of items whose time has not come yet; the queue excludes them from the query (NotInFilter) and the batch is filled with further rows
after a failure recordFailure($queue, $itemId, $error) — delay of 1 min, 5 min, 30 min, 2 h, then 24 h (RETRY_BACKOFF_MINUTES)
after a success recordSuccess($queue, $itemId) — the row is deleted
at the end of the run sendExhaustedNotification($queue) — after MAX_ATTEMPTS (5) failures in a row, one CRON_ITEM_FAILED notification per queue run, not per item

After the attempts are used up the item stays parked and is tried once every 24 hours. A row that has not been renewed for 7 days belongs to an item that left the queue another way and is cleaned up at the next selection. Writes go through raw DBAL (no UnitOfWork::clear() in the middle of a batch); a failure of the record is only logged and the queue goes on without delay. The notification's link points to the record row — once the item recovers, the row disappears and the orphaned-notification clean-up (probe CronFailureNotificationTargetProbe) detaches the notification from its target: it stays in the bell as text without a link.

A new queue with a batch of items

Call findParkedIds() before the selection, recordFailure() / recordSuccess() for each item and sendExhaustedNotification() after the batch. The queue name is a constant in the manager (for example eshop.order.expire). A permanently broken item then does not starve the others.

Identity and permissions#

Cron bypasses the RBAC guard on the internal path

It injects the Api managers directly, not through ApiBridge — so internalMode does not apply to it. Measured: zero API_RBAC path=internal lines across six runs.

A CLI script going through ApiBridge runs as guest

That is a different thing from cron. A session-less script in enforcing mode gets no data and returns silent emptiness, not an error. It has to supply an identity itself: ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)). In detail: RBAC in the API.

Fields are NOT filtered by audience in a cron

Outside a request there is no caller, so #[FieldAccess] does not apply. That is deliberate — an activation email needs activationKey — but it means whatever a cron sends outwards is its own responsibility.

Where to look#

I want Where
what the base class does app/Core/Base/Cron/BasePresenter.php
the concurrency lock app/UI/Api/System/Models/Managers/CronLockManager.php
the existing tasks app/UI/Cron/<Module>/Presenters/
the list of all tasks Scheduled tasks
the generator of that list manual/tools/gen-cron-list.py

Follow-up chapters: Scheduled tasks · Deployment checklist · RBAC in the API · Code conventions

Cookie settings