Blog Post

Decode SQL Server Job Schedules

,

As once a data architect, I cannot help but admiring and appreciating the data model behind the sql server job schedules. The core design is actually just one table, msdb.dbo.sysschedules, but the design beauty/elegance of the table is that it can support many different types of schedule with a rich set of variations for each, for example,  a schedule can be: on the weekdays of 2nd week of every 3 months, run every 30 min between 1:00am to 3:00pm starting at 1:15am.

Recently, I have been working on a task:

With two parameters, @Start_DateTime and @End_DateTime, find the all/specific scheduled jobs that will run during this time window, and list the exact scheduled run time for each job.

I initially try to google out a solution, but I simply cannot find out one that works to my needs, the closest one I can google out is this “Keep Track of all your jobs schedules”, but it has some deficiencies that cannot generate correct result, for example, if the job schedule is daily running every hour from 8:00am to 8:00pm, and you want to find out the job schedules between 10:05am to 1:05pm, the result will give you all schedules from 8:00am to 8:00pm instead of just between 10:05am and 01:05pm, and sometimes, it simply did not give out any job schedules.

So I start to work on my own, I have to say it is a fun / rewarding journey, my 1st version is about 1,300 lines with complicated if/else/case when, and my 2nd version is about 800 lines and my 3rd (the current) version is about 400 lines and I have tested in various weird schedules (in sql server 2012 and a few sql server 2008 cases as well), like a schedule of the 2nd Tuesday/Friday of every 3 months, and also a job with multiple daily / weekly schedules etc, and I always get the correct results.

I will give a few explanations regarding the code, so it is better to understand.

1. The @Start_DateTime and @End_DateTime should be in future, i.e. they should > getdate().

2. I need to rely on next_run_date/next_run_time of msdb.dbo.sysjobschedules, which will be refreshed every 20 min as indicated on BOL. This means if you create a new job with a new schedule, the next_run_date / next_run_time columns will not be populated immediately and if so, my script may not return correct results.

3. The most difficult part is about the calculation of “the <nth> <weekday> of every <X> months”, the difficulty here is that weekday as in datepart(weekday, <@date>) is impacted by the @@DateFirst setting (language dependent). However, I was lucky to find a language-independent solution in Itzik Ben-Gan’s series articles (DateTime Calculations).

if object_id('dbo.uspCheckJobFutureSchedule', 'P') is not null

    drop proc dbo.uspCheckJobFutureSchedule;

go

create proc dbo.uspCheckJobFutureSchedule

  @Start_DT datetime

, @End_DT datetime

, @JobName varchar(128)=''

, @ScheduleName varchar(128)=''

as

