Reading an Inherited SQL Server Agent Job: Step Flow, Retries and History

The job history says the nightly load failed. You open it. One step failed after its retries, the failure branch ran cleanly, the last step succeeded, and the job still says it failed, and the person who built it left two years ago.

A woman with a lantern and a map traces a route through a hedge maze of numbered stepping stones, with one path looping back and one stone walled off from every path.

Jobs like this grow one step at a time. Somebody adds a failure branch, somebody else adds a “go to step 9”, and a step that used to matter stops being reachable at all. Following those jumps by eye gets hard past a handful of steps. The step flow and the history are both in msdb. To test the queries, I built a small job with a retry, a failure branch, a loop and an unreachable step, and read it back with queries.

Where the Flow Is Stored

Two tables hold the flow. msdb.dbo.sysjobs.start_step_id is the step where execution begins.[1] Each row in msdb.dbo.sysjobsteps has an action for success and an action for failure, plus a target step for each.[2]

Code on_success_action / on_fail_action
1 Quit with success (default on success)
2 Quit with failure (default on failure)
3 Go to the next step
4 Go to the step in on_success_step_id / on_fail_step_id

The same table holds retry_attempts and retry_interval. The interval is in minutes.[3] In my test, a step with retries ran again after each failure, and its failure action only applied once the retries ran out.

The Test Job

I tested on one workstation with two instances: SQL Server 2019 (15.0.4500.2, Developer) and SQL Server 2025 (17.0.5015.4, Enterprise Developer). I created the job sat_multistep_demo on both, with its T-SQL steps running in a scratch database. The Agent service on the 2025 instance was stopped, and I left it that way. So the job only ran on 2019. The flow queries ran on both instances and returned the same rows.

Step Name Command On success On failure
1 Check source SELECT 1; go to step 3 quit with failure
2 Old archive step SELECT 1; next step quit with failure
3 Load staging RAISERROR, 2 retries, 1 minute apart next step go to step 5
4 Publish SELECT 1; quit with success quit with failure
5 Log failure SELECT 1; go to step 6 go to step 3
6 Notify and fail SELECT 1; quit with failure quit with failure

Nothing goes to step 2, and steps 3 and 5 can form a loop. Step 6 succeeds and then ends the job as a failure, which is one way to make a handled error still show as a failed job. I added each step with sp_add_jobstep, then set the go-to targets once all six steps existed. This is an excerpt from my setup script, and it writes to msdb, so don’t run it on a server you care about:

The Flow as a Readable List

The first query turns the codes into words. It also marks a go-to whose target step doesn’t exist, for a reason covered below.

Past ten or so steps, I’d rather have a query trace the paths.

Walking the Flow with a Recursive CTE

The second query treats each step as a node and each success or failure action as an edge. Action 3 points to step_id + 1, action 4 points to the stored target, and actions 1 and 2 end the path. The walk starts at start_step_id and carries the path as a string such as /1/3/5/. When the next step is already in the path, the row is marked as a loop and the walk stops there. Any step that never appears in the walk can’t run from a normal start.

Step 2 is unreachable, and the path /1/3/5/3/ is the loop. The loop only runs if step 3 fails, then step 5 fails as well. Agent accepted that definition without a warning, and I didn’t let it run. I don’t know whether Agent would ever break out of it on its own, so I’d check each loop by hand.

The CTE lists every path, not every step once. If a step’s success and failure actions point to the same step, its paths appear twice. A job with many branches can produce a lot of paths. The server-wide default limit for recursion is 100 levels, so a very long job may need OPTION (MAXRECURSION n) on the final SELECT.[4] STRING_AGG needs SQL Server 2017 or later.[5]

Go-To Targets Aren’t Checked

While testing the first query I pointed step 1 at a step that doesn’t exist:

Both instances returned Warning: Non-existent step referenced by @on_success_step_id. and saved the change anyway. On the 2025 instance the walk then marked every step after step 1 as unreachable, and the list showed go to step 99 (MISSING). On 2019 I started the job. Step 1 succeeded, and the job outcome row said The job failed. JobManager tried to run a non-existent step (99) for job sat_multistep_demo. I set the target back to 3 afterward. A warning in a deployment log is easy to miss, so I run the list query after any change to step numbers.

