16

I've been working on code in T-SQL to add new schedules to a SQL Agent job using the sp_add_jobschedule proc in the msdb database. When I add a new schedule (typically a run-once at a specific date/time) and immediately look at the values in sysjobschedules and sysschedules, I can see that the new schedule has been added and is tied to the job_id for my SQL Agent job. However, the values for next_run_date and next_run_time have 0 in them. When I come back and look at them again in 2 or 3 minutes, they still show 0's in them. However when I come back another 5 or 10 minutes later, it now correctly shows the date and time values corresponding to the next scheduled run.

So my questions are:

  • How often do these values get updated?
  • What process is it that updates these values?
  • If I were to add a schedule that was, say, 1 minute in the future, does that mean that the job will not run since the next_run_date/time haven't been updated yet?

Example of the code I use to add a new schedule:

exec msdb.dbo.sp_add_jobschedule @job_id = @jobID
                    , @name = @JobName
                    , @enabled = 1
                    , @freq_type = 1
                    , @freq_interval = 0
                    , @freq_subday_type = 0
                    , @freq_subday_interval = 0
                    , @freq_relative_interval = 0
                    , @freq_recurrence_factor = 0
                    , @active_start_date = @ScheduleRunDate
                    , @active_end_date = 99991231
                    , @active_start_time = @ScheduleRunTime
                    , @active_end_time = 235959

where @jobID is binary(16) that holds the job_id of the job in question, @ScheduleRunDate and @ScheduleRunTime are INTs with the date and time respectively.

Aaron Bertrand
  • 181,950
  • 28
  • 405
  • 624
BBlake
  • 532
  • 2
  • 5
  • 11

3 Answers3

23

Short Answer

It looks like the data in msdb.dbo.sysjobschedules is updated by a background thread in SQL Agent, identified as SQLAgent - Schedule Saver, every 20 minutes (or less frequently, if xp_sqlagent_notify has not been called and no jobs have run in the meantime).

For more accurate information, look at next_scheduled_run_date in msdb.dbo.sysjobactivity. This is updated in real-time any time a job is changed or a job has run. As an added bonus, the sysjobactivity stores the data the right way (as a datetime column), making it a lot easier to work with than those stupid INTs.

That's the short answer:

It could be up to 20 minutes before sysjobschedules reflects the truth; however, sysjobactivity will always be up to date. If you want a lot more details about this, or how I figured it out...


Long Answer

If you care to follow the rabbit for a moment, when you call sp_add_jobschedule, this chain of events is set into motion:

msdb.dbo.sp_add_jobschedule == calls ==> msdb.dbo.sp_add_schedule
                                         msdb.dbo.sp_attach_schedule

msdb.dbo.sp_attach_schedule == calls ==> msdb.dbo.sp_sqlagent_notify

msdb.dbo.sp_sqlagent_notify == calls ==> msdb.dbo.xp_sqlagent_notify

Now, we can't chase the rabbit any further, because we can't really peek into what xp_sqlagent_notify does. But I think we can presume that this extended procedure interacts with the Agent service and tells it that there has been a change to this specific job and schedule. By running a server-side trace we can see that, immediately, the following dynamic SQL is called by SQL Agent:

exec sp_executesql N'DECLARE @nextScheduledRunDate DATETIME 
  SET @nextScheduledRunDate = msdb.dbo.agent_datetime(@P1, @P2) 
  UPDATE msdb.dbo.sysjobactivity 
    SET next_scheduled_run_date = @nextScheduledRunDate 
    WHERE session_id = @P3 AND job_id = @P4',
N'@P1 int,@P2 int,@P3 int,@P4 uniqueidentifier',
20120819,181600,5,'36924B24-9706-4FD7-8B3A-1F9F0BECB52C'

It seems that sysjobactivity is updated immediately, and sysjobschedules is only updated on a schedule. If we change the new schedule to be once a day, e.g.

@freq_type=4, 
@freq_interval=1, 
@freq_subday_type=1, 
@freq_subday_interval=0, 
@freq_relative_interval=0, 
@freq_recurrence_factor=1, 

We still see the immediate update to sysjobactivity as above, and then another update after the job is finished. Various updates come from background and other threads within SQL Agent, e.g.:

SQLAgent - Job Manager
SQLAgent - Update job activity
SQLAgent - Job invocation engine
SQLAgent - Schedule Saver

A background thread (the "Schedule Saver" thread) eventually comes around and updates sysjobschedules; from my initial investigation it appears this is every 20 minutes, and only happens if xp_sqlagent_notify has been called due to a change made to a job since the last time it ran (I did not perform any further testing to see what happens if one job has been changed and another has been run, if the "Schedule Saver" thread updates both - I suspect it must, but will leave that as an exercise to the reader).