begin --proc

    if object_id('tempdb.dbo.#tmp', 'U') is not null

        drop table #tmp;

    if object_id('tempdb.dbo.#job', 'U') is not null

        drop table dbo.#job;

    set nocount on;

    if object_id('tempdb..#tblCalendar', 'U') is not null

        drop table #tblCalendar;

    create table #tblCalendar (dt smalldatetime, [year] smallint, [month] tinyint, [day] tinyint, [weekday] tinyint, [week_of_month] tinyint)

    declare @tbl table (dt smalldatetime, [year] smallint, [month] tinyint, [day] tinyint, [weekday] tinyint, [week_of_month] tinyint);

    --declare @start_dt datetime, @end_dt datetime;

    declare @diff_days int, @calc_start_dt datetime, @i int, @inner_loop_dt datetime;

    declare @current_dt datetime, @current_week datetime;

    declare @dw int;

    declare @calc_end_dt datetime;

    declare @min_end_date datetime, @max_start_date datetime, @dt datetime;

    declare @next_run_date int, @next_run_time int, @next_run_dt datetime;

    --select @start_dt = '2014-02-21', @end_dt = '2014-04-28';

    -- first get all jobs that are scheduled, with job name and the schedule details 

    create table #tmp (

      schedule_id int

    , jobname sysname

    ,schedule_name    sysname

    ,freq_type    int

    ,freq_interval    int

    ,freq_subday_type    int

    ,freq_subday_interval    int

    ,freq_relative_interval    int

    ,freq_recurrence_factor    int

    ,active_start_date    char(8)

    ,active_end_date    char(8)

    ,active_start_time    char(8)

    ,active_end_time    char(8)

    ,active_start_date_int   int

    ,active_end_date_int int

    ,active_start_time_int int

    ,active_end_time_int   int

    ,next_run_date int

    ,next_run_time int

    )

    insert into #tmp (schedule_id, jobname, schedule_name, freq_type

    ,freq_interval

    ,freq_subday_type

    ,freq_subday_interval

    ,freq_relative_interval

    ,freq_recurrence_factor

    ,active_start_date

    ,active_end_date

    ,active_start_time

    ,active_end_time

    ,active_start_date_int

    ,active_end_date_int

    ,active_start_time_int

    ,active_end_time_int

    ,next_run_date

    ,next_run_time

    )

    select s.schedule_id, j.name, s.name, s.freq_type

    ,s.freq_interval

    ,s.freq_subday_type

    ,s.freq_subday_interval

    ,s.freq_relative_interval

    ,s.freq_recurrence_factor

    ,cast(s.active_start_date    as char(8))

    ,cast(s.active_end_date as char(8))

    ,substring(right('00000' + cast(s.active_start_time as varchar(6)), 6),1,2)+':'+    substring(right('00000' + cast(s.active_start_time as varchar(6)), 6),3,2)+':'+substring(right('00000' + cast(s.active_start_time as varchar(6)), 6),5,2)

    ,substring(right('00000' + cast(s.active_end_time as varchar(6)), 6),1,2)+':'+    substring(right('00000' + cast(s.active_end_time as varchar(6)), 6),3,2)+':'+substring(right('00000' + cast(s.active_end_time as varchar(6)), 6),5,2)

    , s.active_start_date

    , s.active_end_date

    , s.active_start_time

    , s.active_end_time

    , js.next_run_date

    , js.next_run_time

    from msdb.dbo.sysschedules s

    inner join msdb.dbo.sysjobschedules js

    on js.schedule_id = s.schedule_id

    inner join msdb.dbo.sysjobs j

    on j.job_id = js.job_id

    where s.enabled=1

    and j.enabled =1

    and js.next_run_date > 0

    and s.name = case @ScheduleName when '' then s.name else @ScheduleName end

    and j.name= case @JobName when '' then j.name else @JobName end

    -- delete all job schedules that are beyond the boundary of the @start_dt and @end_dt

    -- for example, if a job is scheduled to be run in Jan, 2020, and if @start_dt='2018-01-01' and @end_dt = '2018-02-01', 

    -- then we do not need to consider this job schedule (i.e. Jan 2020)

    delete from #tmp

    where convert(datetime, active_start_date +' '+ active_start_time ) > @end_dt

    or convert(datetime, active_end_date +' '+ active_end_time ) < @start_dt;

    -- create a temp table to hold those jobs that will be started between @start_dt and @end_dt window

    create table dbo.#job (

      ServerName sysname default @@servername

    , jobname sysname

    , ScheduleName sysname

    , run_datetime datetime

    );

    declare @schedule_id int, @schedule_name sysname, @active_start_dt datetime, @active_end_dt datetime;

    declare @freq_type int, @freq_interval  int

    , @freq_subday_type   int

    , @freq_subday_interval  int

    , @freq_relative_interval  int

    , @freq_recurrence_factor  int

    , @active_start_time_int int

    , @active_end_time_int int;

    declare curSch cursor for

    select distinct schedule_id, schedule_name, active_start_dt=convert(datetime, active_start_date +' '+ active_start_time )

    , active_end_dt=convert(datetime, active_end_date +' '+ active_end_time )

    , freq_type, freq_interval

    , freq_subday_type

    , freq_subday_interval

    , freq_relative_interval

    , freq_recurrence_factor

    , active_start_time_int

    , active_end_time_int

    , next_run_date

    , next_run_time

    from #tmp;

    open curSch;

    fetch next from curSch into @schedule_id, @schedule_name, @active_start_dt, @active_end_dt

    , @freq_type, @freq_interval

    , @freq_subday_type

    , @freq_subday_interval

    , @freq_relative_interval

    , @freq_recurrence_factor

    , @active_start_time_int

    , @active_end_time_int

    , @next_run_date

    , @next_run_time;

    while @@fetch_status = 0

    begin -- loop

        if @freq_type = 1 -- one time only

        begin -- one time only

            if @active_start_dt between @start_dt and @end_dt

                insert into #job (jobname, ScheduleName, run_datetime)

                select jobname, @schedule_name, convert(datetime, active_start_date +' '+ active_start_time)

                from #tmp

                where schedule_id = @schedule_id

        end -- one time only

        else

        begin -- else not one time only

            select @next_run_dt = convert(datetime, cast(@next_run_date as varchar(10)) +' ' + substring(right('00000' + cast(@next_run_time as varchar(6)), 6),1,2)+':'+    substring(right('00000' + cast(@next_run_time as varchar(6)), 6),3,2)+':'+substring(right('00000' + cast(@next_run_time as varchar(6)), 6),5,2))

            select @max_start_date = case when @next_run_dt >= @start_dt then @next_run_dt else @start_dt end,

            @min_end_date = case when @active_end_dt >= @end_dt then @end_dt else @active_end_dt end;

            select @calc_start_dt = dateadd(month, datediff(month, 0, @max_start_date), 0); -- switch to the month's first day for @max_start_date

            select @calc_end_dt = dateadd(month, datediff(month, 0, @min_end_date)+1, 0)-1; -- witch to the month's last day for @min_end_date

            -- generate a calendar table btw @calc_start_dt and @calc_end_dt

            set @inner_loop_dt = @calc_start_dt

            truncate table #tblCalendar;

            begin tran

            while @inner_loop_dt <= @calc_end_dt

            begin

                insert into #tblCalendar (dt, [year], [month], [day], [weekday], [week_of_month])

                select    @inner_loop_dt, year(@inner_loop_dt), month(@inner_loop_dt), day(@inner_loop_dt), datepart(dw, @inner_loop_dt + @@datefirst -1)

                , datepart(week, @inner_loop_dt) - datepart(week, dateadd(month, datediff(month, 0, @inner_loop_dt), 0)) + 1 ;

                set @inner_loop_dt = @inner_loop_dt + 1;

            end

            commit tran

            delete from @tbl;

        if @freq_type = 4 -- daily 

        begin -- daily

            insert into @tbl (dt)

            select dt from #tblCalendar

            where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

            and datediff(day, @active_start_dt, dt)%@freq_interval = 0;

        end -- daily

        if @freq_type = 8 -- weekly 

        begin -- weekly

            insert into @tbl (dt)

            select dt from #tblCalendar

            where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

            and datediff(week, @next_run_dt, dt)%@freq_recurrence_factor = 0

            and power(2, datepart(dw, dt+@@datefirst-1)%7) & @freq_interval >=1;

        end -- weekly

        if @freq_type = 16

        begin -- monthly, @freq_type = 16

            insert into @tbl (dt)

            select dt from #tblCalendar

            where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

            and datediff(month, @active_start_dt, dt)%@freq_recurrence_factor = 0

            and [day]=@freq_interval;

        end -- monthly, @freq_type = 16

        if @freq_type = 32

        begin -- monthly, @freq_type = 32

            -- find the days that the scheduled jobs will be run (later, we will loop through time to figure out the exact date/time)

            if @freq_relative_interval in (1, 2, 4, 8)

            begin

                if @freq_interval < 8

                begin

                    ; with c as (

                        select dt, [year], [month], [weekday], [day], [week_of_month], rn=row_number() over (partition by year, month order by dt asc)

                        from #tblCalendar

                        where dt between @calc_start_dt and @calc_end_dt

                        and [weekday] = case @freq_interval when 1 then 7 else @freq_interval -1 end

                    )

                    insert into @tbl (dt)

                    select dt from c

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and rn =  log(@freq_relative_interval)/log(2)+1

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                end

                if @freq_interval = 8

                    insert into @tbl

                    select * from #tblCalendar

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and [day] = @freq_relative_interval

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                if @freq_interval=9 -- weekday

                    insert into @tbl

                    select * from #tblCalendar

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and week_of_month =  log(@freq_relative_interval)/log(2)+1

                    and [weekday] <6

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                if @freq_interval=10 -- weekend

                    insert into @tbl

                    select * from #tblCalendar

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and week_of_month =  log(@freq_relative_interval)/log(2)+1

                    and [weekday] in (6, 7)

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

            end

            else -- @freq_relative_interval = 16 -- last

            begin -- @freq_relative_interval = 16

                if @freq_interval < 8

                begin

                    ; with c as (

                        select dt, [year], [month], [weekday], [day], [week_of_month], rn=row_number() over (partition by year, month order by week_of_month desc)

                        from #tblCalendar

                        where dt between @calc_start_dt and @calc_end_dt

                        )

                    insert into @tbl

                    select dt, [year], [month], [weekday], [day], [week_of_month] from c

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and week_of_month =  log(@freq_relative_interval)/log(2)+1

                    and [weekday] = case @freq_interval when 1 then 7 else @freq_interval -1 end

                    and rn=1

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                end

                if @freq_interval = 8

                begin

                    ; with c as (

                        select dt, [year], [month], [weekday], [day], [week_of_month], rn=row_number() over (partition by year, month order by [day] desc)

                        from #tblCalendar

                        where dt between @calc_start_dt and @calc_end_dt

                        )

                    insert into @tbl

                    select dt, [year], [month], [weekday], [day], [week_of_month] from c

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and rn =1

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                end

                if @freq_interval=9 -- weekday

                begin

                    ; with c as (

                        select dt, [year], [month], [weekday], [day], [week_of_month], rn=row_number() over (partition by year, month order by week_of_month desc)

                        from #tblCalendar

                        where dt between @calc_start_dt and @calc_end_dt

                        )

                    insert into @tbl

                    select dt, [year], [month], [weekday], [day], [week_of_month] from c

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and [weekday] <6 and rn=1

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                end

                if @freq_interval=10 -- weekend

                begin

                    ; with c as (

                        select dt, [year], [month], [weekday], [day], [week_of_month], rn=row_number() over (partition by year, month order by week_of_month desc)

                        from #tblCalendar

                        where dt between @calc_start_dt and @calc_end_dt

                        )

                    insert into @tbl

                    select dt, [year], [month], [weekday], [day], [week_of_month] from c

                    where datediff(day, @max_start_date, dt) >=0  and datediff(day, dt, @min_end_date)>=0

                    and [weekday] in (6, 7) and rn=1

                    and datediff(month, @calc_start_dt, dt)%@freq_recurrence_factor = 0;

                end

            end    -- @freq_relative_interval = 16    

        end -- monthly, @freq_type = 32

        if exists (select * from @tbl) -- we do have days that match the schedules, now we need to loop through time to check whether the job will be run

        begin

            declare curT_daily cursor for

            select dt from @tbl;

            open curT_daily;

            fetch next from curT_daily into @dt;

            while @@fetch_status =0

            begin -- while loop

                    set @inner_loop_dt = dateadd(second, @active_start_time_int/10000*3600+@active_start_time_int%10000/100*60+@active_start_time_int%100, @dt);

                    while @inner_loop_dt <= dateadd(second, @active_end_time_int/10000*3600+@active_end_time_int%10000/100*60+@active_end_time_int%100, @dt)

                    begin

                        if @inner_loop_dt between @max_start_date and @min_end_date

                            insert into #job (jobname, ScheduleName, run_datetime)

                            select jobname, @Schedule_Name, @inner_loop_dt

                            from #tmp

                            where schedule_id = @schedule_id

                        if @freq_subday_type = 1

                        begin

                            set @inner_loop_dt = @inner_loop_dt + 1;

                            continue;

                        end

                        if @freq_subday_type = 2

                            set @inner_loop_dt = dateadd(second, @freq_subday_interval, @inner_loop_dt);

                        else if @freq_subday_type = 4

                                set @inner_loop_dt = dateadd(MINUTE, @freq_subday_interval, @inner_loop_dt);

                            else

                                set @inner_loop_dt = dateadd(HOUR, @freq_subday_interval, @inner_loop_dt);

                    end

                fetch next from curT_daily into @dt;

            end -- while loop

            close curT_daily;

            deallocate curT_daily;

        end

        end -- -- else not one time only

        fetch next from curSch into @schedule_id, @schedule_name, @active_start_dt, @active_end_dt

        , @freq_type, @freq_interval

        , @freq_subday_type

        , @freq_subday_interval

        , @freq_relative_interval

        , @freq_recurrence_factor

        , @active_start_time_int

        , @active_end_time_int

        , @next_run_date

        , @next_run_time;

    end -- loop

    close curSch;

    deallocate curSch;

    select ServerName, JobName, ScheduleName, Run_DateTime from #job