What sysjobhistory Records

I started the job with sp_start_job and read msdb.dbo.sysjobhistory once it finished. I’ve trimmed the output below to the first run, and it leaves out the instance_id and message columns to fit the page. run_status is 0 for failed, 1 for succeeded, 2 for retry, 3 for cancelled and 4 for in progress.[6]

  • Each attempt at step 3 has its own row. The first two attempts have run_status 2, and the third has 0. retries_attempted counts up from 0, and the attempts are one minute apart. The message on the final row ends with NOTE: The step was retried the requested number of times (2) without succeeding. The step failed.
  • Step 2 and step 4 have no rows. The history only records steps that ran.
  • The row with step_id 0 and step_name (Job outcome) is the job result. It was written last, but its run_date and run_time are the job’s start time. run_duration is in HHmmss form, so 200 means two minutes.
  • Every step after the retries succeeded, and the job outcome is 0 (failed). The message says The job failed. The Job was invoked by User CONTOSO\dbauser. The last step to run was step 6 (Notify and fail). Step 6 is the reason: it quits with failure when it succeeds.

I alert on the step_id 0 row, not the step rows. Monitoring SQL Server Agent Job Failures with SolarWinds DPA Custom Alerts builds a monitoring query on that row. If the outcome row shows a failure that nobody heard about, Unreported SQL Server Agent Job Failures covers the notification side.

Right after this run, sysjobsteps.last_run_retries for step 3 was 3. The documentation says that column holds the number of retry attempts in the last execution, and the history rows show 2.[2] I go by the history rows.

Unreachable Doesn’t Mean Unused

sp_start_job takes a @step_name parameter to start a job at a step other than the start step.[7] I changed step 3 to SELECT 1; and ran the job twice. The normal start ran steps 1, 3 and 4 and succeeded. Starting at Old archive step ran steps 2, 3 and 4, and the outcome message ended with The job was requested to start at step 2 (Old archive step).

So a step that the walk marks as unreachable may still be part of somebody’s manual recovery routine. Before deleting one, I’d search the history for outcome messages that mention a requested start step, and ask around.

Limits of This Test

  • I ran the job on SQL Server 2019 only. The Agent service on the 2025 instance was stopped, so on 2025 I tested the definitions, the warning and the queries, not the run-time behaviour.
  • I used T-SQL steps only, and didn’t test other subsystems such as CmdExec or PowerShell.
  • I didn’t run the loop.
  • The history query only sees rows still in sysjobhistory. If history has been purged, older runs won’t be there.

Related: Idempotent SQL Server Agent Job Deployment Across Many Instances.

If you’ve untangled an inherited job with a step flow stranger than this one, I’d like to hear about it. Find me on Bluesky or LinkedIn.

References

  1. dbo.sysjobs (Transact-SQL) - Microsoft Learn. Defines start_step_id as the ID of the step where execution begins. ↩
  2. dbo.sysjobsteps (Transact-SQL) - Microsoft Learn. Lists the on_success_action and on_fail_action codes 1 to 4, their defaults, and the step ID columns used by code 4. ↩
  3. sp_add_jobstep (Transact-SQL) - Microsoft Learn. States that retry_interval is the time in minutes between retry attempts. ↩
  4. WITH common_table_expression (Transact-SQL) - Microsoft Learn. States that the server-wide default recursion limit is 100 and that MAXRECURSION can change it per statement. ↩
  5. STRING_AGG (Transact-SQL) - Microsoft Learn. Applies to SQL Server 2017 (14.x) and later versions. ↩
  6. dbo.sysjobhistory (Transact-SQL) - Microsoft Learn. Lists the run_status values, the retries_attempted column and the HHmmss format of run_duration. ↩
  7. sp_start_job (Transact-SQL) - Microsoft Learn. Documents the @step_name parameter, the step at which to begin execution of the job. ↩