Crons & daily timeline
This page is the time-behavior view of the stock/ordering engine: which scheduled jobs run in production, at what time, in what order, and why that order is deliberate. Since the move to event-driven ordering, crons no longer carry the main load — they are safety nets and consolidation passes around a real-time flow.
All times on this page are server time (UTC) — that is what /etc/crontab reads. In
summer, 12:01 UTC = 14:01 in Paris.
Scheduled job inventory
Verified on the production box (Ploi-managed /etc/crontab + /etc/cron.d/ drop-ins):
| Job | Cadence | DB writes? | Role |
|---|---|---|---|
| PrestaShop order sync | every 3 h (0 */3) |
yes (orders) | Imports customer orders from the last 40 days. Never decrements stock (that path was deleted 2026-06-14). |
| PrestaShop product sync | every 3 h (0 */3) |
yes (products + ledger) | Stock, prices, and vendor reconcile (--link_vendors). Every Product.stock write emits a COMPLETED StockAction (#2749). |
| Auto-supplier-orders | every 30 min (*/30) |
yes (SO drafts) | Generates/adjusts supplier orders. Safety net: the event queue (supplier-orders-recalc) carries the load. |
| Supplier email stage | 06:00 | yes (emailPreview) |
Renders each draft's email without sending it (render-only) + persists an items fingerprint (itemsFingerprint). |
| Supplier-stock-health probe | 06:30 | no (read-only) | Snapshot of invariants I1–I5, reconcile accuracy, recalc queue health → KV. |
| Reconcile-supplier-orders | 07:15 | yes (--apply --removeOrphans) |
Recomputes the expected state from customer orders, applies the minimal diff. The only cron that fills supplier orders for manageStock=false products. |
| Auto-validate-stock-actions | 12:01 (canonical) + every 2 h at :01 (/etc/cron.d) |
yes (deliveries + receptions + SA) | Seals what the merchant forgot to confirm. Safety-net decrement — the primary gesture is the UI seal at the physical event (#2886). |
Two clarifications on that inventory:
- The order sync additionally has one-off weekly boost slots set in Ploi (Monday 10:00–10:30, Wednesday 19:45–20:15) — reinforcements, not a different rhythm.
- Auto-validate has two overlapping entries: the daily 12:01 line in
/etc/crontaband the1 */2 * * *drop-in in/etc/cron.d/auto-validate-stock-actions-extra(added with #2886, when the cron went from primary engine to safety net). Both fire at 12:01 on the same day. The overlap is neutralised on two levels: inside the task, idempotency (it skips already-sealed rows) and one seal per transaction; inside the wrapper, aflockthat serializes the two passes — the losing run exits 0 (an expected overlap is not an alert). The lock closes the TOCTOU window that idempotency alone left open: both runs could read "not sealed yet" and both seal.
The day at a glance
The background rhythm (30 min + 3 h) runs around the clock; the one-shot morning events (06:00 → 07:15) form the consolidation sequence detailed below; auto-validate sweeps every 2 hours with its canonical pass at 12:01.
A typical day as a Gantt chart
Durations are illustrative (a few minutes each) — the sequencing is what matters:
gantt
title Typical day (server time, UTC)
dateFormat HH:mm
axisFormat %H:%M
section PrestaShop sync
Orders + products (06:00 pass) : 06:00, 20m
Orders + products (09:00 pass) : 09:00, 20m
section Ordering
Auto-supplier 06:00 : 06:00, 5m
Auto-supplier 06:30 : 06:30, 5m
Auto-supplier 07:00 : 07:00, 5m
section Emails
Preview stage (render-only) : milestone, 06:00, 0m
section Checks
Stock-health probe (read-only) : 06:30, 10m
Reconcile --apply --removeOrphans : 07:15, 15m
section Stock safety net
Auto-validate (canonical pass) : 12:01, 10m
The morning sequencing — why this order
The 06:00 → 07:15 window is not arbitrary. Three reasons interlock:
- Stage (06:00) before everything else. The stage renders the supplier emails into
SupplierOrder.emailPreviewand freezes an items fingerprint (itemsFingerprint, sha256 of the sorted lines). The operator reviews these previews in the back-office review queue before sending. Staging early gives the operator the whole morning to review. - Probe (06:30) after the stage. The probe is read-only; it photographs the state
after the stage and after the 06:00 and 06:30 auto-supplier passes, which keeps its
metrics (reconcile accuracy, backdated drafts,
autoValidateSealed24h) comparable day over day. - Reconcile (07:15) last. Reconcile recomputes the expected supplier-order state from
customer orders and applies the diff. It runs after the 06:00 and 06:30 auto-supplier
passes on purpose: it catches the drift those passes missed, and it is the only
job that fills supplier orders for products not tracked in stock (
manageStock=false), which the auto-supplier cron excludes from its candidates.
The stage ↔ reconcile interplay is covered by the fingerprint: if the 07:15 reconcile
modifies a draft that was staged at 06:00, the current fingerprint no longer matches the
staged one. Sending then blocks on drift (CONFLICT — "Re-prepare, then resend") and
the review queue shows a drift chip with a "Re-prepare" button. What the operator reviewed
is what gets sent — never stale quantities.
A morning, hour by hour
Three things kick off at once:
- The 06:00 auto-supplier pass adjusts the supplier drafts (products with
manageStock=trueor with supply demand). - The email stage renders previews for DRAFT orders without one and freezes each
draft's
itemsFingerprint. - The PrestaShop syncs (orders + products) run — one of the
0 */3passes. The product sync goes through the same ledger-integrated path as the real-time webhooks.
The supplier-stock-health probe (read-only, /etc/cron.d/supplier-stock-health)
captures invariants I1–I5, reconcile accuracy and the health of the
supplier-orders-recalc queue, then pushes everything to KV for the control tower. The
06:30 auto-supplier pass runs in parallel.
The probe is the model of a good cron wrapper: it executes the task file directly and
ends with exit $EXIT_CODE — a failure actually propagates. See the alerting section
below.
reconcile-supplier-orders --apply --removeOrphans recomputes the expected state and
applies the minimal diff: additions, updates, and removals (the orphan sweep cancels +
tags cancellationReason, it does not delete). This is the pass that catches whatever the
06:00/06:30 auto-supplier runs missed and that covers manageStock=false products.
If a draft staged at 06:00 gets modified here, its fingerprint drifts → sending that email is blocked until "Re-prepare".
The background rhythm: the supplier-orders-recalc event queue processes every order
creation / update / cancellation within ~30 s, the auto-supplier pass sweeps behind it
every 30 min, and the PrestaShop syncs come back at 09:00. The operator reviews the staged
email queue and triggers sends — the send always re-renders from the current items and
checks the fingerprint.
Auto-validate fires (twice — daily crontab entry + 2-hourly drop-in, idempotent) and
seals what the merchant forgot: supplier receptions and customer deliveries whose
planned date is at least AUTO_VALIDATE_GRACE_HOURS (12 h) in the past. Every seal
writes via StockLedgerService.createStockAction/createManyStockActions with the shared
computeReplenishStockWrite helper (rolling-stock-math) — the same net write contract as the
UI click's recordPhysicalMovement.
To visualize the density of the morning window (the 30-min + 3-h rhythm around the one-shot events):
Auto-validate grace semantics
The deliveryDate / expectedDeliveryDate fields are planned dates — not proof that a
delivery physically happened. Auto-validate therefore only seals what is old enough to
have realistically occurred:
| Parameter | Prod value | Effect |
|---|---|---|
AUTO_VALIDATE_GRACE_HOURS |
12 (default) |
A reception/delivery is only eligible once its planned date is at least 12 h in the past. Makes the cron safe at any time and frequency. |
--daysBack |
12 |
Lower bound: nothing older than 12 days gets swept (anti "backfill the whole database" — the 2018-2024 backlog held 187 never-sealed orders). |
--maxItems |
200 |
Per-run cap, anti-runaway. |
--force |
passed by the cron | The task refuses an --apply touching > 10 SOs/orders without --force — the cap targets an accidental manual run, not the scheduled cron. |
The 2026-06-10 incident is why the grace window exists. The old eligibility rule
(planned date <= now) meant the noon run confirmed same-day deliveries… that had not
happened yet: 30 phantom fulfillments and 20 phantom receptions, −156/+95 units of wrong
stock. With the 12 h grace, a delivery planned today at 09:00 only becomes eligible at
21:00 — and a merchant who delivers early simply seals via the UI (the task then skips the
row thanks to its idempotency guards).
Reception eligibility: SENT/CONFIRMED SupplierOrders with no delivery yet, keyed on
expectedDeliveryDate with a fallback to scheduledSendDate when absent. Customer-delivery
eligibility: orders whose items are not all fulfilled. In both cases the seal creates the
real records (SupplierOrderDelivery + SUPPLIER_DELIVERY SA, or OrderFulfillment +
DECREMENT SA) through the same write contract as the UI — see
Write paths.
Concrete scenarios
The merchant corrects a stock level at 10:00. The write goes through
recordPhysicalMovement (counter + StockAction in the same transaction), then a
supplier-orders-recalc job (reason manual-stock) is enqueued with a ~30 s delay/dedupe
window. The product's supplier drafts are recomputed within a minute — no need to wait
for the 10:30 cron pass, which will merely confirm the state.
A customer order arrives at 11:59 vs 12:02. Virtually no difference. In both cases the event queue recomputes the ordering within ~30 s. And the 12:01 auto-validate is indifferent to a new order: its planned delivery date lies in the future, so it is more than 12 h away from any eligibility. The only thing that changes is which safety-net pass will eventually seal the delivery if the merchant forgets — a shift of at most 2 h given the 2-hourly sweep.
A delivery is planned for today at 09:00. The expected gesture is the seal at the
physical event: the fulfillment button or "Validate today's delivery round" in the
back-office. If nobody does it, the row becomes eligible at 21:00 (09:00 + 12 h grace) and
the next auto-validate pass — 22:01 — seals it. Under the old daily-only schedule it would
have waited until 12:01 the next day. The probe's autoValidateSealed24h metric counts
exactly these forgotten seals.
Alerting: the exit-code reality
Cron monitoring relies on the wrapper's exit code. Two historical traps used to compound — both are now closed:
bun run cli --task Xalways exits 0 unless the task throws. A task that signalled failure by returning{success: false}got swallowed — the cron "succeeded". ✅ The three gate tasks (auto-validate, auto-supplier-orders-service, supplier-order-email) now throw: on whole-run failure, and whenever per-item failures remain — the throw happens after all the safe work (one bad item never blocks the sweep, but the run fails loudly at the end).- Several
scripts/cron/*.shwrappers had neitherset -enor a finalexit $EXIT_CODE: even a genuine non-zero exit code was flattened to 0 before reaching Ploi. ✅ All 24 wrappers now propagate their exit code (explicitexit $EXIT_CODE,exec, or the command last underset -e).
A failing cron therefore genuinely exits non-zero and Ploi alerts. The correct pattern, to reproduce for any new wrapper:
# Good: run the task file directly + propagate the code
# (model: scripts/cron/supplier-stock-health.sh)
bun packages/scripts/src/tasks/my-task.ts "$@"
EXIT_CODE=$?
exit $EXIT_CODE
and on the task side: throw on failure (model: reconcile-supplier-orders.ts, which
throws on any per-item Prisma error instead of swallowing it — that very silence masked
weeks of broken reconciles in 2026-05).
The contract is pinned by tests: cron-wrappers.shape.test.ts fails any new or edited
wrapper that swallows its exit code (it strips # comments first — prose about exit codes
cannot satisfy the assertion), and auto-validate-stock-actions.shape.test.ts pins the gate
contract.
The back-office "Cron 5" chip
On top of exit-code alerting, the back-office carries a silent-failure detector: the
Stock ledger page
(/manage/ecommerce/inventory/stock-ledger) shows a chip that keeps two questions
deliberately apart.
1. Liveness (the chip's headline) — did the cron RUN? Read from the heartbeat the
task stamps into bext-KV at the end of every run (key
health:auto-validate:{tenantId}:{siteId}, 7-day TTL; the write is best-effort — a bext
outage never fails the run):
| Heartbeat age | Chip | Reading |
|---|---|---|
| ≤ 5 h | green | the cron is running (2-hourly cadence + slack) |
| ≤ 12 h | amber | several 2-hourly sweeps missed — keep an eye on it |
| > 12 h | red | the cron has not run — probably down |
2. Activity (secondary, "last activity") — did the cron WRITE? The age of the latest system validation, per side (⇣ supplier reception / ⇡ customer delivery).
Why the split: the last StockAction measures activity, not liveness. A quiet week writes
no row at all — the old chip read that as "dead cron"; and conversely a cron that died
right after a busy day showed green. The heartbeat answers "did it run?" directly. If bext is
unreachable (page opened off the tenant's box), the chip falls back to the legacy
activity-age status and says so in the popover — never to a false "dead".
This is the failure class hit on 2026-05-26: a cron that "runs" (exit 0) but no longer produces anything. The chip makes it visible without waiting for alerting.
See also
- Overview — the stock/ordering model on one page
- The stock ledger — signed authority, backorders, invariants
- Write paths —
recordPhysicalMovementand the transactional contract - Supplier orders — event-driven ordering and its gates
- PrestaShop sync — 3-hourly syncs and real-time webhooks
- Back office — the
/managesurfaces, including the Cron 5 chip - Safety nets —
--strictaudit, replay-diff, daily probe - Configuration — the flags (
AUTO_VALIDATE_GRACE_HOURS, gates, etc.)