Skip to content

modernx-admin — Scan-trigger infrastructure (crontab + scan state)

Ingest delta detection covers triggers that arrive as a push — computeDelta sees a row change on the ingest batch that produced it. Three planned triggers can’t work that way: cart-abandoned, spending-threshold-crossed and inactive-customer are states an entity ages into — nothing arrives on the ingest path at the moment they become true. As of 2026-08-02 these are backed by a periodic scan instead. That PR (#396) landed the scan mechanism itself with scanTriggers empty — infrastructure only, inert in production. PR #402 registered the first two real definitions, spendingThresholdCrossed and customerBecameInactive (both entityDomain: "customer"); both are live today. cartAbandoned (C2) is the remaining follow-up.

Why a scan needs its own state, not just a query

Section titled “Why a scan needs its own state, not just a query”

A naive “query for matching entities every 5 minutes and fire” would re-fire on every tick for as long as an entity stays matching — a cart that sits abandoned for a day would fire 288 times. The scan mechanism exists to make that once-per-crossing instead, and to make turning a scan automation on not blast the entire pre-existing backlog as if it all just crossed at once. Three existing/new mechanisms combine to guarantee both, rather than one new mega-component:

  1. automation_scan_state (new table, lib/automations/schema.ts + migration 0048_fancy_lifeguard) — one row per (automation_id, automation_version, trigger_type), unique on that triple, holding an opaque cursor (text, not timestamptz — every current definition uses an ISO-8601 instant, but a future one may want a composite watermark) and last_scanned_at. Cascade-deletes from both tenants and automations. Keying by automation_version means re-activating an automation (which bumps version and resets activated_at) seeds a fresh cursor — consistent with automation_fired_ledger, which is also version-keyed.
  2. automation_fired_ledger (existing table) doubles as the fired-set — no second table for it. The scan reads it to suppress re-enqueueing keys that already fired (an optimization); claimFired inside execute.ts#processAutomation remains the authoritative once-per-crossing guarantee, so a race in the scan’s suppression read is harmless.
  3. The cursor seed IS the backfill guard: readOrSeedScanState (lib/automations/engine/scan-state.ts) seeds cursor from automations.activated_at the first time a tick observes a given (automation, version, trigger), so the first scan looks only forward. execute.ts’s existing entityTimestamp < activatedAt check is a second, independent backfill defense that needed no change, since scan events carry the crossing moment as entityTimestamp.

lib/automations/engine/scan.ts defines the contract a future scan trigger registers against:

interface ScanCandidate {
entityKey: string; // ledger-scoped exactly-once dedup key
entityId?: string; // row id to load, when it differs from entityKey
crossedAt: string; // ISO instant the entity BECAME matching
}
/** Context a scan def needs to find entities that newly matched its trigger. */
interface ScanQuery {
db: Db;
tenantId: string;
since: string; // opaque watermark from automation_scan_state
now: Date;
limit: number;
params: unknown; // the firing automation's trigger-node params, already schema-parsed
automationId: string;
}
interface ScanTriggerDef {
triggerType: string;
domain: EntityDomain;
findNewlyMatching(query: ScanQuery): Promise<ScanCandidate[]>; // crossedAt ASC, <= limit rows
}
// PR #402 filled this in — see "The first two registered triggers" below.
export const scanTriggers: readonly ScanTriggerDef[] = [
spendingThresholdScan,
customerInactiveScan,
];

findNewlyMatching originally took (db, tenantId, since, now, limit) as positional arguments; PR #402 collapsed them into the ScanQuery object above and added params/automationId once a real def (spendingThresholdScan) needed the trigger node’s own configured value (the threshold amount) rather than just the entity data.

runScanTick(db, deps?) is the crontab entry point. Per (active automation × registered def present in that automation's active graph), cross-tenant:

  1. Look up the trigger node’s type in automationRegistry and safeParse its params against that node type’s paramsSchema. If the type isn’t registered or the params fail validation, log a warning, increment summary.skipped, and move on to the next pair — no scan state is read or written for a skipped pair, so a bad automation never even seeds a cursor. This is what lets a scan def trust query.params’s shape without its own re-validation.
  2. Read-or-seed that pair’s scan state.
  3. rawCandidates = def.findNewlyMatching({ db, tenantId, since: cursor, now, limit: SCAN_BATCH_LIMIT, params, automationId }) (SCAN_BATCH_LIMIT = 500).
  4. trimTieTail(rawCandidates, SCAN_BATCH_LIMIT) — see below.
  5. Drop candidates already in the fired ledger for this (automationId, automationVersion).
  6. Enqueue one TriggerEvent per surviving candidate (entityTimestamp: crossedAt).
  7. Advance the cursor to the last processed candidate’s crossedAt (processed, not surviving — a suppressed candidate still has to move the watermark past itself) via a compare-and-set (WHERE cursor = <observed>) — only if the enqueue reported success — so an overlapping tick can’t regress it. last_scanned_at is stamped either way.
  8. A pair whose findNewlyMatching throws is logged and skipped; the tick continues to every other pair. One tenant’s broken scan definition must not stall any other tenant’s.

Ordering crossedAt ascending plus the batch limit is what makes a large backlog drain safely: a batch truncated at 500 is console.info-logged (no silent cap) and the cursor only advances to the last item actually processed, so the next tick resumes with no gap and no duplicate.

trimTieTail: the batch boundary can’t split a tie

Section titled “trimTieTail: the batch boundary can’t split a tie”

If a full (== limit) batch’s trailing candidates share one crossedAt, the cursor can’t tell which of them it actually finished processing — it would advance past the tie and silently drop whichever tied rows fell outside the batch. trimTieTail (scan.ts) drops that trailing tie run before the cursor advances, so the next tick picks up the trimmed rows with no gap and no duplicate. A batch below limit is never trimmed (nothing was truncated). If every candidate in a full batch shares one crossedAt — trimming would stall the cursor forever — the batch is kept intact and a warning is logged instead; this is a real, if rare, tradeoff (a stuck cursor is worse than a small chance of splitting a true tie).

The one existing-behavior change: enqueueTriggerEvents returns a boolean

Section titled “The one existing-behavior change: enqueueTriggerEvents returns a boolean”

enqueueTriggerEvents (lib/automations/engine/enqueue.ts) changed from Promise<void> to Promise<boolean> — true once the batch reached the job table (including the empty-list no-op), false when the insert threw and was swallowed. Ingest callers ignore the return value and are behaviourally unchanged; the scan tick needs it because advancing the cursor past a batch that never reached the job table would lose those crossings permanently — instead a failed enqueue leaves the cursor untouched (step 5 above) and the next tick re-enqueues the same candidates.

The first two registered triggers (lib/automations/engine/scan-triggers/customer.ts)

Section titled “The first two registered triggers (lib/automations/engine/scan-triggers/customer.ts)”

Both are entityDomain: "customer" and scan-join against wh_orders rather than a derived last_order_at column on wh_customers — the design doc’s open decision, resolved for backfill correctness (a scan-join always sees the true order history; a derived column could drift if an update were ever missed) and to avoid coupling the order-ingest path to a customer-table write it doesn’t otherwise need. A new wh_orders_tenant_customer_placed_idx index (migration 0049_greedy_roughhouse, (tenant_id, customer_id, placed_at)) serves both triggers’ join+aggregate.

  • spendingThresholdScan — fires once per customer per automation activation when lifetime_value_cents is at or above the node’s configured threshold (major units; the node stores/displays major units like every other money param, converted to cents only for the query) and the customer’s most recent order (MAX(wh_orders.placed_at) — the truest available proxy for “the moment lifetime value moved”, since wh_customers has no updated-at column) is newer than the scan cursor. Held back by SPENDING_SCAN_GRACE_MS (15 minutes): upsertOrders and upsertCustomers commit in separate transactions (lib/ingest/upsert.ts), so an order can land before its customer row’s lifetime_value_cents catches up. Excluding orders newer than now - 15m from the scan guarantees the cursor never advances past an order whose customer aggregate hasn’t caught up yet — without the grace window, a later crossing in the same batch could advance the cursor past the lagging customer’s order and lose that fire permanently. A malformed or missing threshold param (registry validation already rejects this before the def is called, per the params check above) makes findNewlyMatching return [] rather than throw, as a second line of defense.
  • customerInactiveScan — fires when COALESCE(MAX(wh_orders.placed_at), wh_customers.created_at) + days first falls at or before now. crossedAt is that computed window-expiry instant, not scan time — this is what makes the backfill guard work: a customer already inactive when the automation activated has crossedAt < activatedAt and is filtered out by execute.ts’s existing check. No grace window here — the signal is only wh_orders, so there’s no cross-table lag, and a late-arriving order only ever makes a customer less eligible. Deliberately allowed to re-fire: entityKey embeds the anchor instant (<sourceCustomerId>:<anchorIso>), so a customer who is won back and later lapses again produces a new key and a genuine second fire — the one scan trigger so far where “once per crossing” means once per distinct crossing, not once ever. entityId carries the bare customer id separately, since the suffixed entityKey isn’t a valid source_customer_id for execute.ts’s event.entityId ?? event.entityKey load.

runScanTick’s params-validation step (above) is what lets both defs safeParse(params) against their own *ParamsSchema and trust the shape — spendingThresholdScan still re-checks defensively since a safeParse failure inside the def itself is cheap insurance against the two validation paths ever drifting.

SpendingThresholdConfig and CustomerInactiveConfig (components/automations/config/) plug into NodeConfigPanel’s ConfigForNode switch, pre-empting a blank-panel bug: before this PR, any node type without a registered editor rendered nothing usable in the right-hand panel. Both follow the established pattern — safeParse the incoming params for display (falling back to the node’s defaultParams on malformed input), onChange with the merged object, ignore empty/NaN input rather than emitting it.

store.ts gains listActiveAutomationsForTriggerTypes(db, triggerTypes), cross-tenant by design — same posture as the existing stuck-run reaper: a cron sweep is infra-level, not tenant-scoped. It returns [] immediately, without querying, for an empty triggerTypes array, which is the production case today, so the shipped tick costs one cheap early-return check every 5 minutes and nothing else.

events.ts adds TASK_AUTOMATION_SCAN = "automation_scan" — no dot, same constraint as TASK_AUTOMATION_REAP (graphile-worker’s crontab parser rejects a dotted task token). runner.ts’s CRONTAB gains a second */5 * * * * automation_scan line alongside the existing reaper/schedule lines; runner-boot.test.ts proves the real graphile-worker run() accepts the multi-line crontab, which is the test that would have caught the historical dotted-task-name bug.

This is Layer C of the same trigger-breadth arc as Automations engine entity model and Ingest delta detection (parent epic modernx-3073, sibling modernx tracker) — the enabling infrastructure for cart-abandoned, spending-threshold-crossed and inactive-customer, which were the highest-risk layer because of re-fire semantics. C1 (PR #396) shipped the mechanism with scanTriggers empty and exercised only by test-local fixtures, so runScanTick against production config was a single no-op query every 5 minutes. C3 (PR #402) proved the mechanism end-to-end for a real domain: two live triggers, working config editors, and a Slack/webhook/email action wired to real Magento-sourced data — merchants can build VIP and win-back automations today. cartAbandoned (C2) remains the one follow-up bead; it adds a definition to scanTriggers and extends execute.ts’s resolveDomain the same way, and the scan mechanism itself does not need to change for it to land.

Merchant-facing behavior (what the two triggers do, how to configure them, what “fires once” means in practice) is documented separately for support and onboarding — see the public docs’ Automations guide.

Source: modernx-admin PR #396, PR #402.