It Compiled. It Ran. Nothing Got Written.
Diagnosing a CA 2E-generated program that returns clean, throws nothing, and still doesn't do the thing — and why calling it directly is not the same test as running it for real
A compile error is, in a strange way, the easy case. The compiler stops you, points at a line, and you fix it before anyone downstream ever sees the problem. What actually costs people time on CA 2E work isn't the 1,600-message compile listing — it's the program that compiles clean, runs to normal completion, puts nothing in the job log worth reading, and simply doesn't do what it was generated to do. No message. No indicator. The job ends *COMPLETE. And the row you expected in the file isn't there.
This is a longer piece than the others in this set, on purpose. The failure mode itself isn't complicated once you've seen it a few times, but the list of things that can produce it is long enough that "just look at the code" isn't useful advice, and I want to actually walk through each one rather than wave at them.
As with the rest of this material — no real library, program, or field names. The scenario is generalized but the mechanics are not.
The setup
Somebody needs to test a function that a CA 2E model generated — say it inserts a record into a shipment history file given a handful of key values. Instead of running it through whatever normally triggers it — a menu option, a batch job, a display file transaction — they do the direct thing: find the generated program's object name, and call it straight from a command line, hand-typing the parameters.
CALL PGM(GENLIB/PGMNAME) PARM(('') ('CODE1') ('99') ('VALUE1') ('VALUE2'))
It runs. No escape message. No *ESCAPE in the job log, no dump, nothing red. Job ends normally. And then somebody queries the file it was supposed to write to, and the row isn't there.
The instinct at this point is to assume the program is broken — there's a bug in the generated logic, or the action diagram has a condition that's wrong. Sometimes that's true. Far more often, in my experience, the program did exactly what it was told, and what it was told wasn't what production actually sends it.
Why calling the generated program directly isn't the same test
Go back to how a CA 2E function actually gets invoked in production, and this stops being surprising. A generated function very often isn't called directly by whatever kicks it off — there's a CL program sitting in front of it, and that CL program is not just a thin pass-through. It's doing real work before the RPG program ever gets control: setting up file overrides so the RPG program's generic file references resolve to the correct member or the correct library, possibly starting commitment control for the unit of work, sometimes translating an external code into the internal key value the RPG logic actually expects, sometimes validating that the caller is even allowed to do this before bothering to call the program that does it.
Skip the CL and call the RPG program directly, and you've skipped all of that. The program you're testing is real. The environment it's running in is not the environment it was designed to run in. This is the single most common reason a direct call "does nothing" — not because the logic is wrong, but because the test wasn't equivalent to the thing that actually happens in production, and nothing about a clean compile or a clean run tells you that distinction exists.
I'd go as far as saying: if you're testing a generated function for the first time and you don't already know for certain that there's no CL wrapper involved, your first attempt should go through whatever the real entry point is, not around it. Confirm the real path reproduces the problem before you start debugging the RPG program in isolation. Otherwise you can spend an afternoon convinced a program is broken when the only thing broken is how you're calling it.
Hypothesis one: commitment control, and the rollback nobody asked for
This is the one that's caught nearly everyone who's worked with journaled files under commitment control on IBM i at least once, and it's worth understanding properly rather than just knowing "sometimes this happens."
If the file involved is journaled and the program (or something in the call stack above it) started commitment control for this unit of work, then a write to that file isn't final the moment the WRITE or UPDATE operation executes. It's pending, under a commitment definition, until something explicitly commits it. Here's the part that surprises people: if the program reaches last record indicator — *INLR on — without an explicit COMMIT having been issued, the default behavior is not to commit what's pending. It's to roll it back. Silently. No message says "rolling back your changes." The row you thought you wrote simply reverts, as far as any other job or query is concerned, and the job that did the writing ends completely normally, because from the system's point of view nothing went wrong — you just never told it to keep the change.
This is exactly the kind of thing a production CL wrapper handles and a bare, direct CALL PGM doesn't, unless the RPG program itself manages its own commit boundary internally, which not all generated functions do — some are written assuming the caller owns the transaction boundary, which is a completely reasonable design choice for a function that's meant to be one step inside a larger multi-file unit of work, and a completely unreasonable thing to assume when you're calling it standalone from a command line to test it.
How you actually check this, rather than guess:
- Find out whether the file the function writes to is journaled at all. If it isn't, this hypothesis is dead immediately and you can skip to the next one.
- Find out whether commitment control was active for the job that made the call. This is a job-level attribute; a job that never started commitment control can't have this problem, no matter how the file is journaled.
- If both of those are true, the decisive test is simple: call it again, and this time issue an explicit commit yourself immediately afterward, in the same commitment scope, before the job ends. If the row shows up now, you've confirmed it, and the actual fix is either calling the function through whatever normally owns that commit boundary, or issuing the commit yourself as part of however you're driving the test.
I've also seen a variant of this that's more confusing to diagnose: the row is genuinely there, briefly, mid-transaction, and query it from a second session under the wrong isolation level and you'll either see it (uncommitted read) or won't (isolated read waiting on a lock that never resolves because the first job already ended and rolled back). If you're troubleshooting this interactively from two sessions at once, be aware that what you observe depends on which isolation level the checking query uses, and that alone can make the same bug look intermittent when it isn't.
Hypothesis two: the write landed somewhere you didn't check
If the production CL wrapper applies a file override — pointing a generic file name in the RPG program at a specific library, or a specific member, or a specific test copy of a file that exists for exactly this kind of ad hoc testing — and you call the RPG program directly without that override in place, the program still runs. It still executes a WRITE. It just resolves the file reference through whatever's on your own job's library list and override environment instead, which may not be the same target at all. The row might genuinely have been written. Just not where you looked.
This is worth checking before you conclude anything else, because it's fast to check and it's happened to me more than once: display the current file overrides in effect for your job before you call the program, and separately, check what the CL wrapper does when it runs normally — whether it applies an override of its own before invoking the RPG program. If it does, and you didn't replicate it, go look at the file your override-free call would have actually written to. Sometimes the row is sitting there exactly as expected, in a place nobody thought to check because nobody expected the write to land there.
Hypothesis three: the row exists, and the thing you queried can't see it
Related, but a distinct cause: you're looking at the right file, and the write genuinely happened and committed cleanly, but you're checking it through a logical file or a view with select/omit criteria that filters that particular row out. This happens more than you'd expect on files that have several access paths built for different purposes — a logical built for an active-orders screen that specifically excludes anything already flagged complete, for instance, would never show you a row you just inserted with a completed status, even though the physical write was entirely successful.
The fix here isn't a fix, it's just checking correctly: query the physical file directly, or a logical you know carries no select/omit restriction, before you conclude the write didn't happen at all.
Hypothesis four: the parameters you typed weren't the parameters the program needed
This one is specific to testing generated functions by hand, and it's easy to dismiss because "the call didn't error" feels like proof the parameters were fine. It isn't. A generated RPG program's parameter interface reflects exactly what the model's function definition says the parameters are — specific lengths, specific numeric types, zoned versus packed, a code that has to match one of a small set of valid values or the action diagram's own logic treats it as "not eligible" and takes a branch that does nothing on purpose.
If a CL wrapper normally sits in front of this function, part of what it may be doing is exactly this translation — taking a friendlier input and converting it into the exact shape the RPG program expects, sometimes including a lookup against another file to resolve an external code into an internal key. Skip that, hand-type a value that's the wrong length, the wrong justification, or simply doesn't match what a downstream condition is testing for, and the program can run start to finish, hit a condition that evaluates false, and reach *INLR having done nothing — correctly, according to its own logic, because as far as it's concerned you asked for something that isn't eligible.
The only reliable way to rule this in or out is to actually watch it happen: run the program under a source-level debugger, set a breakpoint just before the write operation you expect to fire, and look at the field values in front of you at that point. If you never reach that breakpoint, you've found your answer — some condition upstream of the write is evaluating false, and now you go find out why, with actual field values in front of you instead of guessing from the outside.
The order I'd actually check these in
Not because every case is the same, but because this order goes from cheapest-to-rule-out to most-invasive, and there's no reason to fire up a debugger before you've confirmed the basics:
First, reproduce it through the real caller, not around it. If the CL wrapper or the real entry point produces the same non-result, you've eliminated everything above that's specific to bypassing it, and you're looking at a genuine logic problem, not a test-environment problem. If the real caller works fine and only your direct call fails, stop looking at the RPG program's logic entirely — the problem is in what the direct call skipped, not in the function itself.
Second, if a journaled file and commitment control are anywhere in the picture, rule that out explicitly with a deliberate commit before you trust a negative result at all.
Third, check where the write actually went — overrides, library list, the member actually targeted — before assuming it went nowhere.
Fourth, check what you're using to verify the result — a filtered access path can hide a row that's genuinely there.
Fifth, and only once the first four are exhausted, get into the program itself with a debugger and watch the actual condition logic evaluate against the actual values it received.
I've seen people reach for the debugger first, because it feels like the thorough, technical thing to do. It's usually the slowest way to solve this specific problem, because three of the four most common causes have nothing to do with the code the debugger would show you. The debugger answers "why did this condition evaluate the way it did." It has nothing to say about a rollback that happened after the program already ended cleanly, or a write that landed in a file you weren't looking at.
The broader point, if there is one
A clean compile tells you the syntax is legal. A clean run with no error message tells you the operations that executed didn't fail. Neither one tells you the operations you expected actually executed, against the environment you expected, with the data you expected, and stayed committed after the job ended. Those are four separate claims, and a CA 2E-generated function — layered as it typically is behind a CL driver, sometimes behind commitment control, sometimes behind a translation step you never see unless you go looking for the wrapper — is exactly the kind of thing where testing it by calling the innermost piece directly quietly drops two or three of those four claims without telling you it did.
If something "worked, no errors" and the result still isn't there, the honest first question isn't "what's wrong with the program." It's "did I actually test the thing that runs in production, or did I test a piece of it in an environment nothing else ever puts it in."
Jaya Krushna Mohapatra is a Warehouse Management Systems Architect focused on enterprise integrations, IBM i modernization, and scalable backend systems.
Top comments (0)