NXKnowledge

Community Knowledge > Gotchas and Pitfalls

Where Journal Recording Limitations and Undo-Mark Pitfalls Collide

This article connects two gotchas documented separately elsewhere in this knowledge base: the recorder limitations in journal-recording-limitations.md and the undo-mark handling rules in session-undo-marks-pitfalls.md. Neither article alone predicts the failure mode below; it only shows up once you productionize a recorded journal that also sets its own undo mark.

The setup: a recorded journal, lightly hardened

A typical path into this problem: someone records a journal for an operation, then wraps it in the standard undo-mark pattern from session-undo-marks-pitfalls.md so it can be re-run safely:

Session.UndoMarkId markId = theSession.SetUndoMark(
    Session.MarkVisibility.Visible, "Recorded Operation");

try
{
    // recorded builder code, largely unmodified
    var builder = workPart.Features.CreateSomeBuilder();
    builder.SomeLiteralValue = 12.5; // recorded literal, per journal-recording-limitations.md
    NXObject result = builder.Commit();
    builder.Destroy();

    theSession.UpdateManager.DoUpdate(markId);
}
catch (Exception)
{
    theSession.UndoToMark(markId, "Recorded Operation");
    throw;
}

This looks correct against the pattern in session-undo-marks-pitfalls.md. The problem is what the recorder limitations do to the try block's failure modes.

Why recorded journals fail partway through Commit() more than hand-written code does

As documented in journal-recording-limitations.md, a recorded journal can omit a builder property that happened to sit at its default value during the recording session, even though that property is required for Commit() to succeed under other input conditions. Combined with the fact that recorded values are literal, not parameterized, a recorded journal turned into a reusable tool is far more likely than hand-written code to hit exactly the case the recorder never saw, and therefore to throw partway through Commit().

When Commit() throws:

  • The undo mark set by SetUndoMark before the try block still exists.
  • Depending on how far Commit() got before failing, NX may have already applied partial model state under that mark.
  • The catch block's theSession.UndoToMark(markId, ...) call is what is supposed to clean this up, by rolling the model back to the mark.

So far this matches the documented pattern. The interaction problem shows up in the cases people actually hit once they start reusing recorded code: multi-step recorded journals.

The dangling-mark case: multi-step recorded journals

Because journal code lives in a single file with no external libraries (journal-recording-limitations.md), a recorded journal that performs several related operations typically records them as a sequence of separate builder blocks in that one file, each with its own Commit(), rather than as calls into shared, separately-tested helper functions. A common way to "productionize" that recorded sequence is to wrap the whole sequence in one outer undo mark, on the assumption that one mark covering everything is simpler than one per step:

Session.UndoMarkId markId = theSession.SetUndoMark(
    Session.MarkVisibility.Visible, "Multi-Step Recorded Operation");

try
{
    builder1.Commit(); // recorded step 1
    builder2.Commit(); // recorded step 2, fails here on a real input
    builder3.Commit(); // recorded step 3, never reached

    theSession.UpdateManager.DoUpdate(markId);
}
catch (Exception)
{
    theSession.UndoToMark(markId, "Multi-Step Recorded Operation");
    throw;
}

If builder2.Commit() throws (for the reason above: a recorder-omitted property that this input actually needed), the catch block runs UndoToMark against the single outer mark. That is the correct call per session-undo-marks-pitfalls.md and it does roll back step 1's partial state. But two things commonly go wrong around it in practice:

  1. The rollback itself is skipped or fails. If the outer try/catch isn't actually wrapping every recorded step (easy to get wrong when adapting single-file recorded code that wasn't structured with error handling in mind), or if UndoToMark is called with the wrong mark identifier because the recorded code's flow was restructured during productionization, the mark from SetUndoMark is left in the session with no corresponding undo call ever reaching it. This is the "dangling undo mark": a mark that was created, never rolled back to, and never explicitly cleaned up.
  2. The wrong cleanup call is used to fix it. Per session-undo-marks-pitfalls.md, conflating "delete this one mark" with "roll everything back to this mark" is a documented, repeated source of confusing behavior. Someone who notices a leftover undo mark after a failed recorded run and reaches for UndoToMark again (instead of DeleteUndoMark) risks undoing marks created after the failure, including any cleanup or logging the surrounding tool did once it caught the exception.

The cleanup pattern this interaction actually needs

The fix is to treat a recorded journal's Commit() failure as an expected outcome, not an edge case, precisely because journal-recording-limitations.md establishes that recorded builder property sets are not guaranteed complete against arbitrary input. That means:

  • Set the undo mark immediately before the recorded step(s) that might fail, not once at the top of a long multi-step sequence, so a partial-sequence failure has a narrow, well-understood mark to act on.
  • On a caught Commit() failure, decide explicitly whether the intent is "roll the model back" (UndoToMark) or "just discard the bookkeeping mark I created, without touching anything else" (DeleteUndoMark) — do not default to whichever call happens to be nearby in the recorded code, since recorded code doesn't contain this decision at all.
  • If the mark was already rolled back via UndoToMark inside the catch block, do not also call DeleteUndoMark on it afterward on the assumption that "cleanup" always means both; UndoToMark already consumes the mark for that purpose.
  • If a tool sets a mark purely for its own bookkeeping around a recorded step (for example, to isolate that step's undo history from surrounding steps) and the step succeeds, clean up with DeleteUndoMark rather than leaving the mark in the session indefinitely, since an accumulating pile of unnamed marks from repeated tool runs is itself what session-undo-marks-pitfalls.md calls out as the multiplied cost.
Session.UndoMarkId markId = theSession.SetUndoMark(
    Session.MarkVisibility.Invisible, "Recorded Step Boundary");

bool committed = false;
try
{
    NXObject result = builder.Commit(); // recorder-omitted property may make this throw
    committed = true;
}
finally
{
    if (committed)
    {
        // Step succeeded: this mark was only internal bookkeeping, discard it
        // without disturbing any marks the caller set up around this call.
        theSession.DeleteUndoMark(markId, "Recorded Step Boundary");
    }
    else
    {
        // Step failed partway through Commit(): roll the model back to
        // before this step, since Commit() may have applied partial state.
        theSession.UndoToMark(markId, "Recorded Step Boundary");
    }
}

Practical takeaway

  • Recorded journals are more likely than hand-written code to throw partway through Commit(), because the recorder can omit builder properties that only matter for inputs the recording session never exercised (journal-recording-limitations.md).
  • Every Commit() call taken from a recorded journal and reused in a tool needs an undo mark scoped tightly enough that a partial failure has one clear, known mark to act on, not one mark shared across an entire multi-step recorded sequence.
  • On failure, roll the model back with UndoToMark. On success, if the mark was only internal bookkeeping, discard it with DeleteUndoMark instead of leaving it dangling in the session or reaching for UndoToMark and accidentally undoing marks set afterward.

Source: Synthesis of journal-recording-limitations.md and session-undo-marks-pitfalls.md in this knowledge base · retrieved 2026-09-25