This is the fourth conversion post in the SuiteScript 1.0 to 2.1 series, and it is the one people get wrong most often. The RESTlet migration we covered last month is scary because it breaks integrations. Scheduled scripts are the opposite problem. They keep working right up until the day they don’t, and because nobody is sitting in front of them when they run, the failure shows up as a report that quietly stopped updating three weeks ago.
There is also a decision buried in this one that the other conversions don’t have. With a User Event or a Suitelet, you port the script and you are done. With a scheduled script you have to decide whether you are porting it at all, or replacing it with something else.
Don’t port your scheduled script. Rewrite it as a Map/Reduce.
SuiteScript 2.1 still has a Scheduled Script type. You can absolutely take your 1.0 script, wrap it in define(), swap the nlapi calls for module calls, deploy it as @NScriptType ScheduledScript, and go home. It will run.
It will also inherit every limitation you have been fighting for the last eight years, and you will have burned your one good excuse to fix it.
Here is the practical difference. A scheduled script gets 10,000 usage units and one hour, total, for the whole run. When it runs out, you handle that yourself, which in 1.0 meant nlapiYieldScript() and a lot of hoping. A Map/Reduce doesn’t have that problem, because governance resets on every function invocation instead of applying to the job as a whole. It also runs across multiple processors at once, which a scheduled script cannot do, and it recovers from a failure on one record without losing the other 4,000.
If your 1.0 script contains the words getRemainingUsage or nlapiYieldScript anywhere, that is your signal. It was always meant to be a Map/Reduce. The script type just didn’t exist when you wrote it.
The exception is a genuinely small, genuinely serial job. Something that touches four records, or has to process them in a strict order, or just posts a summary email once a day. Those stay scheduled scripts, and that is fine. Everything else should move.
The script we are converting
This is a real pattern I see in almost every account that has been on NetSuite for more than a few years: find the open invoices, flag them for the dunning process, don’t blow up on governance. In 1.0 it looks like this.
function processOverdueInvoices()
{
var context = nlapiGetContext();
var results = nlapiSearchRecord('invoice', null,
[ new nlobjSearchFilter('status', null, 'anyof', 'CustInvc:A') ],
[ new nlobjSearchColumn('internalid'),
new nlobjSearchColumn('entity') ]);
if (!results) return;
for (var i = 0; i < results.length; i++)
{
if (context.getRemainingUsage() < 100)
{
var state = nlapiYieldScript();
if (state.status == 'FAILURE')
{
nlapiLogExecution('ERROR', 'Yield failed', state.reason);
throw 'Failed to yield script';
}
}
var id = results[i].getValue('internalid');
nlapiSubmitField('invoice', id, 'custbody_dunning_flag', 'T');
nlapiLogExecution('DEBUG', 'Flagged', id);
}
}
Before we touch anything, look at what is wrong with it, because two of these bugs have probably been silently costing you data for years.
It only ever sees 1,000 invoices. nlapiSearchRecord caps at 1,000 results and returns them without complaint. If you have 3,400 open invoices, 2,400 of them never get flagged, no error is thrown, and nothing in the execution log tells you. I have found this exact bug in production accounts where everyone assumed the script was working because it wasn’t erroring.
One bad record kills the whole run. If invoice 812 is locked by a closed period, the submitField throws, the loop dies, and invoices 813 through 3,400 are never touched. Tomorrow’s run starts from the top and hits the same wall in the same place, forever.
Yielding is not free. Every yield writes state, ends the execution, and reschedules. On a long job you can spend a meaningful chunk of your hour just yielding, and if the queue is busy the resumed run sits waiting.
A Map/Reduce fixes all three of those as a side effect of its structure. You don’t have to write any code for it.
How the four stages actually work
Most explanations of Map/Reduce start with a diagram and lose people immediately. Here is the version I use when I am explaining it to a client’s admin.
getInputData runs once and hands back your work. Return a search object, a query, an array, or a plain object. You do not loop here. You just say “here is the pile.”
map runs once per item in that pile, in parallel, on however many processors you have. Its job is to look at one item and decide what bucket it belongs in. It calls context.write({ key, value }) to put it there.
reduce runs once per unique key, and receives every value that was written under that key as an array. This is where the actual work happens for most scripts.
summarize runs once at the end and hands you the wreckage: usage totals, how long it took, and every error that was thrown in map or reduce, keyed by the record that caused it. Skipping this stage is the single most common mistake in Map/Reduce scripts.
The governance is per invocation, and the numbers are worth memorising because they drive how you split the work:
| Stage | Usage units | Time limit |
|---|---|---|
| getInputData | 10,000 | 60 minutes |
| map | 1,000 | 5 minutes |
| reduce | 5,000 | 15 minutes |
| summarize | 10,000 | 60 minutes |
There is also a soft limit of 10,000 units per map or reduce job. Hit it and NetSuite yields the job gracefully and spins up a new one to carry on, which is the yielding you used to hand-roll in 1.0, except you don’t write it and it doesn’t fail.
The converted script
Same business logic, four stages, no yield handling, no 1,000-record ceiling.
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
*/
define(['N/search', 'N/record'], (search, record) => {
const getInputData = () => {
return search.create({
type: search.Type.INVOICE,
filters: [
['status', 'anyof', 'CustInvc:A']
],
columns: ['internalid', 'entity']
});
};
const map = (context) => {
const result = JSON.parse(context.value);
const invoiceId = result.id;
const customerId = result.values.entity.value;
context.write({
key: customerId,
value: invoiceId
});
};
const reduce = (context) => {
const customerId = context.key;
const invoiceIds = context.values;
invoiceIds.forEach((invoiceId) => {
record.submitFields({
type: record.Type.INVOICE,
id: invoiceId,
values: { custbody_dunning_flag: true }
});
});
log.audit({
title: 'Flagged customer ' + customerId,
details: invoiceIds.length + ' invoice(s)'
});
};
const summarize = (summary) => {
log.audit({
title: 'Run complete',
details: 'usage=' + summary.usage +
' seconds=' + summary.seconds +
' yields=' + summary.yields
});
summary.mapSummary.errors.iterator().each((key, error) => {
log.error({ title: 'Map error on key ' + key, details: error });
return true;
});
summary.reduceSummary.errors.iterator().each((key, error) => {
log.error({ title: 'Reduce error on key ' + key, details: error });
return true;
});
};
return { getInputData, map, reduce, summarize };
});
Notice what is not in there. No usage checks. No yield logic. No try/catch wrapped around the whole thing. If invoice 812 is in a locked period, that one reduce key fails, gets logged in summarize with its key attached so you know exactly which customer to look at, and the other several thousand invoices are processed normally.
Why key by customer and not by invoice?
You could write context.write({ key: invoiceId, value: invoiceId }) and get one reduce invocation per invoice. It works. But every reduce invocation has overhead, and you would be paying it 3,400 times to do 10 units of work each.
Keying by customer groups the invoices, so a customer with 40 open invoices gets handled in one invocation instead of 40. At 10 units per submitFields on a transaction, and 5,000 units per reduce invocation, you have room for roughly 500 invoices under one key before the soft limit yields you. That is plenty of headroom for anything realistic.
The general rule: pick a key that naturally groups related work, and check that no single key can exceed your governance budget. If one customer could plausibly have 800 open invoices, key by invoice instead and take the overhead.
Three things that will trip you up
context.value is a string, always
In the map stage, context.value is a JSON string, not an object. Forget the JSON.parse and you get a mystifying undefined when you reach for a property. In the reduce stage, context.values is an array of strings, so if you wrote objects in map, you parse each one on the way out.
The values in a parsed search result are nested
When your input is a search, a parsed map result looks like this:
{
"recordType": "invoice",
"id": "1024",
"values": {
"internalid": [ { "value": "1024", "text": "1024" } ],
"entity": { "value": "487", "text": "Acme Cabinetry" }
}
}
Select and list fields come back as { value, text }. Some columns come back wrapped in an array. Free-text and numeric fields come back as plain strings. Do not guess at the shape. Log context.value once in the map stage, look at what actually came back for your specific columns, then write your parsing against that.
The .each() callback has to return true
The iterators in summarize stop the moment your callback returns anything falsy. Forget the return true and you will see exactly one error logged and assume there was only one. This catches people constantly.
Deploying it
Upload the file, create a new Script record so NetSuite reads the fresh JSDoc, and deploy it. Two things on the deployment are different from what you are used to.
Under Concurrency Limit you choose how many processors this deployment may use. This is shared across your whole account, so setting every Map/Reduce to the maximum means they fight each other. Start at 1 or 2 for a job that runs overnight and only raise it if the run genuinely takes too long.
Yield After Minutes defaults to 60 and controls when a long-running job voluntarily hands the processor back. Leave it alone unless you have a specific reason.
Set the schedule as normal. One thing worth knowing: a Map/Reduce running across several processors shows multiple entries on the Map/Reduce Script Status page, under Customization > Scripting > Map/Reduce Script Status. For this script type that page is far more useful than the plain execution log.
Prove it works before you retire the old one
The same rule from the RESTlet post applies here. Deploy the Map/Reduce alongside the 1.0 script, don’t overwrite it.
In Sandbox, run the old script and record what it changed. Then reset, run the new one, and compare. You are looking for one thing specifically: the new script should touch more records than the old one, because the old one was capped at 1,000. If it touches exactly the same number and that number is 1,000, you have just confirmed the bug and you now know how much data was being skipped.
In Production, deploy the Map/Reduce with its schedule set to Not Scheduled. Run it on demand once, read summarize, fix whatever it surfaces. Only when a manual run comes back clean do you put it on the schedule and set the old script’s deployment to Not Scheduled. Leave the old deployment in place, undeployed, for a full month before you delete it. It costs you nothing and it is a five-second rollback if something turns up.
Where this leaves you
Scheduled scripts are the easiest conversion in the whole 1.0 to 2.1 project to do badly, because a lazy port compiles, deploys, and runs, and nothing tells you that you just carried a 1,000-record ceiling and a fragile serial loop into 2.1 with you. The version above takes maybe two hours longer to write and removes an entire category of silent failure.
Work through your audit list and mark every scheduled script as either “small and serial” or “should be a Map/Reduce.” In most accounts that split lands around 20/80, and the 80 is where your real risk has been hiding.
Next in this series: converting Suitelets, and what to do with the 1.0 forms that were never really forms.
If you have a backlog of scheduled scripts and no time to work through it, The NetSuite Pro converts them as a fixed-fee engagement. Get in touch.
Discover more from The NetSuite Pro
Subscribe to get the latest posts sent to your email.
Leave a Reply