Why Resource Governor Ignores Your SQL Server Agent Job Owner

You did everything right. Resource Governor is configured, you have a workload group that caps CPU and memory so one runaway job cannot starve everything else, and you have a classifier function that reads the user name and routes that job into the throttled group. You set the offending job’s owner to the login the classifier is looking for, you kick it off, and you check which group it landed in. It is in default. You change the step’s “Run As” user instead. Still default. You stare at the classifier function, which is four lines long and obviously correct, and you start to wonder whether Resource Governor is even on.
It is on. The classifier is fine. The problem is a single fact about when classification happens, and once you know it, the whole thing stops being mysterious. I worked through this on a Database Administrators Stack Exchange question years ago,[1] and it comes up often enough that it deserves a proper writeup with a test bed you can run yourself.
The One Sentence That Explains It
The Resource Governor classifier function runs exactly once per session, during the login process, and never again.
That is the whole thing. Classification is a login-time event. Microsoft documents the login sequence explicitly: first the login is authenticated, then any logon triggers fire, and then classification runs and assigns the session to a workload group for the rest of its life.[2] Whatever the classifier decides at that moment is what the session is stuck with until it disconnects.
So the question that actually matters is not “who owns the job?” It is “which login does SQL Server Agent use to connect to the instance?” And the answer to that is not the job owner.
How SQL Server Agent Actually Connects
SQL Server Agent is a separate process. When it needs to run a job, it logs in to the database engine using its own service account, the account the SQL Server Agent service runs under. That login is when classification happens, and at that instant the only identity the engine sees is the Agent service account.
The job owner and the step’s “Run As” setting do change the security context the job step runs under, but they do it after login, with EXECUTE AS. When a job is owned by a login that is not a member of sysadmin, Agent runs the step with an EXECUTE AS LOGIN so the work happens in the owner’s context. A T-SQL step’s “Run As” (a database user) layers an EXECUTE AS USER on top of that. Both are impersonation, and impersonation happens on a connection that is already open and already classified.
EXECUTE AS does not re-run the classifier. There is no event that re-evaluates the workload group mid-session. So no matter how you set the owner or the Run As user, the session was classified the moment Agent logged in as the service account, and it stays in whatever group the service account maps to.
That is why the four-line classifier “obviously” routing by user name does nothing for Agent jobs. It is reading SUSER_SNAME(), which at classification time is the Agent service account, not the job owner you carefully set.
Proving It With a Test Bed
This is the kind of claim that is much more convincing when you watch it happen, so here is a self-contained test bed. A word of warning first: this changes the instance-wide Resource Governor configuration, so run it on a test instance only, never on production. There is a cleanup script at the end that puts everything back.
First, create a classifier function and a few workload groups on a throwaway resource pool. Note that the classifier routes three different identities: the Agent service account, a test login, and a test user. Replace DOMAIN\USER with the account your SQL Server Agent service actually runs under.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
USE [master]; SET NOCOUNT ON; GO CREATE FUNCTION [dbo].[fnDummyClassifier]() RETURNS sysname WITH SCHEMABINDING AS BEGIN DECLARE @group_name sysname = NULL; IF SUSER_SNAME() = N'DOMAIN\USER' SET @group_name = N'AgentServiceAccountGroup'; IF SUSER_SNAME() = N'ResourceGovernorTestLogin' SET @group_name = N'ResourceGovernorTestLoginGroup'; IF SUSER_SNAME() = N'ResourceGovernorTestUser' SET @group_name = N'ResourceGovernorTestUserGroup'; IF @group_name IS NULL SET @group_name = N'default'; RETURN @group_name; END; GO CREATE RESOURCE POOL [TestPool] WITH (MAX_CPU_PERCENT = 10); CREATE WORKLOAD GROUP [AgentServiceAccountGroup] WITH ( group_max_requests = 0 , importance = Medium , request_max_cpu_time_sec = 0 , request_max_memory_grant_percent = 25 , request_memory_grant_timeout_sec = 0 , max_dop = 0 ) USING [TestPool]; CREATE WORKLOAD GROUP [ResourceGovernorTestLoginGroup] WITH ( group_max_requests = 0 , importance = Medium , request_max_cpu_time_sec = 0 , request_max_memory_grant_percent = 25 , request_memory_grant_timeout_sec = 0 , max_dop = 0 ) USING [TestPool]; CREATE WORKLOAD GROUP [ResourceGovernorTestUserGroup] WITH ( group_max_requests = 0 , importance = Medium , request_max_cpu_time_sec = 0 , request_max_memory_grant_percent = 25 , request_memory_grant_timeout_sec = 0 , max_dop = 0 ) USING [TestPool]; ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = [dbo].[fnDummyClassifier]); ALTER RESOURCE GOVERNOR RECONFIGURE; GO |
Next, create a login and let it see server state so the job can report which group it landed in. Use whatever throwaway password your policy allows.
|
1 2 3 4 5 6 7 8 |
CREATE LOGIN [ResourceGovernorTestLogin] WITH PASSWORD = N'a-throwaway-test-password' , DEFAULT_LANGUAGE = us_english , CHECK_EXPIRATION = OFF , CHECK_POLICY = OFF; GRANT VIEW SERVER STATE TO [ResourceGovernorTestLogin]; GO |
Now the first experiment: a job whose owner is that test login. Because the login is not a member of sysadmin, Agent will run the step in the login’s context. The step reports back who it is and which workload group it is in.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
DECLARE @job_id uniqueidentifier; EXEC [msdb].[dbo].[sp_add_job] @job_name = N'TestResourceGovernorJob' , @enabled = 1 , @description = N'Tests resource governor classification' , @start_step_id = 1 , @owner_login_name = N'ResourceGovernorTestLogin' , @job_id = @job_id OUTPUT; EXEC [msdb].[dbo].[sp_add_jobstep] @job_id = @job_id , @step_id = 1 , @step_name = N'Step1' , @subsystem = N'TSQL' , @command = N' SELECT UserName = CONVERT(nvarchar(30), SUSER_SNAME()) , [SYSTEM_USER] = CONVERT(nvarchar(30), SYSTEM_USER) , [SESSION_USER] = CONVERT(nvarchar(30), SESSION_USER) , [ORIGINAL_LOGIN]= CONVERT(nvarchar(30), ORIGINAL_LOGIN()) , WorkloadGroup = CONVERT(nvarchar(30), wg.name) FROM sys.dm_exec_requests AS der INNER JOIN sys.dm_resource_governor_workload_groups AS wg ON der.group_id = wg.group_id WHERE der.session_id = @@SPID;' , @flags = 4 /* write step output to the job history table */ , @on_success_action = 1; EXEC [msdb].[dbo].[sp_add_jobserver] @job_id = @job_id, @server_name = N'(LOCAL)'; EXEC [msdb].[dbo].[sp_start_job] @job_id = @job_id; GO WAITFOR DELAY N'00:00:01'; DECLARE @msg nvarchar(max); SELECT @msg = sjh.message FROM [msdb].[dbo].[sysjobhistory] AS sjh INNER JOIN [msdb].[dbo].[sysjobs] AS sj ON sjh.job_id = sj.job_id WHERE sj.name = N'TestResourceGovernorJob' AND sjh.step_id = 1; PRINT (N''); PRINT (@msg); PRINT (N''); EXEC [msdb].[dbo].[sp_delete_job] @job_name = N'TestResourceGovernorJob'; GO |
The interesting part of the step output:
|
1 2 3 |
UserName SESSION_USER ORIGINAL_LOGIN WorkloadGroup ------------------------ ------------- --------------- ------------------------ ResourceGovernorTestLogin guest DOMAIN\USER AgentServiceAccountGroup |
Read that carefully. SUSER_SNAME() reports ResourceGovernorTestLogin, because by the time the step runs, Agent has impersonated the owner. But ORIGINAL_LOGIN() is DOMAIN\USER, the Agent service account that actually opened the connection, and the workload group is AgentServiceAccountGroup. The session was classified as the service account at login, and the later impersonation did not change that.
The second experiment uses the step’s “Run As” database user instead of relying on the owner. This adds an EXECUTE AS USER, so SESSION_USER changes, but nothing about the login does.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
CREATE USER [ResourceGovernorTestUser] FOR LOGIN [ResourceGovernorTestLogin]; DECLARE @job_id uniqueidentifier; EXEC [msdb].[dbo].[sp_add_job] @job_name = N'TestResourceGovernorJob' , @enabled = 1 , @description = N'Tests resource governor classification' , @start_step_id = 1 , @owner_login_name = N'ResourceGovernorTestLogin' , @job_id = @job_id OUTPUT; EXEC [msdb].[dbo].[sp_add_jobstep] @job_id = @job_id , @step_id = 1 , @step_name = N'Step1' , @subsystem = N'TSQL' , @database_name = N'master' , @database_user_name = N'ResourceGovernorTestUser' , @command = N' SELECT UserName = CONVERT(nvarchar(30), SUSER_SNAME()) , [SYSTEM_USER] = CONVERT(nvarchar(30), SYSTEM_USER) , [SESSION_USER] = CONVERT(nvarchar(30), SESSION_USER) , [ORIGINAL_LOGIN]= CONVERT(nvarchar(30), ORIGINAL_LOGIN()) , WorkloadGroup = CONVERT(nvarchar(30), wg.name) FROM sys.dm_exec_requests AS der INNER JOIN sys.dm_resource_governor_workload_groups AS wg ON der.group_id = wg.group_id WHERE der.session_id = @@SPID;' , @flags = 4 , @on_success_action = 1; EXEC [msdb].[dbo].[sp_add_jobserver] @job_id = @job_id, @server_name = N'(LOCAL)'; EXEC [msdb].[dbo].[sp_start_job] @job_id = @job_id; GO WAITFOR DELAY N'00:00:01'; DECLARE @msg nvarchar(max); SELECT @msg = sjh.message FROM [msdb].[dbo].[sysjobhistory] AS sjh INNER JOIN [msdb].[dbo].[sysjobs] AS sj ON sjh.job_id = sj.job_id WHERE sj.name = N'TestResourceGovernorJob' AND sjh.step_id = 1; PRINT (N''); PRINT (@msg); PRINT (N''); EXEC [msdb].[dbo].[sp_delete_job] @job_name = N'TestResourceGovernorJob'; DROP USER [ResourceGovernorTestUser]; GO |
This time SESSION_USER is the impersonated user, but the workload group has not budged:
|
1 2 3 |
UserName SESSION_USER ORIGINAL_LOGIN WorkloadGroup ------------------------ ------------------------ --------------- ------------------------ ResourceGovernorTestLogin ResourceGovernorTestUser DOMAIN\USER AgentServiceAccountGroup |
Two different ways of changing the job’s identity, and both land in AgentServiceAccountGroup every time, because the only identity that ever mattered for classification was the service account that logged in.
What Actually Works
Once you accept that the service account is the identity at classification time, the fix is direct: classify on the service account. If you want all SQL Server Agent jobs to run inside a capped workload group, put the Agent service account into its own group in the classifier, exactly as the test bed does. Every job, regardless of owner or Run As, will land there.
That is the blunt instrument, and often it is exactly what you want: “keep all of Agent’s work inside this CPU and memory budget so nightly maintenance never crowds out the application.” It is one line in the classifier and it is robust, because the service account is genuinely the connecting identity and is not something a job step can talk its way out of.
From the Test Bed to a Real Configuration
The toy classifier above proves the point, but it is worth seeing what this looks like in a configuration that actually ships. Here is the shape of a real deployment script, sanitized, that governs SQL Server Agent across several environments. The classifier routes every account Agent connects under into one workload group, and that group sits on a pool with a real budget.
One prerequisite before any of this: Resource Governor is an Enterprise-tier feature. Through SQL Server 2022 it is available only in Enterprise and Developer editions; starting with SQL Server 2025 it also works in Standard and Standard Developer editions.[4] A deployment script should check for that rather than failing halfway, which is why the real one gates on SERVERPROPERTY('EngineEdition') before touching anything.
The classifier itself is the same idea as the test bed, just driven by a CASE over every service account in the estate. Notice that a single environment usually has more than one account to list, because the Database Engine and the Agent service can run under different identities, and you generally want both governed together:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
CREATE OR ALTER FUNCTION [dbo].[resource_governor_classifier_login]() RETURNS sysname WITH SCHEMABINDING AS BEGIN DECLARE @workload_group sysname; SELECT @workload_group = CASE SUSER_SNAME() /* SQL Server Agent + Database Engine service accounts, all environments */ WHEN N'CONTOSO\svc-dev-sqlagent' THEN N'sql_server_agent_group' WHEN N'CONTOSO\svc-prod-sqlagent' THEN N'sql_server_agent_group' WHEN N'CONTOSO\svc-dev-sqlengine' THEN N'sql_server_agent_group' WHEN N'CONTOSO\svc-prod-sqlengine' THEN N'sql_server_agent_group' /* Everyone else */ ELSE N'default' END; RETURN @workload_group; END; GO |
The pool is where you actually spend the budget. These numbers govern CPU three ways (a guaranteed minimum under contention, a soft maximum, and a hard cap), reserve a slice of memory, and cap I/O so a runaway maintenance job cannot saturate the disk:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
CREATE RESOURCE POOL [sql_server_agent_pool] WITH ( MIN_CPU_PERCENT = 10 /* guaranteed CPU under contention */ , MAX_CPU_PERCENT = 50 /* soft cap when others are idle */ , CAP_CPU_PERCENT = 70 /* hard ceiling, never exceeded */ , MIN_MEMORY_PERCENT = 5 , MAX_MEMORY_PERCENT = 25 , MIN_IOPS_PER_VOLUME = 0 , MAX_IOPS_PER_VOLUME = 5000 ); CREATE WORKLOAD GROUP [sql_server_agent_group] WITH ( IMPORTANCE = MEDIUM , REQUEST_MAX_MEMORY_GRANT_PERCENT = 50 , MAX_DOP = 2 , GROUP_MAX_REQUESTS = 0 ) USING [sql_server_agent_pool]; ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = [dbo].[resource_governor_classifier_login]); ALTER RESOURCE GOVERNOR RECONFIGURE; GO |
The difference between a hard CAP_CPU_PERCENT and a soft MAX_CPU_PERCENT is worth internalizing. The soft maximum only applies when there is contention, so an Agent job can still use more CPU when the server is otherwise idle; the hard cap is enforced no matter what. For maintenance work that is exactly the right combination: let it run fast at 3 a.m. when nothing else is happening, but never let it climb past the ceiling when the daytime workload shows up. And because the classifier routes on the service account, every job lands in this group automatically, with no per-job configuration to forget.
Finer Control: Classify by Application Name
Routing every Agent job into one group is sometimes too coarse. Maybe you want only the index-rebuild job throttled, not the lightweight log-backup job. Because the job owner is invisible at login, you need something that is visible at login and that differs between jobs. The application name is the usual answer.
When SQL Server Agent runs a T-SQL subsystem step, it connects with an application name that identifies the job and step, in the form SQLAgent - TSQL JobStep (Job 0x{hex job id} : Step {n}). That string is available to the classifier through APP_NAME(), which Microsoft lists as a supported classification input.[2] So you can route a specific job by matching on its hex job id:
|
1 2 |
IF APP_NAME() LIKE N'SQLAgent - TSQL JobStep (Job 0x3F2A...%' SET @group_name = N'MaintenanceGroup'; |
This is more surgical, but be honest about its limits. The application name is only this predictable for the T-SQL subsystem; CmdExec, PowerShell, SSIS, and other step types connect differently or not through this path at all. More importantly, Microsoft warns that an application can put any string it likes in the application name,[2] so this is a routing convenience, not a security boundary. And the hex job id changes if you drop and recreate the job, so a classifier pinned to it needs maintenance. For “throttle everything Agent does,” classify the service account. For “throttle this one job,” APP_NAME() is the tool, with eyes open.
Putting Everything Back
When you are done experimenting, this removes the classifier, drops the groups and pool, and cleans up the login. Removing the classifier requires setting it to NULL and reconfiguring before the function can be dropped.[3]
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = NULL); ALTER RESOURCE GOVERNOR RECONFIGURE; GO DROP WORKLOAD GROUP [AgentServiceAccountGroup]; DROP WORKLOAD GROUP [ResourceGovernorTestUserGroup]; DROP WORKLOAD GROUP [ResourceGovernorTestLoginGroup]; DROP RESOURCE POOL [TestPool]; ALTER RESOURCE GOVERNOR RECONFIGURE; GO DROP FUNCTION [dbo].[fnDummyClassifier]; DROP LOGIN [ResourceGovernorTestLogin]; GO |
One Safety Note Before You Try This
A classifier function is a great way to lock yourself out of an instance. If the function is slow, every login waits on it, and if it routes you somewhere with no resources, you may not be able to connect to fix it. Keep the function trivial, avoid data access inside it, and know your escape hatch: the Dedicated Administrator Connection is not subject to classification, so a sysadmin can always get in over the DAC to set CLASSIFIER_FUNCTION = NULL and recover.[2] Test classifier changes on an instance you do not mind breaking first.
The Takeaway
The reason your job owner and Run As settings seem to be ignored is that they are, as far as Resource Governor is concerned. Classification is a login-time decision, SQL Server Agent logs in as its service account, and the impersonation that gives a job step its owner or Run As identity happens afterward without ever re-classifying the session. To govern Agent jobs, classify on the thing that is actually present at login: the service account for a blanket cap, or the application name when you need to single out one job.
Have you put SQL Server Agent under Resource Governor, or been bitten by this login-time-classification surprise somewhere else? I would like to hear how you handled it. Find me on Bluesky or LinkedIn.
References
- Using Resource Governor with SQL Agent jobs – my answer on Database Administrators Stack Exchange. The original test bed and the observation that Agent classification keys off the service account, not the job owner. ↩
- Resource Governor Classifier Function – Microsoft Learn. The login process order (authenticate, logon trigger, classify), the per-session-at-login behavior, the
SUSER_SNAME()andAPP_NAME()classification examples and the warning that application names are caller-supplied, and the DAC recovery path. ↩ - ALTER RESOURCE GOVERNOR (Transact-SQL) – Microsoft Learn. Assigning a classifier with
CLASSIFIER_FUNCTION, and whyRECONFIGUREis required before a change takes effect. ↩ - Resource Governor – Microsoft Learn. Overview of resource pools and workload groups, and the edition support (Enterprise and Developer through SQL Server 2022; Standard and Standard Developer added in SQL Server 2025). ↩