I am not sure if the 20-minute cycle is offset from when SQL Agent starts, or from midnight, or from something machine-specific. On two different instances on the same physical server, the "Schedule Saver" thread updated sysjobschedules, on both instances, at almost the exact same time - 18:31:37 & 18:51:37 on one, and 18:31:39 & 18:51:39 on the other. I did not start SQL Server Agent at the same time on these servers, but there is a remote possibility that the start times happened to be 20 minutes offset. I doubt it, but I don't have time right now to confirm by restarting Agent on one of them and waiting for more updates to happen.

I know who did it, and when it happened, because I placed a trigger there and captured it, in case I couldn't find it in the trace, or I inadvertently filtered it out.

CREATE TABLE dbo.JobAudit
(
  [action] CHAR(1),
  [table] CHAR(1), 
  hostname SYSNAME NOT NULL DEFAULT HOST_NAME(), 
  appname SYSNAME  NOT NULL DEFAULT PROGRAM_NAME(),
  dt DATETIME2     NOT NULL DEFAULT SYSDATETIME()
);

CREATE TRIGGER dbo.schedule1 ON dbo.sysjobactivity FOR INSERT AS INSERT dbo.JobAudit([action], [table] SELECT 'I', 'A'; GO CREATE TRIGGER dbo.schedule2 ON dbo.sysjobactivity FOR UPDATE AS INSERT dbo.JobAudit([action], [table] SELECT 'U', 'A'; GO CREATE TRIGGER dbo.schedule3 ON dbo.sysjobschedules FOR INSERT AS INSERT dbo.JobAudit([action], [table] SELECT 'I', 'S'; GO CREATE TRIGGER dbo.schedule4 ON dbo.sysjobschedules FOR UPDATE AS INSERT dbo.JobAudit([action], [table] SELECT 'U', 'S'; GO

That said, it is not hard to catch with a standard trace, this one even comes through as non-dynamic DML:

UPDATE msdb.dbo.sysjobschedules 
  SET next_run_date = 20120817, 
      next_run_time = 20000 
 WHERE (job_id = 0xB87B329BFBF7BA40B30D9B27E0B120DE 
 and schedule_id = 8)

If you want to run a more filtered trace to track this behavior over time (e.g. persisting through SQL Agent restarts instead of on-demand), you can run one that has appname = 'SQLAgent - Schedule Saver'...

So I think that if you want to know the next run time immediately, look at sysjobactivity, not sysjobschedules. This table is directly updated by Agent or its background threads ("Update job activity", "Job Manager" and "Job invocation engine") as activity happens or as it is notified by xp_sqlagent_notify.

Be aware, though, that it is very easy to muck up either table - since there are no protections against deleting data from these tables. (So if you decided to clean up, for example, you can easily remove all the rows for that job from the activity table.) In this case I'm not exactly sure how SQL Server Agent gets or saves the next run date. Perhaps worthy of more investigation at a later date when I have some free time...

Aaron Bertrand
  • 181,950
  • 28
  • 405
  • 624
1

msdb.dbo.sp_help_job always appears to return the correct actual next_run_date / next_run_time.

It uses sp_get_composite_job_info, which does the following call to actually retrieve the next_run_date/time.

      IF ((@@microsoftversion / 0x01000000) >= 8) -- SQL Server 8.0 or greater
        INSERT INTO @xp_results
        EXECUTE master.dbo.xp_sqlagent_enum_jobs @can_see_all_running_jobs, @job_owner, @job_id
      ELSE
        INSERT INTO @xp_results
        EXECUTE master.dbo.xp_sqlagent_enum_jobs @can_see_all_running_jobs, @job_owner

Since sysjobschedule appears to be unreliable, I would just use sp_help_job.

Andriy M
  • 23,261
  • 6
  • 60
  • 103
0

I was also wondering when next_run_date and next_run_time are being updated but was not able to find it. If you ever need to calculate these values yourself, I have created an UDF that will do just that.

Code updated May 6th 2022

CREATE FUNCTION [dbo].[fn_schedule_next_run]
(
    @schedule_id int,
    @from_msdb bit = 0
)
RETURNS DATETIME
AS
BEGIN
/*
This will calculate and return the next rundate/time for any schedule in [msdb].[dbo].[sysschedules] (@from_msdb = 1)
or from local table [schedule] (@from_msdb = 0). If there is no next rundate/time, the function returns NULL.

The fields in [msdb].[dbo].[sysschedules] (and the local table [schedule]) are defined as follows (as seen on https://learn.microsoft.com/en-us/sql/relational-databases/system-tables/dbo-sysschedules-transact-sql?view=sql-server-ver15)

freq_type - int

How frequently a job runs for this schedule.

1 = One time only
4 = Daily
8 = Weekly
16 = Monthly
32 = Monthly, relative to freq_interval
64 = Runs when the SQL Server Agent service starts
128 = Runs when the computer is idle

freq_interval - int

Days that the job is executed. Depends on the value of freq_type. 
The default value is 0, which indicates that freq_interval is unused. 
See the table below for the possible values and their effects.

freq_subday_type - int

Units for the freq_subday_interval. The following are the possible 
values and their descriptions.

1 : At the specified time
2 : Seconds
4 : Minutes
8 : Hours

freq_subday_interval - int

Number of freq_subday_type periods to occur between each execution of the job.

freq_relative_interval - int

When freq_interval occurs in each month, if freq_type is 32 (monthly relative). 
Can be one of the following values:

0 = freq_relative_interval is unused
1 = First
2 = Second
4 = Third
8 = Fourth
16 = Last

freq_recurrence_factor - int

Number of weeks or months between the scheduled execution of a job. 
freq_recurrence_factor is used only if freq_type is 8, 16, or 32. 
If this column contains 0, freq_recurrence_factor is unused.

active_start_date - int

Date on which execution of a job can begin. The date is formatted as YYYYMMDD. NULL indicates today's date.

active_end_date - int

Date on which execution of a job can stop. The date is formatted YYYYMMDD.

active_start_time - int

Time on any day between active_start_date and active_end_date that job begins executing. 
Time is formatted HHMMSS, using a 24-hour clock.

active_end_time - int

Time on any day between active_start_date and active_end_date that job stops executing. 
Time is formatted HHMMSS, using a 24-hour clock.

Value of freq_type Effect on freq_interval

1 (once)                    freq_interval is unused (0)
4 (daily)                   Every freq_interval days
8 (weekly)                  freq_interval is one or more of the following:
                                1 = Sunday
                                2 = Monday
                                4 = Tuesday
                                8 = Wednesday
                                16 = Thursday
                                32 = Friday
                                64 = Saturday
16 (monthly)                On the freq_interval day of the month
32 (monthly, relative)      freq_interval is one of the following:
                                1 = Sunday
                                2 = Monday
                                3 = Tuesday
                                4 = Wednesday
                                5 = Thursday
                                6 = Friday
                                7 = Saturday
                                8 = Day
                                9 = Weekday
                                10 = Weekend day
64 (starts when SQL Server Agent service starts)    freq_interval is unused (0)
128 (runs when computer is idle)                    freq_interval is unused (0)


This is more or less the algorithm used to find the next time

if freq_subday_type is not in (0, 1, 2, 4, 8) set time = null if now < active_start_time set time = active_start_time, today = true if now > active_end_time set time = active_start_time, today = false if now >= active_start_time and now <= active_end_time if freq_subday_type = 1 set time = active_start_time, today = false if freq_subday_type = 2 set seconds = seconds since active_start_time until now if (seconds mod freq_subday_interval) = 0 set time = active_start_time + seconds, today = true else set time = active_start_time + freq_subday_interval * ((seconds div freq_subday_interval) + 1), today = true if time > active_end_time set time = active_start_time, today = false if freq_sub_type = 4 set minutes = minutes since active_start_time until now if (minutes mod freq_subday_interval) = 0 if (seconds(now) <= seconds(active_start_time)) set time = active_start_time + minutes, today = true else set time = active_start_time + freq_subday_interval * ((minutes div freq_subday_interval) + 1), today = true if (time > active_end_time) set time = active_start_time, today = false else set time = active_start_time + freq_subday_interval * ((minutes div freq_subday_interval) + 1), today = true if (time > active_end_time) set time = active_start_time, today = false if freq_sub_type = 8 set hours = hours since active_start_time until now if (hours mod freq_subday_interval) = 0 if (minutes(now) < minutes(active_start_time)) or (minutes(now) = minutes(active_start_time) and seconds(now) <= seconds(active_start_time)) set time = active_start_time + hours, today = true else set time = active_start_time + freq_subday_interval * ((hours div freq_subday_interval) + 1), today = true if (time > active_end_time) set time = active_start_time, today = false else set time = active_start_time + freq_subday_interval * ((hours div freq_subday_interval) + 1), today = true if (time > active_end_time) set time = active_start_time, today = false


This is more or less the algorithm used to find the next date

if today > active_end_date then return null set date = active_start_date if freq_type = 1 (once) => if date == today then return date else return null if freq_type = 4 (daily) => if date == today then return date if freq_interval = 0 then return null while (date < today && date < active_end_date) date += freq_interval if date < active_end_date then return date else return null if freq_type = 8 (weekly) => while (date < today && date < active_end_date) date += 7 * freq_recurrence_factor while (date < active_end_date) set next_week_date = date + 8 while (date < next_week_date && weekday(date) not in freq_interval) date += 1 if (date < next_week_date) ==> return date date += next_week_date - 8 + 7 * freq_recurrence_factor end while return null if freq_type = 16 (monthly) => while (day(date) < freq_interval && date < active_end_dateday) date += 1 while (date < today && date < active_end_date) set month(date) = month(date) + freq_recurrence_factor if (date < active_end_date) then return date else return null if freq_type = 32 (monthly relative) => while (date < active_end_date) set year = year(date) set month = month(date) set day = first/second/third/fourth/last (freq_relative_interval) mo/tu/we/th/fr/sa/su/day/weekday/weekendday (freq_interval) of month/year if (date == today) return date set month(date) = month(date) + freq_recurrence_factor ; set year accordingly end return null;

*/

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_date int,
    @active_end_date int,
    @active_start_time int,
    @active_end_time int;

IF (@from_msdb = 1) BEGIN
    SELECT
        @freq_type = [freq_type],
        @freq_interval = [freq_interval],
        @freq_subday_type = [freq_subday_type],
        @freq_subday_interval = [freq_subday_interval],
        @freq_relative_interval = [freq_relative_interval],
        @freq_recurrence_factor = [freq_recurrence_factor],
        @active_start_date = [active_start_date],
        @active_end_date = [active_end_date],
        @active_start_time = [active_start_time],
        @active_end_time = [active_end_time]
    FROM 
        [msdb].[dbo].[sysschedules]
    WHERE
        [schedule_id] = @schedule_id
END
ELSE BEGIN
    SELECT
        @freq_type = [freq_type],
        @freq_interval = [freq_interval],
        @freq_subday_type = [freq_subday_type],
        @freq_subday_interval = [freq_subday_interval],
        @freq_relative_interval = [freq_relative_interval],
        @freq_recurrence_factor = [freq_recurrence_factor],
        @active_start_date = [active_start_date],
        @active_end_date = [active_end_date],
        @active_start_time = [active_start_time],
        @active_end_time = [active_end_time]
    FROM 
        [dbo].[schedule]
    WHERE
        [schedule_id] = @schedule_id
END

-- 'AT TIME ZONE' since SQL Server 2016
DECLARE @CurrentDate DATE = GETUTCDATE() AT TIME ZONE 'UTC' AT TIME ZONE 'W. Europe Standard Time'
DECLARE @CurrentTime TIME = GETUTCDATE() AT TIME ZONE 'UTC' AT TIME ZONE 'W. Europe Standard Time'
DECLARE @CurrentDateAndTime DATETIME = CAST(@CurrentDate AS DATETIME) + CAST(@CurrentTime AS DATETIME)

DECLARE @StartDateAndTime DATETIME = CAST(@CurrentDate AS datetime) + STUFF(STUFF(RIGHT('000000' + CAST(@active_start_time AS VARCHAR), 6), 5, 0, ':'), 3, 0, ':')
DECLARE @EndDateAndTime DATETIME = CAST(@CurrentDate AS datetime) + STUFF(STUFF(RIGHT('000000' + CAST(@active_end_time AS VARCHAR), 6), 5, 0, ':'), 3, 0, ':')

DECLARE @NextTime DATETIME = NULL
DECLARE @ForToday BIT = 0
DECLARE @Seconds INT
DECLARE @Minutes INT
DECLARE @Hours INT

IF (@freq_subday_type NOT IN (0, 1, 2, 4, 8) OR @freq_subday_interval &lt; 0) BEGIN
    SET @NextTime = NULL
    SET @ForToday = 1
END
ELSE IF @CurrentDateAndTime &lt; @StartDateAndTime BEGIN
    SET @NextTime = @StartDateAndTime
    SET @ForToday = 1
END
ELSE IF (@CurrentDateAndTime &gt; @EndDateAndTime) BEGIN
    SET @NextTime = @StartDateAndTime
    SET @ForToday = 0
END
ELSE IF (@freq_subday_type IN (0, 1)) BEGIN
    -- At the specified time
    SET @NextTime = @StartDateAndTime
    SET @ForToday = 0
END
ELSE IF (@freq_subday_type = 2) BEGIN
    -- Every @freq_subday_interval seconds
    SET @Seconds = DATEDIFF(SECOND, @StartDateAndTime, @CurrentDateAndTime)
    IF (@Seconds % @freq_subday_interval = 0) BEGIN
        SET @NextTime = @CurrentDateAndTime
        SET @ForToday = 1
    END
    ELSE BEGIN
        SET @Seconds = @freq_subday_interval * ((@Seconds / @freq_subday_interval) + 1)
        SET @NextTime = DATEADD(SECOND, @Seconds, @StartDateAndTime)
        IF (@NextTime &lt;= @EndDateAndTime) BEGIN
            SET @ForToday = 1
        END
        ELSE BEGIN
            SET @NextTime = @StartDateAndTime
            SET @ForToday = 0
        END
    END
END
ELSE IF (@freq_subday_type = 4) BEGIN
    -- Every @freq_subday_interval minutes
    SET @Minutes = DATEDIFF(MINUTE, @StartDateAndTime, @CurrentDateAndTime)
    IF (@Minutes % @freq_subday_interval = 0) BEGIN
        IF (DATEPART(SECOND, @CurrentDateAndTime) &lt;= DATEPART(SECOND, @StartDateAndTime)) BEGIN
            SET @NextTime = DATEADD(MINUTE, @Minutes, @StartDateAndTime)
            SET @ForToday = 1
        END
        ELSE BEGIN
            SET @Minutes = @freq_subday_interval * ((@Minutes / @freq_subday_interval) + 1)
            SET @NextTime = DATEADD(MINUTE, @Minutes, @StartDateAndTime)
            IF (@NextTime &lt;= @EndDateAndTime) BEGIN
                SET @ForToday = 1
            END
            ELSE BEGIN
                SET @NextTime = @StartDateAndTime
                SET @ForToday = 0
            END
        END
    END
    ELSE BEGIN
        SET @Minutes = @freq_subday_interval * ((@Minutes / @freq_subday_interval) + 1)
        SET @NextTime = DATEADD(MINUTE, @Minutes, @StartDateAndTime)
        IF (@NextTime &lt;= @EndDateAndTime) BEGIN
            SET @ForToday = 1
        END
        ELSE BEGIN
            SET @NextTime = @StartDateAndTime
            SET @ForToday = 0
        END
    END
END
ELSE IF (@freq_subday_type = 8) BEGIN
    -- Every @freq_subday_interval hours
    SET @Hours = DATEDIFF(HOUR, @StartDateAndTime, @CurrentDateAndTime)
    IF (@Hours % @freq_subday_interval = 0) BEGIN
        IF (DATEPART(MINUTE, @CurrentDateAndTime) &lt;= DATEPART(MINUTE, @StartDateAndTime) OR (DATEPART(MINUTE, @CurrentDateAndTime) = DATEPART(MINUTE, @StartDateAndTime) AND DATEPART(SECOND, @CurrentDateAndTime) &lt;= DATEPART(SECOND, @StartDateAndTime))) BEGIN
            SET @NextTime = DATEADD(HOUR, @Hours, @StartDateAndTime)
            SET @ForToday = 1
        END
        ELSE BEGIN
            SET @Hours= @freq_subday_interval * ((@Hours / @freq_subday_interval) + 1)
            SET @NextTime = DATEADD(HOUR, @Hours, @StartDateAndTime)
            IF (@NextTime &lt;= @EndDateAndTime) BEGIN
                SET @ForToday = 1
            END
            ELSE BEGIN
                SET @NextTime = @StartDateAndTime
                SET @ForToday = 0
            END
        END
    END
    ELSE BEGIN
        SET @Hours= @freq_subday_interval * ((@Hours / @freq_subday_interval) + 1)
        SET @NextTime = DATEADD(HOUR, @Hours, @StartDateAndTime)
        IF (@NextTime &lt;= @EndDateAndTime) BEGIN
            SET @ForToday = 1
        END
        ELSE BEGIN
            SET @NextTime = @StartDateAndTime
            SET @ForToday = 0
        END
    END
END

SET @NextTime = CASE WHEN @NextTime IS NOT NULL AND @ForToday &lt;&gt; 1 THEN DATEADD(DAY, 1, @NextTime) ELSE @NextTime END

DECLARE @StartDate DATE = STUFF(STUFF(RIGHT('00000000' + CAST(@active_start_date AS VARCHAR), 8), 7, 0, '-'), 5, 0, '-')
DECLARE @EndDate DATE = STUFF(STUFF(RIGHT('00000000' + CAST(@active_end_date AS VARCHAR), 8), 7, 0, '-'), 5, 0, '-')
DECLARE @Today DATE = @NextTime
DECLARE @RunningDate DATE = @NextTime
DECLARE @NextDate DATE = NULL

IF (@NextTime IS NULL) BEGIN
    GOTO DONE
END

IF (@freq_type = 1) BEGIN
    -- 1 = One time only
    SET @NextDate = CASE WHEN (@RunningDate = @Today) THEN @RunningDate ELSE NULL END
    GOTO DONE
END

IF (@RunningDate &gt; @EndDate) BEGIN
    GOTO DONE
END

SET @RunningDate = @StartDate
IF (@freq_type = 4) BEGIN
    -- 4 = Daily
    IF (@RunningDate = @Today) BEGIN
        SET @NextDate = @RunningDate
        GOTO DONE
    END
    IF (@freq_interval = 0) GOTO DONE
    WHILE (@RunningDate &lt; @Today AND @RunningDate &lt; @EndDate) BEGIN
        SET @RunningDate = DATEADD(DAY, @freq_interval, @RunningDate)
    END
    SET @NextDate = CASE WHEN (@RunningDate &lt;= @EndDate) THEN @RunningDate ELSE NULL END
    GOTO DONE
END

IF (@freq_type = 8) BEGIN
    -- 8 = Weekly
    DECLARE @WhichWeekdays INT = @freq_interval
    DECLARE @EveryXWeeks INT = @freq_recurrence_factor
    DECLARE @NextWeekDate DATE

    IF ((@WhichWeekdays &amp; 127) = 0) BEGIN
        -- if no day  of the week selected, allow any day of the week
        SET @WhichWeekdays = 127
    END
    IF (@EveryXWeeks &lt;= 0) BEGIN
        -- if &quot;occurs every x week&quot; is not set, set &quot;occurs every x week&quot; to 1
        SET @EveryXWeeks = 1
    END
    IF (@StartDate &gt; @Today) BEGIN
        SET @NextWeekDate = @StartDate
    END
    ELSE BEGIN
        -- set the starting week to the week of @Today
        SET @NextWeekDate = DATEADD(DAY, 7 * @EveryXWeeks * (DATEDIFF(DAY, @StartDate, @Today) / (7 * @EveryXWeeks)), @StartDate)
    END
    SET @RunningDate = @NextWeekDate
    IF (DATEDIFF(DAY, @RunningDate, @Today) &gt;= 7) BEGIN
        SET @NextWeekDate = DATEADD(DAY, (7 * @EveryXWeeks), @NextWeekDate)
        SET @RunningDate = @NextWeekDate
    END
    ELSE BEGIN
        WHILE (@RunningDate &lt; @Today) BEGIN
            SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
        END
    END
    IF (@RunningDate &gt;= @Today AND @RunningDate &lt; @EndDate AND (0 &lt;&gt; (POWER(2, DATEPART(WEEKDAY, @RunningDate) - 1) &amp; @WhichWeekdays))) BEGIN
        SET @NextDate = @RunningDate
        GOTO DONE
    END
    WHILE (@RunningDate &lt; @EndDate) BEGIN
        IF (DATEDIFF(DAY, @NextWeekDate, @RunningDate) &gt;= 7) BEGIN
            SET @NextWeekDate = DATEADD(DAY, (7 * @EveryXWeeks), @NextWeekDate)
            SET @RunningDate = @NextWeekDate
        END
        ELSE BEGIN
            WHILE (DATEDIFF(DAY, @NextWeekDate, @RunningDate) &lt; 7) BEGIN
                IF (@RunningDate &lt; @EndDate AND (0 &lt;&gt; (POWER(2, DATEPART(WEEKDAY, @RunningDate) - 1) &amp; @WhichWeekdays))) BEGIN
                    SET @NextDate = @RunningDate
                    GOTO DONE
                END
                SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
            END
        END
    END
    GOTO DONE
END

IF (@freq_type = 16) BEGIN
    -- 16 = Monthly
    DECLARE @DayOfTheMonth INT = @freq_interval
    DECLARE @EveryXMonths INT = @freq_recurrence_factor
    DECLARE @NextMonthDate DATE

    IF (@DayOfTheMonth &gt; 31) BEGIN
        GOTO DONE
    END
    IF (@EveryXMonths &lt;= 0) BEGIN
        -- if &quot;occurs every x months&quot; is not set, set &quot;occurs every month&quot;
        SET @EveryXMonths = 1
    END

    IF (@StartDate &gt; @Today) BEGIN
        SET @NextMonthDate = DATEADD(DAY, 1 - DATEPART(DAY, @StartDate), @StartDate)
    END
    ELSE BEGIN
        -- set the next month date (is nearest 1st to today of month
        SET @NextMonthDate = DATEADD(
            MONTH, 
            @EveryXMonths 
                * (DATEDIFF(MONTH, 
                    DATEADD(DAY, 1 - DATEPART(DAY, @StartDate), @StartDate), 
                    DATEADD(DAY, 1 - DATEPART(DAY, @Today), @Today)) 
                    / @EveryXMonths), 
            DATEADD(DAY, 1 - DATEPART(DAY, @StartDate), @StartDate))
    END
    WHILE (DATEADD(MONTH, 1, @NextMonthDate) &lt; @Today) BEGIN
        SET @NextMonthDate = DATEADD(MONTH, @EveryXMonths, @NextMonthDate)
    END
    SET @RunningDate = @NextMonthDate
    WHILE (@RunningDate &lt; @Today AND @RunningDate &lt; @EndDate) BEGIN
        SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
    END

    -- to avoid an endless loop for the below special cases of schedules, we need to count the maximum number of months
    --      - @DayOfTheMonth = 30 or 31 
    --      - @StartDate somewhere in february
    --      - @EveryXMonths = 12
    -- or also 
    --      - @DayOfTheMonth = 31 
    --      - @StartDate somewhere in a month with 30 days
    --      - @EveryXMonths = 12
    DECLARE @MaxMonths INT = 48

    IF (DATEPART(DAY, @RunningDate) &lt;&gt; @DayOfTheMonth) BEGIN
        WHILE (DATEPART(DAY, @RunningDate) &lt;&gt; @DayOfTheMonth AND @RunningDate &lt; @EndDate AND @MaxMonths &gt; 0) BEGIN
            IF (DATEPART(DAY, DATEADD(DAY, 1, @RunningDate)) &gt; 1) BEGIN
                -- we are stil in same month
                SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
            END
            ELSE BEGIN
                -- we passed to the next month
                SET @NextMonthDate = DATEADD(MONTH, @EveryXMonths, @NextMonthDate)
                SET @RunningDate = @NextMonthDate
                SET @MaxMonths = @MaxMonths - @EveryXMonths
            END
        END
    END
    IF (DATEPART(DAY, @RunningDate) = @DayOfTheMonth ) BEGIN
        SET @NextDate = @RunningDate
    END
    GOTO DONE
END

IF (@freq_type = 32) BEGIN
    -- 32 = Monthly, relative to freq_interval
    DECLARE @FirstUpToLastType INT = @freq_relative_interval
    DECLARE @DayType INT = @freq_interval

    IF (@FirstUpToLastType = 0) BEGIN
        GOTO DONE
    END
    IF (@FirstUpToLastType = 5) BEGIN
        -- last su/mo/tu/we/th/fr/sa/day/weekday/weekendday
        SET @RunningDate = DATEADD(DAY, -1, DATEADD(MONTH, 1, DATEADD(DAY, 1 - DAY(@RunningDate), @RunningDate)))
        WHILE (@RunningDate &lt; @EndDate) BEGIN
            IF (@DayType IN (1,2,3,4,5,6,7)) BEGIN
                -- 1 = Sunday
                -- 2 = Monday
                -- 3 = Tuesday
                -- 4 = Wednesday
                -- 5 = Thursday
                -- 6 = Friday
                -- 7 = Saturday
                WHILE (DATEPART(WEEKDAY, @RunningDate) &lt;&gt; @DayType) BEGIN
                    SET @RunningDate = DATEADD(DAY, -1, @RunningDate)
                END
            END
            ELSE IF (@DayType = 9) BEGIN
                -- 9 = Weekday
                WHILE (DATEPART(WEEKDAY, @RunningDate) IN (1, 7)) BEGIN
                    SET @RunningDate = DATEADD(DAY, -1, @RunningDate)
                END
            END
            ELSE IF (@DayType = 10) BEGIN
                -- 10 = Weekend day
                WHILE (DATEPART(WEEKDAY, @RunningDate) NOT IN (1, 7)) BEGIN
                    SET @RunningDate = DATEADD(DAY, -1, @RunningDate)
                END
            END

            IF (@RunningDate &gt;= @Today AND @RunningDate &lt;= @EndDate) BEGIN
                SET @NextDate = @RunningDate
                GOTO DONE
            END
            IF (@RunningDate &lt; @EndDate) BEGIN
                GOTO DONE
            END
            -- try next month - should succeed if less than @EndDate
            SET @RunningDate = DATEADD(DAY, -1, DATEADD(MONTH, 1, DATEADD(DAY, 1 - DAY(@RunningDate), @RunningDate)))
        END
        GOTO DONE
    END
    ELSE BEGIN
        -- freq_relative_interval = 1,2,3,4
        -- first/second/third/fourth su/mo/tu/we/th/fr/sa/day/weekday/weekendday
        SET @RunningDate = DATEADD(DAY, 1 - DAY(@RunningDate), @RunningDate)
        WHILE (@RunningDate &lt;= @EndDate) BEGIN
            IF (@DayType IN (1,2,3,4,5,6,7)) BEGIN
                -- 1 = Sunday
                -- 2 = Monday
                -- 3 = Tuesday
                -- 4 = Wednesday
                -- 5 = Thursday
                -- 6 = Friday
                -- 7 = Saturday
                WHILE (DATEPART(WEEKDAY, @RunningDate) &lt;&gt; @DayType) BEGIN
                    SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
                END
            END
            ELSE IF (@DayType = 9) BEGIN
                -- 9 = Weekday
                WHILE (DATEPART(WEEKDAY, @RunningDate) NOT IN (2,3,4,5,6)) BEGIN
                    SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
                END
            END
            ELSE IF (@DayType = 10) BEGIN
                -- 10 = Weekend day
                WHILE (DATEPART(WEEKDAY, @RunningDate) NOT IN (1, 7)) BEGIN
                    SET @RunningDate = DATEADD(DAY, 1, @RunningDate)
                END
            END

            IF (@FirstUpToLastType &gt; 1) BEGIN
                -- for second, third, fourth and last
                IF (@DayType IN (1,2,3,4,5,6,7)) BEGIN
                    -- 1-7 = Weekday Sunday to Saturday
                    SET @RunningDate = DATEADD(DAY, 7 * (@FirstUpToLastType - 1), @RunningDate)
                END
                ELSE IF (@DayType = 8) BEGIN
                    -- 8 = Day
                    SET @RunningDate = DATEADD(DAY, @FirstUpToLastType - 1, @RunningDate)
                END
                ELSE IF (@DayType = 9) BEGIN
                    -- 9 = Weekday
                    -- add 1, 2 or 3 days for second, third or fourth weekday unless we are 
                    -- Friday (second weekday), Thursday (third weekday) or Wednesday 
                    -- (fourth weekday) in which cases we add 2 more days to skip weekend
                    SET @RunningDate = DATEADD(DAY, @FirstUpToLastType - 1 + 
                        CASE @FirstUpToLastType 
                            WHEN 2 THEN CASE WHEN DATEPART(WEEKDAY, @RunningDate) &lt;= 6 THEN 2 ELSE 0 END
                            WHEN 3 THEN CASE WHEN DATEPART(WEEKDAY, @RunningDate) &lt;= 5 THEN 2 ELSE 0 END
                            WHEN 4 THEN CASE WHEN DATEPART(WEEKDAY, @RunningDate) &lt;= 4 THEN 2 ELSE 0 END
                            ELSE 0
                        END, @RunningDate)
                END
                ELSE IF (@DayType = 10) BEGIN
                    -- 10 = Weekend day
                    -- this implementation will take the first day of the next weekend, not the next weekend day
                    -- add 7, 14 or 21 days for next weekend unless we are Sunday where we need to add 
                    -- 1 day less to have the Saturday of the next weekend 
                    SET @RunningDate = DATEADD(DAY, 7 * (@FirstUpToLastType - 1) - CASE WHEN DATEPART(WEEKDAY, @RunningDate) = 6 THEN 1 ELSE 0 END, @RunningDate)
                END
            END
            IF (@RunningDate &gt;= @Today AND @RunningDate &lt;= @EndDate) BEGIN
                SET @NextDate = @RunningDate
                GOTO DONE
            END
            IF (@RunningDate &lt; @EndDate) BEGIN
                GOTO DONE
            END
            -- try next month - should succeed if less than @EndDate
            SET @RunningDate = DATEADD(MONTH, 1, DATEADD(DAY, 1 - DAY(@RunningDate), @RunningDate))
        END
    END
END

DONE: IF (@NextDate IS NULL OR @NextTime IS NULL) BEGIN RETURN NULL END

RETURN CAST(@NextDate AS DATETIME) + CAST(CAST(@NextTime AS TIME) AS DATETIME)

END

See https://github.com/paulpeeters/mssql_calculate_next_run_time

Paul Peeters
  • 111
  • 2