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.

Note

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/crontab and the 1 */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, a flock that 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:

  1. Stage (06:00) before everything else. The stage renders the supplier emails into SupplierOrder.emailPreview and 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.
  2. 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.
  3. 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.
Tip

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=true or 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 */3 passes. 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.

Note

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.
Warning

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:

  1. bun run cli --task X always 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).
  2. Several scripts/cron/*.sh wrappers had neither set -e nor a final exit $EXIT_CODE: even a genuine non-zero exit code was flattened to 0 before reaching Ploi. ✅ All 24 wrappers now propagate their exit code (explicit exit $EXIT_CODE, exec, or the command last under set -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