cd ../cookbook

Scheduled jobs and events: doing work later, the platform way

Scheduled script executions for recurring work, the event queue for reacting to it, and the fire-the-event, do-the-work-elsewhere pattern that keeps both honest.

You hit this the first time a business rule grows a second job. It creates the record, fine, and then it also emails a manager, updates a dashboard counter, and calls a REST endpoint, all synchronously, all while a user stares at a spinner. Or you hit it from the other side: someone asks for a nightly data quality check and you realize you have no idea where "every night at 2am" lives on the platform. The answers are older than Flow Designer and still carrying half the platform's weight: scheduled script executions for doing work on a clock, and the event queue for doing work in reaction to something, without making the something wait.

The quick version

Three pieces. First, an event registry record, which you create in the UI rather than in code: navigate to System Policy, Events, Registry, and register acme.incident.stale_unassigned on the incident table, with a description a stranger can understand.

Second, a scheduled script execution (sysauto_script) that runs nightly and does nothing but find offenders and fire events:

// Scheduled Script Execution: "DQ sweep - stale unassigned incidents"
// Run: Daily. Time zone: set explicitly, do not leave blank. Time: 02:00.
var LOG = '[DQ_SWEEP] ';
var found = 0;

var gr = new GlideRecord('incident');
// Active, nobody assigned, untouched for 2 days. Conditions, not sys_ids:
// this must mean the same thing on every instance it runs on.
gr.addEncodedQuery('active=true^assigned_toISEMPTY^sys_updated_on<javascript:gs.daysAgoStart(2)');
gr.query();
while (gr.next()) {
    gs.eventQueue('acme.incident.stale_unassigned', gr,
        gr.getValue('number'), gr.getDisplayValue('assignment_group'));
    found++;
}
gs.info(LOG + 'Queued ' + found + ' stale unassigned incident events');

Third, a script action (System Policy, Events, Script Actions) registered against that event, where the actual work lives:

// Script Action on acme.incident.stale_unassigned
// current = the incident the event was fired for, event = the sysevent row
var LOG = '[DQ_ACT] ';

current.work_notes = 'Data quality sweep: unassigned for 2+ days. ' +
    'Queued for triage (event ' + event.getUniqueValue() + ').';
current.update();

gs.info(LOG + 'Nudged ' + event.parm1 + ' in group ' + event.parm2);

The sweep finds problems. The event announces them. The script action fixes them. Each piece is small enough to reason about, and you can swap any one of them without touching the other two.

Building it step by step

Step one: register the event. An event is just a name until the registry makes it a contract. Register it on the table whose records you will pass to eventQueue, and prefix it with something that identifies you, like acme. or your app scope. Unprefixed names are how your event collides with someone else's three years from now.

Step two: create the scheduled script execution. System Definition, Scheduled Jobs, then Scheduled Script Execution gives you a sysauto_script record. Set Run to Daily, set the Time zone field explicitly (more on that below), and set the time. Keep the script's job description narrow: query, fire events, log a count. Resist the urge to do the work here.

Step three: write the script action. The script action receives two variables: current, the GlideRecord the event was fired against, and event, the sysevent row itself, with parm1 and parm2 available as plain strings. Do the real work here, and log with a prefix so the whole pipeline is grep-able end to end.

Step four: test each piece alone. Fire the event by hand from a background script against one known record and watch the script action react. Then run the scheduled job manually with Execute Now and watch the queue fill. Only then trust the clock.

Here is the whole pipeline in one picture:

The fire-the-event, do-the-work-elsewhere pattern
Scheduled job wakessysauto_script runs at 02:00 in the time zone you set, queries for offenders
Events queuedgs.eventQueue writes one row per offender into sysevent, state ready, parm1 and parm2 riding along
Event processor claims themThe platform's event job sweeps the queue every few seconds, state moves to processing
Reactions fan outEvery script action and notification registered for the event name runs, then state lands on processed or error

What is actually happening under the hood

gs.eventQueue does something almost insultingly simple: it inserts a row into the sysevent table with state ready, the event name, a reference to your record, and your two parameter strings. That is the entire cost inside your transaction, one insert. A platform job on the scheduler sweeps the queue every few seconds, claims ready events, flips them to processing, runs every script action and event-triggered notification registered for that name, and marks them processed, or error if a handler threw.

That little indirection buys you three things inline calls never give you. It is asynchronous, so the code that noticed the problem returns immediately instead of paying for the reaction. It is observable, because every event is a queryable row with a state and timestamps, so "did it run, and when" is a list view instead of an argument. And it fans out: when someone later wants an email on top of your work note, that is a notification added to the same event, zero changes to the sweep.

One honesty note: the queue is not a message bus. An event that lands in error stays there; nothing retries it automatically. You can requeue by flipping the state back to ready, but if you find yourself building retry logic on top of sysevent, you have outgrown it and should look at Flow Designer error handling or an integration hub.

So when is a flow the better answer today? When the reaction is a business process: approvals, tasks, notifications, things a process owner will want to read and modify without you. Flow Designer's record-based and scheduled triggers cover most "when X happens, do Y" cases declaratively, and that is the right default for new process work. Events still win when you need to signal from deep inside a script, when many independent reactions must hang off one announcement, or when volume is high enough that you want the cheapest possible producer. And the two compose: a script action can start a flow through sn_fd.FlowAPI when you want event plumbing with flow logic on the end.

Gotchas

"Run at 2am" means 2am in the job's Time zone field, and if you leave that blank, 2am in the instance's system time zone, which is almost never yours. I have seen a "nightly" sweep run at 10

for a Hyderabad team for months before anyone worked out why. Set the field explicitly, and remember daylight saving shifts can slide an unpinned job an hour against the business.

A few more. parm1 and parm2 are strings, full stop: pass numbers, names, or sys_ids, never objects, and if you need more than two values, put a small JSON string in one parameter and parse it in the handler. Pass the right GlideRecord as the second argument to eventQueue, because that record becomes current in the script action, and the registry entry's table should match it. And do not treat sysevent as an audit trail: the table rotates, and old shards fall off after days, not years. If you need a permanent record, your script action should write one somewhere durable.

Monitoring deserves a habit, not a dashboard project. A healthy queue holds a small number of ready events at any moment. A stuck queue looks like a ready count that climbs and an oldest-ready timestamp that ages: when you see both, check that the platform's event processing job is running and look for a handler that is throwing or hanging. A pile of rows in error state means a specific handler is broken; the state field tells you where to dig before a single user complains.

Keep a saved list view on sysevent filtered to your event name prefix, sorted newest first. It turns "is the pipeline healthy" into a ten second glance: states, volume, and the age of the oldest ready row.

Variations worth knowing

One summary event instead of many. If the sweep routinely finds hundreds of offenders and the reaction is human (a report, an email to a lead), fire one event carrying the count and a link to the filtered list rather than hundreds of per-record events. Per-record events are for per-record work.

Conditional scheduled jobs. sysauto_script supports a conditional checkbox with a condition script. A job that checks a property like acme.dq.sweep.enabled before running gives you a kill switch you can flip in production without touching the job, which you will be grateful for at exactly the wrong hour of some night.

Schedule-aware runs. Point the job's schedule at a business calendar so it skips weekends or change freezes, rather than encoding calendar logic in the script.

Events as an integration seam. Because notifications, script actions, and (through a script action) flows can all hang off one event name, a well-named event becomes a public API for "this thing happened" inside your instance. I have retired entire tangles of chained business rules by replacing them with one event and three small handlers. The sweep-plus-event pattern here is the same idea on a timer: the detector and the reactor stay strangers, and both stay simple.