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:
automation_scan_state(new table,lib/automations/schema.ts+ migration0048_fancy_lifeguard) — one row per(automation_id, automation_version, trigger_type), unique on that triple, holding an opaquecursor(text, nottimestamptz— every current definition uses an ISO-8601 instant, but a future one may want a composite watermark) andlast_scanned_at. Cascade-deletes from bothtenantsandautomations. Keying byautomation_versionmeans re-activating an automation (which bumpsversionand resetsactivated_at) seeds a fresh cursor — consistent withautomation_fired_ledger, which is also version-keyed.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);claimFiredinsideexecute.ts#processAutomationremains the authoritative once-per-crossing guarantee, so a race in the scan’s suppression read is harmless.- The cursor seed IS the backfill guard:
readOrSeedScanState(lib/automations/engine/scan-state.ts) seedscursorfromautomations.activated_atthe first time a tick observes a given(automation, version, trigger), so the first scan looks only forward.execute.ts’s existingentityTimestamp < activatedAtcheck is a second, independent backfill defense that needed no change, since scan events carry the crossing moment asentityTimestamp.
ScanTriggerDef and runScanTick
Section titled “ScanTriggerDef and runScanTick”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:
- Look up the trigger node’s type in
automationRegistryandsafeParseitsparamsagainst that node type’sparamsSchema. If the type isn’t registered or the params fail validation, log a warning, incrementsummary.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 trustquery.params’s shape without its own re-validation. - Read-or-seed that pair’s scan state.
rawCandidates = def.findNewlyMatching({ db, tenantId, since: cursor, now, limit: SCAN_BATCH_LIMIT, params, automationId })(SCAN_BATCH_LIMIT = 500).trimTieTail(rawCandidates, SCAN_BATCH_LIMIT)— see below.- Drop candidates already in the fired ledger for this
(automationId, automationVersion). - Enqueue one
TriggerEventper surviving candidate (entityTimestamp: crossedAt). - 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_atis stamped either way. - A pair whose
findNewlyMatchingthrows 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 whenlifetime_value_centsis at or above the node’s configuredthreshold(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”, sincewh_customershas no updated-at column) is newer than the scan cursor. Held back bySPENDING_SCAN_GRACE_MS(15 minutes):upsertOrdersandupsertCustomerscommit in separate transactions (lib/ingest/upsert.ts), so an order can land before its customer row’slifetime_value_centscatches up. Excluding orders newer thannow - 15mfrom 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 missingthresholdparam (registry validation already rejects this before the def is called, per the params check above) makesfindNewlyMatchingreturn[]rather than throw, as a second line of defense.customerInactiveScan— fires whenCOALESCE(MAX(wh_orders.placed_at), wh_customers.created_at) + daysfirst falls at or beforenow.crossedAtis that computed window-expiry instant, not scan time — this is what makes the backfill guard work: a customer already inactive when the automation activated hascrossedAt < activatedAtand is filtered out byexecute.ts’s existing check. No grace window here — the signal is onlywh_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:entityKeyembeds 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.entityIdcarries the bare customer id separately, since the suffixedentityKeyisn’t a validsource_customer_idforexecute.ts’sevent.entityId ?? event.entityKeyload.
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.
Right-rail config editors
Section titled “Right-rail config editors”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.
Cross-tenant discovery and crontab wiring
Section titled “Cross-tenant discovery and crontab wiring”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.
Why this exists
Section titled “Why this exists”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.