Fix NetSuite MapReduce Timeout on High-Volume SuiteQL
> The cursor builds on runSuiteQLPaged invocation, not on the first call to the page iterator.

On this page
The SSS_TIME_LIMIT_EXCEEDED error in a MapReduce map stage usually means one thing: the cursor initialization for query.runSuiteQLPaged is consuming your entire governance budget before a single row reaches your processing logic. On days when transactionaccountingline exceeds 1.5 million rows, common in Electronic Journal deployments or high-volume multi-subsidiary environments, the eager cursor build over tens of thousands of rows per chunk with multiple JOINs burns through the map timeout. Reducing pageSize or cutting pages per chunk does not help because the bottleneck is not row iteration; it is the upfront materialization of the full result set.
Why runSuiteQLPaged Builds the Full Cursor First
query.runSuiteQLPaged does not stream lazily. When you call it, NetSuite executes the entire SuiteQL statement, including all JOINs, WHERE clauses, and ORDER BY logic, against the underlying database and materializes a cursor positioned at the first row of the complete result set. Only then does it return a paged result object you can iterate. For a query joining transactionaccountingline to vendor, customer, account, subsidiary, department, currency, and three custom records across tens of thousands of rows, that materialization can take far longer than your remaining map budget. Your map function then has only a few seconds left to process pages of rows. The math does not work.
The cursor builds on
runSuiteQLPagedinvocation, not on the first call to the page iterator. Every JOIN multiplies the intermediate row set before the first page is returned.
It is also worth noting that without SuiteAnalytics Connect enabled, the query.runSuiteQL and query.runSuiteQLPaged methods can return a maximum of 100,000 results across all pages in the result set. Crossing that threshold produces its own failure mode layered on top of the timeout problem.
Chunk by Transaction ID Range, Not by Hour
Hourly chunking on createddate fails because high-volume days concentrate lines into specific hours, month-end close, batch invoice imports, or automated revenue recognition runs. A single hour can still hold tens of thousands of lines. The reliable partition key is the transaction internal ID (transaction.id), which is sequential, indexed, and evenly distributed. You can confirm the internal ID convention through the NetSuite Connector documentation, which explains that every record carries a permanent integer ID distinct from the visible transaction number.
Calculate ID boundaries in the getInputData stage, then pass each range as a map task parameter:
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
*/
define(['N/query', 'N/search', 'N/log', 'N/runtime'], (query, search, log, runtime) => {
const getInputData = () => {
// Find min/max transaction IDs for the target period
const boundsSql = `
SELECT MIN(t.id) AS min_id, MAX(t.id) AS max_id
FROM transaction t
WHERE t.trandate >= DATE '2024-11-01'
AND t.trandate < DATE '2024-12-01'
`;
const boundsResult = query.runSuiteQL({ query: boundsSql }).asMappedResults()[0];
const minId = boundsResult.min_id;
const maxId = boundsResult.max_id;
// Target ~25,000 lines per chunk (adjust based on JOIN complexity)
const chunkSize = 25000;
const chunks = [];
let currentMin = minId;
while (currentMin <= maxId) {
const currentMax = currentMin + chunkSize - 1;
chunks.push({ minId: currentMin, maxId: Math.min(currentMax, maxId) });
currentMin = currentMax + 1;
}
log.audit({ title: 'MapReduce chunks', details: chunks.length });
return chunks;
};
const map = (context) => {
const { minId, maxId } = JSON.parse(context.value);
const sql = `
SELECT tal.id, tal.transaction, tal.account, tal.debit, tal.credit,
tal.memo, tal.createddate, tal.currency, tal.exchangerate,
v.entityid AS vendor_name, c.companyname AS customer_name,
ac.acctnumber AS account_number, sub.name AS subsidiary_name,
dept.name AS department_name
FROM transactionaccountingline tal
LEFT JOIN transaction t ON tal.transaction = t.id
LEFT JOIN vendor v ON t.entity = v.id
LEFT JOIN customer c ON t.entity = c.id
LEFT JOIN account ac ON tal.account = ac.id
LEFT JOIN subsidiary sub ON t.subsidiary = sub.id
LEFT JOIN department dept ON t.department = dept.id
LEFT JOIN currency curr ON tal.currency = curr.id
LEFT JOIN customrecord_ej_custom1 c1 ON t.id = c1.custrecord_ej_trans_link
LEFT JOIN customrecord_ej_custom2 c2 ON t.id = c2.custrecord_ej_trans_link
LEFT JOIN customrecord_ej_custom3 c3 ON t.id = c3.custrecord_ej_trans_link
WHERE tal.id BETWEEN ? AND ?
ORDER BY tal.id
`;
const paged = query.runSuiteQLPaged({
query: sql,
params: [minId, maxId],
pageSize: 500
});
const iterator = paged.iterator();
iterator.each((page) => {
page.data.asMappedResults().forEach((row) => {
context.write({
key: row.id,
value: row
});
});
return true;
});
};
const reduce = (context) => {
// Write to Electronic Journal staging table or file
context.values.forEach((val) => {
const row = JSON.parse(val);
// Your existing rowToLineSQL / addLine logic here
});
};
return { getInputData, map, reduce };
});This approach guarantees each map task processes a known, bounded ID range. The BETWEEN predicate lets the database use the primary key index on transactionaccountingline.id, eliminating the full-scan cursor build. Governance consumption drops sharply per chunk because the optimizer seeks directly to the range start.
Governance Settings That Prevent Silent Failures
MapReduce scripts default to 1,000 governance units per map invocation. The control here is a soft limit that makes the job yield when surpassed, the runtime reschedules the work rather than killing the process. The SAFE Guide (Pitfall #123) recommends estimating (per-iteration cost) × (max iterations) + overhead before deploying any bulk script so variable-size inputs do not blow the budget silently.
To raise the ceiling on the map deployment:
- Open Customization > Scripting > Script Deployments.
- Edit your MapReduce deployment.
- Raise the Governance Limit to a value sized for your worst-case chunk (the platform maximum for the map stage is 50,000 units).
- Set Yield Threshold so the runtime yields before the hard timeout is reached.
Concurrency is a separate axis. The NetSuite Connector concurrency guide explains that base concurrent-request limits are tied to your service tier and increase by 10 for each SuiteCloud Plus license. If your account concurrency license allows, raise Maximum Concurrent Queued Executions to 10–15. Parallel map tasks on disjoint ID ranges then finish the month in minutes instead of hours.
Monitoring Cursor Build Time in Production
Add a timer around the runSuiteQLPaged call and log the elapsed milliseconds:
const start = Date.now();
const paged = query.runSuiteQLPaged({ query: sql, params: [minId, maxId], pageSize: 500 });
log.audit({ title: 'Cursor init ms', details: Date.now() - start });If cursor initialization exceeds 60,000 ms on any chunk, split that range further in getInputData. The SSS_SEARCH_ERROR_OCCURRED you saw on the paged call itself is often a downstream symptom of the cursor build exceeding the internal search timeout, not a query syntax error. You can trace these failures through the System Notes Guide, which records governance consumption and timeout events per script execution.
When to Consider SuiteAnalytics Connect Instead
If your Electronic Journal export runs monthly and the full dataset exceeds the 100,000-row SuiteQL cap, MapReduce is the wrong tool. With SuiteAnalytics Connect enabled, the 100,000-row ceiling on query.runSuiteQL and query.runSuiteQLPaged is removed entirely, and an ODBC/JDBC connection streams the same SuiteQL query directly to an external warehouse, Snowflake, Redshift, or SQL Server, without touching NetSuite governance. Your compliance team can query the replicated transactionaccountingline view daily while the NetSuite instance stays responsive for AP/AR processing.
For volumes under the SuiteQL cap, the ID-range MapReduce pattern above closes the gap. I've seen it reduce a failing multi-hour job to roughly 20 minutes on a multi-million-line November close. The key is accepting that runSuiteQLPaged is not a streaming API, it is a paginated view of an already-materialized cursor. Partition the work so the cursor is small enough to build fast, or move the workload off-platform entirely. Once the run is clean and reconciled, your auditors will thank you for the deterministic boundary each chunk provides during variance review.


