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.
Last updated: September 2026

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, a Query, or a SysGlobalObjectCache handle 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 HoldsCheap to Re-create?Correct ScopeWhy
Primitive counter, real, strTriviallyNarrowest block that uses itZero allocation cost; a narrow scope prevents stale reuse
Table buffer (CustTable, SalesLine)Cheap to declare, expensive to holdNarrowest block, never the class declarationA live buffer holds a cursor, a record image, and possibly a lock
Query / QueryRun objectExpensive — metadata assembly and range parsingOutside the loop that executes itRebuilding the query per iteration re-parses ranges every time
Map, Set, List used as a lookup cacheExpensive to repopulateOutside the loop; clear rather than re-newRe-allocating discards the very cache you built
.NET object implementing IDisposableVariesA using blockDeterministic Dispose() beats waiting for garbage collection
container accumulating rowsGrows quadraticallyAvoid entirely in loopsValue 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.CreditMax twenty 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 forUpdate buffer parked on the instance is the classic source of "random" UpdateConflict exceptions under concurrency.
  • Batch serialisation breaks. A RunBaseBatch or 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

  1. Find the per-iteration allocations. The call tree shows Query::addDataSource and QueryBuildDataSource::addRange invoked once per line — 480,000 times. Both are hoisted above the loop, and only QueryBuildRange::value() is called per iteration.
  2. Stop the container from accumulating. The task collected settled line identifiers into a container via conIns, which copies the entire structure on each append. At 480,000 elements this dominates the runtime. The container is replaced by a List of type Types::Int64, which appends in constant time.
  3. Demote the class-scoped buffer. CustTable was an instance variable on the settlement engine, selected forUpdate early and held for the whole run. It becomes a local buffer inside the block that needs it, and the engine retains only the CustAccount key.
  4. Scope the export writer. A System.IO.StreamWriter opened at task start and closed in a finally at task end is moved into a using block around the export phase, releasing the handle 90 percent earlier in the task's life.
  5. Keep the genuinely shared cache wide. The rebate-agreement lookup Map stays 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 Map on 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, or RecordViewCache; 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 select reduces 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 a using statement — or an explicit Dispose() in a finally — releases the handle deterministically.

[!WARNING] Exam Trap 5: Flushing a RecordInsertList Too Late insertDatabase() 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.

Loading diagram...
Choosing Narrower or Wider Variable Scope by Cost Profile
Test Your Knowledge

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?

A
B
C
D
Test Your Knowledge

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
B
C
D
Test Your Knowledge

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
B
C
D
Test Your Knowledge

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?

A
B
C
D
Congratulations!

You've completed this section

Continue exploring other exams