{{:wiki:icons:t-sql.png?nolink&90 |}} Database scheduler [[hobby:development:sql|{{ :wiki:icons:left_arrow.png?48|Vai alla mappa}}]]\\ //(scheduler per l'esecuzione pianificata di procedure SQL)// \\ \\ Scheduler table-driven in SQL Server, basato su SQL Server Agent con una tabella di configurazione e una stored procedure "dispatcher". === Architettura === - **Tabella di configurazione:** definisce quali procedure eseguire, con che frequenza, se sono attive - **Tabella di log:** traccia le esecuzioni (successo/errore, durata) - **Stored procedure dispatcher:** legge la config, decide cosa รจ "dovuto" eseguire, lancia le procedure con EXEC dinamico e gestione errori - **Un solo SQL Server Agent Job:** che gira ogni minuto (o ogni X minuti) e chiama il dispatcher Vantaggio: non serve creare un job Agent per ogni procedura, ne basta uno solo che orchestra tutto in base alla tabella. === Creazione delle tabelle === -- ----------------------------------------------------------------------------- -- 1) CONFIGURATION TABLE -- Stores the definition of every job. ScheduleType drives which of the other -- scheduling columns/tables are actually used for that row. -- ----------------------------------------------------------------------------- CREATE TABLE dbo.scheduler_config( IdJob INT IDENTITY(1,1) PRIMARY KEY, JobName NVARCHAR(100) NOT NULL, -- Human-friendly name, not used by the engine ProcedureSchema SYSNAME NOT NULL DEFAULT 'dbo', -- Schema of the target procedure ProcedureName SYSNAME NOT NULL, -- Name of the stored procedure to execute Parameters NVARCHAR(MAX) NULL, -- Optional literal parameter string, e.g. '@Param1=1,@Param2=''ABC''' ScheduleType VARCHAR(15) NOT NULL, -- INTERVAL -> runs every FrequencyMinutes minutes -- DAILY_TIME -> runs at specific times of day, listed in scheduler_config_times -- ONE_TIME -> runs once at SpecificDateTime, then never again FrequencyMinutes INT NULL, -- required only when ScheduleType = INTERVAL SpecificDateTime DATETIME NULL, -- required only when ScheduleType = ONE_TIME StartTime TIME NULL, -- optional time-of-day window, applies to INTERVAL only EndTime TIME NULL, -- WeekDays VARCHAR(20) NULL, -- optional allowed weekdays (1=Monday..7=Sunday), applies to INTERVAL and DAILY_TIME IsActive BIT NOT NULL DEFAULT 1, -- enable/disable without deleting the row IsRunning BIT NOT NULL DEFAULT 0, -- prevents overlapping executions of the same job LastRunDate DATETIME NULL, -- timestamp of the last time this job started CONSTRAINT sK_Schedu_cerConfig_ScheduleType CHECK (ScheduleType IN ('INTERVAL','DAILY_TIME','ONE_TIME')) ); GO -- ----------------------------------------------------------------------------------- -- 2) FIXED DAILY RUN TIMES -- Holds one or more fixed times of day for jobs whose ScheduleType = 'DAILY_TIME'. -- Example: a job can run at both 08:00 and 18:00 by inserting two rows here. -- ----------------------------------------------------------------------------------- CREATE TABLE dbo.scheduler_config_times ( Id INT IDENTITY(1,1) PRIMARY KEY, ConfigID INT NOT NULL REFERENCES dbo.scheduler_config(IdJob), RunTime TIME NOT NULL, -- e.g. '08:00:00' LastRunDate DATE NULL -- last calendar date this specific slot fired; prevents re-firing within the same day ); GO -- ---------------------------------------------------------------------------- -- 3) EXECUTION LOG -- Keeps an execution history for every run of every job, including duration -- and any error raised. -- ---------------------------------------------------------------------------- CREATE TABLE dbo.scheduler_log ( ID INT IDENTITY(1,1) PRIMARY KEY, ConfigID INT NOT NULL, -- FK back to scheduler_config.ID ProcedureName SYSNAME NOT NULL, -- denormalized for quick reading without a join StartDate DATETIME NOT NULL, EndDate DATETIME NULL, Outcome VARCHAR(20) NULL, -- 'RUNNING' / 'OK' / 'ERROR' ErrorMessage NVARCHAR(MAX) NULL -- populated only when Outcome = 'ERROR' ); GO -- ----------------------------------------------------------------------------- -- 4) SUPPORTING INDEXES -- The dispatcher filters on ScheduleType / IsActive / IsRunning on every run -- (every minute), so these columns need a covering index to avoid a table -- scan as scheduler_config grows. -- ----------------------------------------------------------------------------- CREATE NONCLUSTERED INDEX sX_Schedu_cerConfig_Dispatch ON dbo.scheduler_config (ScheduleType, IsActive, IsRunning) INCLUDE (ProcedureSchema, ProcedureName, Parameters, FrequencyMinutes, LastRunDate, StartTime, EndTime, WeekDays, SpecificDateTime); GO CREATE NONCLUSTERED INDEX sX_Schedu_cerConfigTimes_ConfigID ON dbo.scheduler_config_times (ConfigID) INCLUDE (RunTime, LastRunDate); GO === Dispatcher procedure === -- ---------------------------------------------------------------------------- -- DISPATCHER PROCEDURE -- Main orchestrator, meant to be called every minute by a single SQL Server -- Agent Job. Handles INTERVAL, DAILY_TIME and ONE_TIME scheduling. -- No CURSOR object is used: due jobs are collected in a table variable and -- walked with a WHILE loop, which is required only because each dynamic -- EXEC needs its own isolated TRY/CATCH so one failing job never blocks -- the others. All status/log updates that don't need per-row isolation -- are done as set-based statements instead. -- -------------------------------------------------------------------------- -- SAMPLE CONFIGURATION ROWS -- -------------------------------------------------------------------------- */ -- INSERT INTO dbo.scheduler_config (JobName, ProcedureName, ScheduleType, FrequencyMinutes, StartTime, EndTime, WeekDays) -- VALUES ('Import CSV X3', 'sp_ImportCsvX3', 'INTERVAL', 15, '06:00', '22:00', '1,2,3,4,5'); -- DECLARE @NewConfigID INT; -- INSERT INTO dbo.scheduler_config (JobName, ProcedureName, ScheduleType) -- VALUES ('Daily Cost Recalc', 'sp_RecalcCosts', 'DAILY_TIME'); -- SET @NewConfigID = SCOPE_IDENTITY(); -- INSERT INTO dbo.scheduler_config_times (ConfigID, RunTime) -- VALUES (@NewConfigID, '08:00'), (@NewConfigID, '18:00'); -- INSERT INTO dbo.scheduler_config (JobName, ProcedureName, ScheduleType, SpecificDateTime) -- VALUES ('Year-End Fix', 'sp_YearEndFix', 'ONE_TIME', '2026-12-31 23:55:00'); -- ---------------------------------------------------------------------------- CREATE OR ALTER PROCEDURE dbo.sp_scheduler_dispatcher AS BEGIN SET NOCOUNT ON; DECLARE @Now DATETIME = GETDATE(); DECLARE @Today DATE = CAST(@Now AS DATE); DECLARE @NowTime TIME = CAST(@Now AS TIME); DECLARE @TodayWeekDay VARCHAR(1) = CAST(DATEPART(WEEKDAY, @Now) AS VARCHAR(1)); -- Working table with a row number, used to walk through the due jobs one -- at a time without allocating a CURSOR object. LogID is filled later via -- an OUTPUT clause, avoiding a lookup query inside the loop. DECLARE @DueJobs TABLE ( RowNum INT IDENTITY(1,1) PRIMARY KEY, ConfigID INT, ProcedureSchema SYSNAME, ProcedureName SYSNAME, Parameters NVARCHAR(MAX), TimeSlotID INT NULL, LogID INT NULL ); -- 1) INTERVAL jobs: due when enough minutes have passed since LastRunDate, -- and (if set) we're inside the allowed time window and weekday list. INSERT INTO @DueJobs (ConfigID, ProcedureSchema, ProcedureName, Parameters, TimeSlotID) SELECT ID, ProcedureSchema, ProcedureName, Parameters, NULL FROM dbo.scheduler_config WHERE IsActive = 1 AND IsRunning = 0 AND ScheduleType = 'INTERVAL' AND (LastRunDate IS NULL OR DATEDIFF(MINUTE, LastRunDate, @Now) >= FrequencyMinutes) AND (StartTime IS NULL OR @NowTime >= StartTime) AND (EndTime IS NULL OR @NowTime <= EndTime) AND (WeekDays IS NULL OR WeekDays LIKE '%' + @TodayWeekDay + '%'); -- 2) DAILY_TIME jobs: due when current time has reached a configured -- RunTime slot and that slot hasn't already fired today. INSERT INTO @DueJobs (ConfigID, ProcedureSchema, ProcedureName, Parameters, TimeSlotID) SELECT c.ID, c.ProcedureSchema, c.ProcedureName, c.Parameters, t.ID FROM dbo.scheduler_config c INNER JOIN dbo.scheduler_config_times t ON t.ConfigID = c.ID WHERE c.IsActive = 1 AND c.IsRunning = 0 AND c.ScheduleType = 'DAILY_TIME' AND (c.WeekDays IS NULL OR c.WeekDays LIKE '%' + @TodayWeekDay + '%') AND (t.LastRunDate IS NULL OR t.LastRunDate < @Today) AND CONVERT(CHAR(5), @NowTime, 108) >= CONVERT(CHAR(5), t.RunTime, 108); -- 3) ONE_TIME jobs: due once, when current datetime has reached -- SpecificDateTime and the job has never run before. INSERT INTO @DueJobs (ConfigID, ProcedureSchema, ProcedureName, Parameters, TimeSlotID) SELECT ID, ProcedureSchema, ProcedureName, Parameters, NULL FROM dbo.scheduler_config WHERE IsActive = 1 AND IsRunning = 0 AND ScheduleType = 'ONE_TIME' AND LastRunDate IS NULL AND @Now >= SpecificDateTime; -- Nothing due: exit early, no further statements needed. IF NOT EXISTS (SELECT * FROM @DueJobs) RETURN; -- Mark every due job as running and stamp LastRunDate in one set-based UPDATE, -- instead of doing it row-by-row inside the loop. UPDATE c SET c.IsRunning = 1 ,c.LastRunDate = GETDATE() FROM dbo.scheduler_config c INNER JOIN @DueJobs d ON d.ConfigID = c.ID; -- Same for DAILY_TIME slots: stamp all fired slots at once. UPDATE t SET t.LastRunDate = @Today FROM dbo.scheduler_config_times t INNER JOIN @DueJobs d ON d.TimeSlotID = t.ID WHERE d.TimeSlotID IS NOT NULL; -- Bulk-insert one 'RUNNING' log row per due job, capturing the generated -- IDs directly via OUTPUT so the loop below needs no lookup query. DECLARE @LogMap TABLE ( ConfigID INT ,LogID INT ); INSERT INTO dbo.scheduler_log (ConfigID, ProcedureName, StartDate, Outcome) OUTPUT inserted.ConfigID, inserted.ID INTO @LogMap (ConfigID, LogID) SELECT ConfigID, ProcedureName, GETDATE(), 'RUNNING' FROM @DueJobs; UPDATE d SET d.LogID = m.LogID FROM @DueJobs d INNER JOIN @LogMap m ON m.ConfigID = d.ConfigID; -- Execute each due procedure individually: dynamic SQL with per-job error -- handling genuinely requires row-by-row processing, so this is a plain -- WHILE loop keyed on RowNum rather than a CURSOR. DECLARE @i INT = 1; DECLARE @Count INT = (SELECT COUNT(*) FROM @DueJobs); DECLARE @ConfigID INT; DECLARE @Schema SYSNAME; DECLARE @Name SYSNAME; DECLARE @Params NVARCHAR(MAX) DECLARE @LogID INT; DECLARE @SqlCmd NVARCHAR(MAX); WHILE @i <= @Count BEGIN SELECT @ConfigID = ConfigID, @Schema = ProcedureSchema, @Name = ProcedureName, @Params = Parameters, @LogID = LogID FROM @DueJobs WHERE RowNum = @i; -- QUOTENAME() protects schema/procedure names against injection and -- reserved-word issues; parameters, if present, are appended as-is. SET @SqlCmd = QUOTENAME(@Schema) + '.' + QUOTENAME(@Name) + CASE WHEN @Params IS NOT NULL THEN ' ' + @Params ELSE '' END; BEGIN TRY EXEC (@SqlCmd); UPDATE dbo.scheduler_log SET EndDate = GETDATE(), Outcome = 'OK' WHERE ID = @LogID; END TRY BEGIN CATCH -- One failing procedure never stops the loop: error is logged, -- next job proceeds. UPDATE dbo.scheduler_log SET EndDate = GETDATE(), Outcome = 'ERROR', ErrorMessage = ERROR_MESSAGE() WHERE ID = @LogID; END CATCH -- Always release the running flag, whether the procedure succeeded or failed. UPDATE dbo.scheduler_config SET IsRunning = 0 WHERE ID = @ConfigID; SET @i += 1; END END GO