end -- proc

go

So how can we use this code in a more innovative way other than finding the future job scheduled times? Here are a few user cases:

1. You need to check whether there is any job not running at an expected datetime.

A job can miss its execution because the last running is past the current schedule time. For example, if a job is scheduled to run every 10 min and usually it takes only 5 min to finish the run, now if the job (run at 10:00am) suddenly runs 12 min, then the expected 10:10am run will not start at all. There are some other weird incidents that can cause the job not running, like failover happened and caused sql agent service not started during the time that the job is scheduled to run etc.

2. You need to make sure a job indeed runs.

I have seen in some environments, some jobs are configured with a completion notification, i.e. whether the job succeeds or fails, an email alert will be sent. But 99% of the time, the job succeeds, yet, as a DBA, I still need to bear the pain to receive and delete a job saying “The job xxxx completes”.  What I really want is if the job fails, send me an alert, otherwise, do not send me anything. But I need to make sure the job indeed runs. The job schedule can be something weird, like the every 3 months, the 3rd weekend days from 10:00am to 10:00pm every 2 hours.

The solution: Every  1st day of each month (or any day you like), I will run the script and dump the result to a table, let’s call it tblJobFutureSchedule, and then at the end of every day (or every hour/week to your own choice), I will run a check script to compare the result in  the msdb.dbo.sysjobhistory with tblJobFutureSchedule to make sure (1) there is no missing job execution and (2) the job indeed runs. If issue found, an alert will be sent, and we can do further investigation.

3. If there is a scheduled server patching work (implying that there are multiple reboots during the patching process), we can use the code to generate a list of jobs that will be scheduled to run during the patching window, so we can disable these jobs before the patching starts and later enable these jobs after the patching window.

Attachment:uspCheckJobFutureSchedule

Rate

You rated this post out of 5. Change rating

Share

Share

Rate

You rated this post out of 5. Change rating