17.5 Tuning Variable Scope, Buffer Lifetime & Memory Pressure
Key Takeaways
- Because a variable's lifetime equals the block that declared it, declaration placement is a cost decision: narrow the scope of cheap-to-create, expensive-to-hold objects such as table buffers that pin cursors and locks, and widen the scope of expensive-to-create objects such as Query, QueryRun, and lookup Maps that would otherwise be rebuilt on every loop iteration.
- A table buffer promoted to an instance variable keeps a cursor open for the life of the object, serves stale data to later methods, can park a forUpdate lock, and breaks clean batch contract serialisation; hold the key and re-select instead.
- Going out of scope does not call Dispose on a .NET object, so streams, readers, and writers must be declared inside a using statement to release their unmanaged handles deterministically.
- Appending to a container inside a loop is quadratic because container assignment copies the whole structure; use List, Map, or RecordInsertList, and call insertDatabase() while the RecordInsertList is still in scope.
- Narrowing scope shortens how long a buffer is held while a select field list shrinks how much the buffer holds; the two optimisations address different symptoms and are frequently confused on the exam.
17.5 Tuning Variable Scope, Buffer Lifetime & Memory Pressure
Quick Answer: "Modify variable scope to optimize performance" is a distinct bullet in the Apply fundamental performance optimization techniques objective, and it is not the same question as the language-level scoping rules. Here the concern is cost: how long a declaration keeps memory, a cursor, or a lock alive on the Application Object Server (AOS). Three levers carry the weight. Narrow a declaration so an expensive object is released at the end of the block instead of the end of the method. Widen a declaration only when re-allocating inside a loop is the more expensive choice — a
Map, aQuery, or aSysGlobalObjectCachehandle rebuilt per iteration is pure waste. And never promote a table buffer to an instance variable purely for convenience, because a class-scoped buffer keeps a cursor, a record image, and potentially a row lock alive for the life of the object.
1. Scope Is a Cost Decision, Not Only a Correctness Decision
Because a variable's lifetime is exactly the block that declared it, the placement of a declaration is also a statement about how long the runtime must hold whatever that variable points to. The right choice differs by what the variable holds.
| What the Variable Holds | Cheap to Re-create? | Correct Scope | Why |
|---|---|---|---|
Primitive counter, real, str | Trivially | Narrowest block that uses it | Zero allocation cost; a narrow scope prevents stale reuse |
Table buffer (CustTable, SalesLine) | Cheap to declare, expensive to hold | Narrowest block, never the class declaration | A live buffer holds a cursor, a record image, and possibly a lock |
Query / QueryRun object | Expensive — metadata assembly and range parsing | Outside the loop that executes it | Rebuilding the query per iteration re-parses ranges every time |
Map, Set, List used as a lookup cache | Expensive to repopulate | Outside the loop; clear rather than re-new | Re-allocating discards the very cache you built |
.NET object implementing IDisposable | Varies | A using block | Deterministic Dispose() beats waiting for garbage collection |
container accumulating rows | Grows quadratically | Avoid entirely in loops | Value semantics mean each conIns copies the whole structure |
The failure mode is symmetrical: too wide and you hold resources you no longer need; too narrow and you pay setup costs repeatedly. Trace Parser exposes both — the first as a flat memory profile that never drops between operations, the second as thousands of short, identical calls in the call tree.
2. Narrowing: Releasing Buffers, Cursors and Locks Early
The most common real-world win is shrinking the scope of a table buffer. Consider a method that validates a file, then posts it:
// BEFORE: one wide scope for a 40-line method
public void processImportFile(FileId _fileId)
{
ImportStagingTable staging;
CustTable custTable; // needed for 4 lines
VendTable vendTable; // needed for 3 lines
LedgerJournalTrans journalTrans; // needed at the very end
// ... 30 lines of file parsing that touch none of these ...
}
Every one of those buffers is allocated on entry and held until the method returns, even though the parsing phase never touches them. Rewriting the method so each buffer is declared inside the block that uses it lets the runtime release the cursor and record image as soon as the block closes:
// AFTER: each buffer lives only as long as its phase
public void processImportFile(FileId _fileId)
{
// ... parsing phase declares only what parsing needs ...
{
CustTable custTable;
select firstonly RecId, CreditMax from custTable
where custTable.AccountNum == this.parsedAccount();
// custTable is released at the closing brace.
}
{
LedgerJournalTrans journalTrans;
// posting phase
}
}
Two effects compound here. First, the buffer itself is released earlier. Second — and this is the larger win — the select above uses a field list (RecId, CreditMax), so the buffer that lives inside the narrow scope is a two-column image rather than a full CustTable row. Narrow scope and narrow projection are complementary: the first shortens how long you hold the memory, the second shrinks how much memory you hold.
Locking behaviour follows the same logic. A buffer selected forUpdate inside a transaction holds its lock until the transaction ends, but a buffer selected forUpdate and then left in scope while the method performs unrelated work extends the window during which other sessions collide with it. Declaring the buffer in the tightest possible block is a structural way of keeping that window short.
3. Widening: When Re-Allocation Inside a Loop Is the Real Cost
The opposite mistake is just as expensive and shows up more often in code written by developers who have just learned the narrowing rule.
// ANTI-PATTERN: rebuilds the query metadata on every one of 50,000 iterations
while select salesLine
{
Query query = new Query();
QueryBuildDataSource qbds = query.addDataSource(tableNum(InventDim));
qbds.addRange(fieldNum(InventDim, InventSiteId)).value(salesLine.InventSiteId);
QueryRun qr = new QueryRun(query);
while (qr.next()) { /* ... */ }
}
Assembling a Query involves metadata lookups and range parsing. Doing that 50,000 times to change one range value is wasted AOS compute. The correct shape builds the object once, in the enclosing scope, and mutates only the range:
Query query = new Query();
QueryBuildDataSource qbds = query.addDataSource(tableNum(InventDim));
QueryBuildRange range = qbds.addRange(fieldNum(InventDim, InventSiteId));
while select salesLine
{
range.value(queryValue(salesLine.InventSiteId));
QueryRun qr = new QueryRun(query);
while (qr.next()) { /* ... */ }
}
The same reasoning applies to a Map used as a lookup cache. If the map is declared inside the loop, every iteration throws away the entries the previous iteration paid to compute. Declare it outside; if per-iteration isolation is genuinely required, call a clear or re-initialise operation rather than allocating a new instance, so the runtime is not asked to collect 50,000 discarded maps.
4. The Class-Scoped Table Buffer Anti-Pattern
Promoting a table buffer to an instance variable feels convenient — several methods need the same record, so why pass it around? The costs are real and are a favourite scenario stem.
- The cursor never closes. The buffer holds an open record image for as long as the object instance lives. In a batch task that instantiates one controller and loops for an hour, that is an hour of retained state.
- Stale reads become invisible. Method A selects the record, method C reads
this.custTable.CreditMaxtwenty seconds later and silently uses a value another session has already changed. A local buffer forces an explicit re-select, which is the behaviour you actually want. - Locks outlive their transaction intent. A
forUpdatebuffer parked on the instance is the classic source of "random"UpdateConflictexceptions under concurrency. - Batch serialisation breaks. A
RunBaseBatchor SysOperation data contract that carries a live table buffer in instance state cannot be packed cleanly; contracts are meant to carry keys and primitives, and the receiving task re-selects from the key.
The disciplined pattern is to hold the key, not the buffer:
class ABC_CreditReviewEngine
{
// Hold the key, not a live cursor.
protected CustAccount custAccount;
protected AmountMST currentCreditLimit()
{
CustTable custTable; // local, short-lived
select firstonly CreditMax from custTable
where custTable.AccountNum == custAccount;
return custTable.CreditMax;
}
}
If the repeated re-select genuinely becomes the bottleneck, the answer is a caching mechanism designed for the job — CacheLookup on the table, SysGlobalObjectCache, or a RecordViewCache over an equality-defined set — not a parked buffer.
5. Deterministic Release for Interop and Accumulators
Two categories of variable deserve explicit lifetime management rather than a scope rule alone.
Disposable .NET objects. Streams, readers, writers, and HttpClient-adjacent objects hold unmanaged handles. Declaring them in a using statement scopes them to the block and guarantees Dispose() runs at block exit, even on an exception path. Leaving them to garbage collection is what produces the socket and handle exhaustion described in the outbound integration material.
Accumulators. A container grows by copying, so appending inside a loop is quadratic. The correct accumulators are RecordInsertList and RecordSortedList for records, and List/Map for objects — all reference types whose scope should span the loop but not the whole method. RecordInsertList in particular buffers rows and flushes them in array inserts, so it must stay in scope for the whole insert phase and be flushed explicitly before it goes out of scope.
public void bulkStageLines(RefRecId _headerRecId)
{
RecordInsertList inserts = new RecordInsertList(tableNum(ABC_StagingLine));
ABC_StagingLine line;
while select sourceLine where sourceLine.HeaderRecId == _headerRecId
{
line.clear();
line.HeaderRecId = _headerRecId;
line.Amount = sourceLine.Amount;
inserts.add(line);
}
inserts.insertDatabase(); // Flush before the list leaves scope.
}
Compile-Time Scope Costs Too
One scoping choice costs build time rather than run time. A macro declared in a class declaration is reachable from every method of every derived class, and Microsoft states that this legacy behaviour has a significant effect on compiler performance. Replacing such macros with scoped const members removes that penalty — a rare case where the performance argument and the code-quality argument point the same way.
6. Realistic Enterprise Scenario: A Batch Task That Grows Until It Times Out
Business Problem
A nightly rebate settlement task processes 480,000 sales lines. It completes in 25 minutes on a Tier 2 sandbox with 40,000 test lines, but in production it climbs steadily in memory and is killed at the batch timeout after four hours. Trace Parser shows no single slow statement; instead, the call tree shows a flat sequence of identical Query construction calls, and AOS memory never drops between iterations.
Architecture & Implementation Walkthrough
- Find the per-iteration allocations. The call tree shows
Query::addDataSourceandQueryBuildDataSource::addRangeinvoked once per line — 480,000 times. Both are hoisted above the loop, and onlyQueryBuildRange::value()is called per iteration. - Stop the container from accumulating. The task collected settled line identifiers into a
containerviaconIns, which copies the entire structure on each append. At 480,000 elements this dominates the runtime. The container is replaced by aListof typeTypes::Int64, which appends in constant time. - Demote the class-scoped buffer.
CustTablewas an instance variable on the settlement engine, selectedforUpdateearly and held for the whole run. It becomes a local buffer inside the block that needs it, and the engine retains only theCustAccountkey. - Scope the export writer. A
System.IO.StreamWriteropened at task start and closed in afinallyat task end is moved into ausingblock around the export phase, releasing the handle 90 percent earlier in the task's life. - Keep the genuinely shared cache wide. The rebate-agreement lookup
Mapstays declared above the loop. It was briefly moved inside the loop during the refactor, which made the task slower because each iteration rebuilt a cache the previous one had just populated — a useful reminder that "narrow everything" is not the rule.
Measurable Outcomes
Peak AOS memory for the task becomes flat rather than monotonically rising, because nothing now survives an iteration that does not need to. The query-construction calls fall from 480,000 to one. The task completes inside the batch window, and it does so without a single change to the SQL it issues — the entire gain came from where the declarations sit.
7. Real-World MB-500 Exam Traps
[!WARNING] Exam Trap 1: "Narrow Everything" as a Universal Answer A scenario describes a loop that rebuilds a lookup
Mapon every pass. An option offers to "reduce the variable's scope to the loop body." That is exactly the defect. Scope should be narrowed for cheap-to-create, expensive-to-hold objects and widened for expensive-to-create, cheap-to-hold ones.
[!WARNING] Exam Trap 2: Treating an Instance Table Buffer as a Cache When a stem says several methods need the same customer record, the tempting option promotes the buffer to the class declaration. It holds a cursor, serves stale data, and can park a lock. The supported caching answers are
CacheLookup,SysGlobalObjectCache, orRecordViewCache; the structural answer is to hold the key and re-select.
[!WARNING] Exam Trap 3: Confusing Scope with Projection Narrowing a declaration shortens how long a buffer lives; it does not shrink the row. Only a field list on the
selectreduces how many columns cross the wire. A question describing an oversized buffer wants the field list, and a question describing retained memory wants the scope change — read which symptom is stated.
[!WARNING] Exam Trap 4: Letting a Disposable Object Fall Out of Scope Going out of scope does not call
Dispose()on a .NET object; it only makes the reference unreachable. Only ausingstatement — or an explicitDispose()in afinally— releases the handle deterministically.
[!WARNING] Exam Trap 5: Flushing a
RecordInsertListToo LateinsertDatabase()must be called while the list is still in scope. A refactor that narrows the list's declaration into the loop body both defeats the array-insert batching and discards buffered rows, which usually surfaces as a silent shortfall in the inserted row count.
A batch job iterates 200,000 sales lines. Inside the loop it constructs a new Query, adds a data source, adds a range for the current line's site, and runs it. Trace Parser shows hundreds of thousands of identical metadata construction calls. Which scope change resolves this?
Several methods on a settlement engine class need the same customer record. A developer declares CustTable as a protected instance variable in the class declaration and selects it forUpdate once during initialisation. Which combination of consequences does this design introduce?
A developer refactors a bulk staging routine and moves the RecordInsertList declaration from the method body into the while select loop body so that its scope is as narrow as possible. Row counts in the staging table drop sharply after the change. What went wrong?
A report data provider selects full CustTable buffers inside a long method and holds them until the method returns. Memory profiling shows both a large per-row footprint and memory that is never released until the method completes. Which pair of changes addresses both symptoms?
You've completed this section
Continue exploring other exams