Status: Reviewed
Stack: PowerShell → SQL / stored procs → Blazor
Depends on: SQL-CLI-09 (overview), SQL-CLI-01 (calling), SQL-CLI-07 (pipeline shapes)
Goal: Know when to use msdb Agent control-plane procs (sp_help_job*, sp_start_job / sp_stop_job, sp_update_job, …) vs querying msdb tables for Blazor inventory — and the permission / multi-set / platform gotchas.
Sources: Microsoft Learn (sql-server-ver17 / 2026). Expands SQL-CLI-09 §3.4 only — do not treat this as a rewrite of the overview. Calling: SQL-CLI-01. Staging → Blazor: SQL-CLI-07.
| In this doc (09d) | Out → sibling |
|---|---|
Agent control plane: sp_help_job*, sp_start_job / sp_stop_job, sp_update_job, thin sp_add_job* |
Config / sp_configure / who → 09a |
History: sp_help_jobhistory, sp_purge_jobhistory |
Object help / space / rename → 09b |
Schedules / steps help: sp_help_schedule, sp_help_jobstep |
Security / principals / orphans → 09c |
Fixed Agent roles (SQLAgent*Role) |
Surface area / xp_* → 09e |
Dashboard shapes: sysjobs, sysjobsteps, sysjobhistory, sysjobactivity, sysschedules, sysjobschedules |
Full call patterns / decision table → CLI-09 |
Control plane (ops): Agent procs in msdb — start/stop, enable/disable, interactive help.
Read path for Blazor: always msdb tables (single-select joins) — never scrape multi-result sp_help_job into a grid.
Platform: SQL Server Agent lives on SQL Server and Azure SQL Managed Instance (MI has feature gaps). Azure SQL Database has no Agent — use Elastic Jobs (jobs.sp_* in the job DB) or external schedulers. Same proc names in Elastic Jobs are a different API.
| Need | Prefer | Why |
|---|---|---|
| One-off peek in SSMS / sqlcmd | sp_help_job / sp_help_jobactivity / sp_help_jobhistory |
Fast human dump; filters built in |
| Inventory → staging → Blazor | msdb.dbo.sysjobs* tables |
One result set, typed columns, WHERE/JOIN, stable shape |
| Start / stop a job now | sp_start_job / sp_stop_job |
Supported control plane — don’t reinvent with service calls |
| Enable / disable job (ops) | sp_update_job @enabled |
Scriptable; OperatorRole can flip enable on jobs they don’t own (name/id + @enabled only) |
| Create new jobs at scale | SMO / scripted job defs / SSDT (thin use of sp_add_job*) |
Prefer checked-in scripts over ad-hoc sp_add_* chains |
| Runtime “is it running?” for dashboards | sysjobactivity (current Agent session) |
Live dates; better than parsing help-proc text |
| Run history grids | sysjobhistory + DATETIMEFROMPARTS |
Documented int→datetime pattern; filterable |
| Next-run for UI | Prefer sysjobactivity.next_scheduled_run_date |
sysjobschedules can lag (~20 min refresh per Learn) |
| Purge old history | sp_purge_jobhistory (careful) |
Control plane; OperatorRole / sysadmin for broad purge |
| Caller | Behavior |
|---|---|
| sqlcmd / go-sqlcmd | Prints all sets — fine for eyeballs; ugly for -o CSV / parsers |
Invoke-Sqlcmd |
Typically surfaces the last result set — earlier sets silently dropped |
ADO.NET / SqlCommand |
Can walk NextResult() — not worth it for inventory |
Rule for this stack: Collect with a single-select against msdb tables into dbo.*Staging (CLI-07). Use Agent help procs for interactive triage; use start/stop/update for control plane only.
All live in msdb. Call as EXEC msdb.dbo.sp_… when the session DB isn’t msdb. Columns per proc: purpose · args · quirks · perms · prefer-instead.
sp_help_job| Purpose | Job metadata; with @job_name/@job_id, also steps + schedules + target servers |
| Args | Optional filters: @job_name / @job_id, @job_aspect (ALL/JOB/SCHEDULES/STEPS/TARGETS), @job_type (LOCAL/MULTI-SERVER), @enabled, @execution_status, owner/category/subsystem/dates, … |
| Result quirks | No job id/name → one job-summary set. With id/name → multiple sets (job + steps + schedules + targets). last_run_* / next_run_* are int (yyyyMMdd / HHmmss) |
| Perms | SQLAgentUserRole (owned only) / Reader / Operator / sysadmin |
| Prefer instead | sysjobs (+ sysjobsteps / sysjobschedules / sysschedules) for Blazor |
EXEC msdb.dbo.sp_help_job; -- interactive list
EXEC msdb.dbo.sp_help_job @job_name = N'NightlyBackups', @job_aspect = N'ALL';
sp_help_jobactivity| Purpose | Snapshot of runtime state for the Agent session |
| Args | Optional @job_id or @job_name; optional @session_id (default = most recent session) |
| Result quirks | One set: run_requested_date, start_execution_date, stop_execution_date, next_scheduled_run_date, run_status, … Session IDs live in msdb.dbo.syssessions (new session each Agent service start) |
| Perms | Agent roles; only sysadmin sees activity for jobs owned by others |
| Prefer instead | sysjobactivity joined to sysjobs filtered to MAX(session_id) |
EXEC msdb.dbo.sp_help_jobactivity; -- all jobs caller can see (latest session)
sp_help_jobhistory| Purpose | Filtered job / step history report |
| Args | @job_name / @job_id, @step_id, @run_status, date/time ints (yyyyMMdd / HHmmss), @mode (SUMMARY default / FULL), @oldest_first, @server, … |
| Result quirks | Column list depends on @mode. Dates/times/durations are ints, not datetime |
| Perms | UserRole = owned jobs only; Reader/Operator/sysadmin broader |
| Prefer instead | sysjobhistory for dashboards |
EXEC msdb.dbo.sp_help_jobhistory @job_name = N'NightlyBackups', @mode = N'FULL';
sp_help_jobstep / sp_help_schedule| Proc | Purpose | Key args | Prefer instead |
|---|---|---|---|
sp_help_jobstep |
Steps for one job | @job_name or @job_id; optional @step_id / @step_name; @suffix |
sysjobsteps |
sp_help_schedule |
Schedule definitions | @schedule_id / @schedule_name; @attached_schedules_only; @include_description |
sysschedules (+ sysjobschedules for job link) |
UserRole: owned jobs/schedules only.
sp_start_job / sp_stop_jobsp_start_job |
sp_stop_job |
|
|---|---|---|
| Purpose | Run job immediately | Signal Agent to stop a running job |
| Args | @job_name or @job_id; optional @step_name (local), @server_name (target) |
@job_name / @job_id / @originating_server (mutually exclusive among first three); optional @server_name for MSX |
| Result | None (return code 0/1) | None |
| Perms | User/Reader: owned only. Operator: all local. sysadmin: local + multiserver | Same split |
| Gotchas | Applies to SQL Server + MI. Name collision with Elastic Jobs jobs.sp_start_job |
Stop is a signal — long BACKUP/RESTORE/DBCC may finish a stable point first. CmdExec/PowerShell steps can be force-killed (files left open) — extreme cases only |
EXEC msdb.dbo.sp_start_job @job_name = N'Weekly Sales Data Backup';
EXEC msdb.dbo.sp_stop_job @job_name = N'Weekly Sales Data Backup';
sp_update_job (ops focus)| Purpose | Change job attributes; most common scripted use = enable/disable |
| Args | @job_name or @job_id; then only what you change: @enabled, @new_name, @description, notify_*, @owner_login_name, … |
| Quirks | Omitted params keep current values. Owner change: T-SQL steps run as the job owner — changing owner changes security context. Only sysadmin can change ownership |
| OperatorRole note | Can enable/disable jobs they don’t own via @job_name/@job_id + @enabled only — any other param → fail |
| Prefer | Keep for control plane; don’t build create/replace pipelines here |
EXEC msdb.dbo.sp_update_job @job_name = N'NightlyBackups', @enabled = 0;
sp_add_job* family (thin)| Stance | Detail |
|---|---|
| Exists | sp_add_job, sp_add_jobstep, sp_add_schedule, sp_attach_schedule, sp_add_jobserver, … |
| Rick’s habit | Prefer checked-in job scripts / SMO / existing defs for create & promote. Ad-hoc sp_add_* chains drift and skip review |
| Ops day-to-day | Inventory + start/stop + enable/disable — not greenfield job authoring in this doc |
sp_purge_jobhistory| Purpose | Delete history rows (optionally older than @oldest_date) |
| Args | @job_name or @job_id; optional @oldest_date. sysadmin / SQLAgentOperatorRole may omit job → purge all (sysadmin: local+multiserver; Operator: local only) |
| Perms | Default: sysadmin or SQLAgentOperatorRole. User/Reader need explicit EXECUTE and then only on owned jobs |
| Risk | Easy to wipe fleet history — always pass @oldest_date (and usually a job name) in scripts |
EXEC msdb.dbo.sp_purge_jobhistory
@job_name = N'NightlyBackups',
@oldest_date = '2025-01-01';
Roles live in msdb, concentric (more privilege ⊆ less). Users need one of these or sysadmin to use Agent; otherwise the Agent node is invisible in SSMS.
| Role | Inventory / history | Start/stop | Enable/disable others’ local jobs | Purge history | Multiserver |
|---|---|---|---|---|---|
| SQLAgentUserRole | Owned jobs/schedules | Owned only | No (owned only) | No by default (needs explicit EXEC on purge; owned only) |
No |
| SQLAgentReaderRole | All local + multiserver view | Owned only | No | Same as UserRole | View yes; control no |
| SQLAgentOperatorRole | All view | All local | Yes via sp_update_job @enabled only |
All local | View yes; start/stop/purge multiserver no |
| sysadmin | Full | Full (local + multiserver) | Full | Full | Full |
Proxy inheritance: higher roles inherit proxies granted to lower roles — grant proxies carefully.
Collector login for Blazor: prefer SQLAgentReaderRole (read-wide) without Operator purge/start power, unless the app must trigger jobs (then Operator or a dedicated control path).
| Gotcha | Detail |
|---|---|
Multi-result sp_help_job |
Passing @job_name returns steps/schedules/targets as extra sets → Invoke-Sqlcmd last-set trap |
| Int dates/times | History and many help columns use yyyyMMdd / HHmmss ints — convert for UI (see §6) |
| History retention | On-prem: Agent properties (max rows / max per job). MI: Agent properties are read-only; history caps fixed at defaults (1000 total, 100 per job) — plan external archival if you need more |
sysjobschedules lag |
Learn: table may refresh on a ~20 minute cycle — don’t trust next_run_* there for live UI; use sysjobactivity |
| Local vs multiserver (MSX/TSX) | OperatorRole does not start/stop/purge multiserver jobs — need sysadmin. MSX not supported on MI |
| Azure SQL DB | No SQL Agent / no msdb Agent tables. Elastic Jobs = different jobs.sp_* API |
| MI gaps | Agent always on; no proxies; limited notifications; no CPU-idle schedules; PowerShell/CmdExec limits — see Learn SQL Agent job limitations in SQL Managed Instance |
sp_stop_job on CmdExec/PS |
Can force-kill the process — prefer graceful step design |
| Job owner context | T-SQL steps run as owner — ownership changes are security changes |
| Name collisions | sp_start_job / sp_stop_job / sp_purge_jobhistory also exist under Elastic Jobs schema — wrong database = wrong product |
Single-select shapes → stage → Blazor (CLI-07). Prefer documented DATETIMEFROMPARTS over undocumented helpers.
SET NOCOUNT ON;
SELECT
j.job_id,
j.name AS job_name,
j.enabled,
j.category_id,
SUSER_SNAME(j.owner_sid) AS owner_name,
j.date_created,
j.date_modified,
j.description,
SYSUTCDATETIME() AS collected_utc
FROM msdb.dbo.sysjobs AS j
ORDER BY j.name;
SET NOCOUNT ON;
SELECT
j.name AS job_name,
a.start_execution_date,
a.stop_execution_date,
a.next_scheduled_run_date,
a.last_executed_step_id,
CASE
WHEN a.start_execution_date IS NOT NULL AND a.stop_execution_date IS NULL THEN N'Running'
ELSE N'Not running'
END AS run_status_label
FROM msdb.dbo.sysjobs AS j
INNER JOIN msdb.dbo.sysjobactivity AS a ON a.job_id = j.job_id
WHERE a.session_id = (SELECT MAX(session_id) FROM msdb.dbo.sysjobactivity);
SET NOCOUNT ON;
SELECT TOP (500)
j.name AS job_name,
h.step_id,
h.step_name,
h.run_status,
DATETIMEFROMPARTS(
h.run_date / 10000, h.run_date % 10000 / 100, h.run_date % 100,
h.run_time / 10000, h.run_time % 10000 / 100, h.run_time % 100, 0
) AS run_start,
(h.run_duration / 10000) * 3600
+ ((h.run_duration % 10000) / 100) * 60
+ (h.run_duration % 100) AS run_duration_seconds,
h.message
FROM msdb.dbo.sysjobhistory AS h
INNER JOIN msdb.dbo.sysjobs AS j ON j.job_id = h.job_id
ORDER BY h.instance_id DESC;
-- Steps
SELECT j.name AS job_name, s.step_id, s.step_name, s.subsystem,
s.database_name, s.command
FROM msdb.dbo.sysjobsteps AS s
JOIN msdb.dbo.sysjobs AS j ON j.job_id = s.job_id;
-- Schedules attached to jobs
SELECT j.name AS job_name, sch.name AS schedule_name, sch.enabled,
sch.freq_type, js.next_run_date, js.next_run_time
FROM msdb.dbo.sysjobs AS j
JOIN msdb.dbo.sysjobschedules AS js ON js.job_id = j.job_id
JOIN msdb.dbo.sysschedules AS sch ON sch.schedule_id = js.schedule_id;
Assume Windows auth (-E). Swap to CLI-06 for Entra/SQL auth. Always -b / check exit codes (CLI-01, CLI-07).
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q "EXEC dbo.sp_help_job;"
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q "EXEC dbo.sp_help_jobactivity;"
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q \
"EXEC dbo.sp_start_job @job_name = N'NightlyBackups';"
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q \
"EXEC dbo.sp_stop_job @job_name = N'NightlyBackups';"
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q \
"EXEC dbo.sp_update_job @job_name = N'NightlyBackups', @enabled = 0;"
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q \
"EXEC dbo.sp_help_jobhistory @job_name = N'NightlyBackups', @mode = N'FULL';"
$q = @"
SET NOCOUNT ON;
SELECT j.name AS JobName, j.enabled AS IsEnabled,
SUSER_SNAME(j.owner_sid) AS OwnerName,
j.date_modified AS DateModified, SYSUTCDATETIME() AS CollectedUtc
FROM msdb.dbo.sysjobs AS j
ORDER BY j.name;
"@
Invoke-Sqlcmd -ServerInstance $env:SQL_SERVER -Database msdb -TrustServerCertificate -Query $q |
Export-Csv ".\staging\jobs_$($env:SQL_SERVER -replace '\\','_').csv" -NoTypeInformation
$q = @"
SET NOCOUNT ON;
SELECT j.name AS JobName, a.start_execution_date AS StartExecutionDate,
a.last_executed_step_id AS LastStepId
FROM msdb.dbo.sysjobs AS j
JOIN msdb.dbo.sysjobactivity AS a ON a.job_id = j.job_id
WHERE a.session_id = (SELECT MAX(session_id) FROM msdb.dbo.sysjobactivity)
AND a.start_execution_date IS NOT NULL
AND a.stop_execution_date IS NULL;
"@
Invoke-Sqlcmd -ServerInstance $env:SQL_SERVER -Database msdb -TrustServerCertificate -Query $q |
ConvertTo-Json -Depth 3 |
Set-Content ".\running-jobs-$($env:SQL_SERVER -replace '\\','_').json" -Encoding UTF8
$q = @"
SET NOCOUNT ON;
SELECT j.name AS JobName, h.step_name AS StepName, h.run_status AS RunStatus,
DATETIMEFROMPARTS(
h.run_date/10000, h.run_date%10000/100, h.run_date%100,
h.run_time/10000, h.run_time%10000/100, h.run_time%100, 0) AS RunStart,
h.message AS Message
FROM msdb.dbo.sysjobhistory AS h
JOIN msdb.dbo.sysjobs AS j ON j.job_id = h.job_id
WHERE h.run_status = 0
AND DATETIMEFROMPARTS(
h.run_date/10000, h.run_date%10000/100, h.run_date%100,
h.run_time/10000, h.run_time%10000/100, h.run_time%100, 0)
>= DATEADD(DAY, -7, SYSUTCDATETIME());
"@
Invoke-Sqlcmd -ServerInstance $env:SQL_SERVER -Database msdb -TrustServerCertificate -Query $q |
ConvertTo-Json -Depth 3 |
Set-Content ".\job-failures-7d.json" -Encoding UTF8
sqlcmd -S "$SQL_SERVER" -E -d msdb -b -Q \
"EXEC dbo.sp_purge_jobhistory @job_name = N'NightlyBackups', @oldest_date = '2025-01-01';"
| Doc | Covers |
|---|---|
| SQL-CLI-09 | Overview: call patterns, procs vs DMVs, curated catalog (§3.4 Agent) |
| SQL-CLI-09a | sp_configure / RECONFIGURE; instance helpers |
| SQL-CLI-09b | Object help, space, text, rename; multi-set trap |
| SQL-CLI-09c | Logins / users / roles / orphans / permissions reporting |
| 09d (this) | Agent control plane vs msdb tables; roles; recipes |
| 09e | Surface area / xp_cmdshell / Ole Automation — policy & safer alternatives |
Related stack: SQL-CLI-01 (calling / -b / -d), SQL-CLI-07 (Collect→Stage→Blazor).
Learn anchors (ver17): SQL Server Agent Tables, SQL Server Agent Fixed Database Roles, sp_help_job, sp_help_jobactivity, sp_help_jobhistory, sp_help_jobstep, sp_help_schedule, sp_start_job, sp_stop_job, sp_update_job, sp_purge_jobhistory, dbo.sysjobs, dbo.sysjobsteps, dbo.sysjobhistory, dbo.sysjobactivity, dbo.sysschedules, dbo.sysjobschedules, Job automation with SQL Agent Jobs (Azure SQL Managed Instance), Elastic Jobs overview (Azure SQL Database).