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.
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#
- The presenter goes into
app/UI/Cron/<Module>/Presenters/XxxPresenter.php,extends App\UI\Cron\BasePresenter, with only a manager in the constructor. - 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());
}
- Write
Frekvence:orSpouštětin the docblock — that is where the "How often" column in the manual comes from. Without it the manual build emits a warning. - Write
URL:in the docblock too — the generator takes the address from there. - For a non-periodic task simply write "manually"; inventing a cron expression for it is worse than nothing.
- Add the task to the deployment checklist — otherwise the generator reports at build time that it is missing from the plan.
- 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#
- A constant
GUARD_NAME(a name without thecron.prefix, so it does not meet the base lock) andGUARD_TTLa little shorter than the period — 20 h for a daily task, not 24 h. - At the start of the action
acquire(GUARD_NAME, GUARD_TTL); onfalsereturn success withskipped: ran_recently. - On an exception release the lock, and only then
sendError(). - 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