From 3adace56d7b09e3f8f9c3c80adfce7b173a46de8 Mon Sep 17 00:00:00 2001 From: Jordan van Gogh Date: Sun, 17 Mar 2019 11:31:39 +0100 Subject: [PATCH 1/2] added SQL server recent job history persistence --- Database/Tables/tables_sqlServer.sql | 115 +++++ Quartzmin.sln | 43 +- README.md | 23 + .../SqlServerExecutionHistoryStoreTests.cs | 407 ++++++++++++++++++ .../SqlServerExecutionHistoryTests.cs | 242 +++++++++++ ...ins.RecentHistory.Tests.Integration.csproj | 41 ++ .../README.txt | 5 + .../TestJob.cs | 20 + .../TestJobListener.cs | 30 ++ .../appSettings.test.json | 7 + .../xunit.runner.json | 4 + .../ExecutionHistoryPlugin.cs | 18 +- .../Impl/SqlServer/SqlServerConstants.cs | 30 ++ .../SqlServerExecutionHistoryPlugin.cs | 29 ++ .../SqlServerExecutionHistoryStore.cs | 308 +++++++++++++ 15 files changed, 1279 insertions(+), 43 deletions(-) create mode 100644 Database/Tables/tables_sqlServer.sql create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryStoreTests.cs create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryTests.cs create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/README.txt create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJob.cs create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJobListener.cs create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/appSettings.test.json create mode 100644 Source/Quartz.Plugins.RecentHistory.Tests.Integration/xunit.runner.json create mode 100644 Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerConstants.cs create mode 100644 Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryPlugin.cs create mode 100644 Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryStore.cs diff --git a/Database/Tables/tables_sqlServer.sql b/Database/Tables/tables_sqlServer.sql new file mode 100644 index 0000000..9b5fb09 --- /dev/null +++ b/Database/Tables/tables_sqlServer.sql @@ -0,0 +1,115 @@ +USE [enter_db_name_here] +GO +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[DF_QRTZ_EXECUTION_HISTORY_VETOED]') AND type = 'D') +BEGIN +ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] DROP CONSTRAINT [DF_QRTZ_EXECUTION_HISTORY_VETOED] +END +GO +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[DF_QRTZ_EXECUTION_HISTORY_RECOVERING]') AND type = 'D') +BEGIN +ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] DROP CONSTRAINT [DF_QRTZ_EXECUTION_HISTORY_RECOVERING] +END +GO +IF EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_TRIGGER_NAME_ACTUAL_FIRE_TIME_UTC') +DROP INDEX [IX_TRIGGER_NAME_ACTUAL_FIRE_TIME_UTC] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +GO +IF EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_SCHED_NAME') +DROP INDEX [IX_SCHED_NAME] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +GO +IF EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_JOB_NAME_ACTUAL_FIRE_TIME_UTC') +DROP INDEX [IX_JOB_NAME_ACTUAL_FIRE_TIME_UTC] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +GO +IF EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_ACTUAL_FIRE_TIME_UTC') +DROP INDEX [IX_ACTUAL_FIRE_TIME_UTC] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +GO +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_STATS]') AND type in (N'U')) +DROP TABLE [dbo].[QRTZ_EXECUTION_HISTORY_STATS] +GO +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND type in (N'U')) +DROP TABLE [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +GO +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND type in (N'U')) +BEGIN +CREATE TABLE [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]( + [FIRE_INSTANCE_ID] [nvarchar](140) NOT NULL, + [SCHEDULER_INSTANCE_ID] [nvarchar](200) NOT NULL, + [SCHED_NAME] [nvarchar](120) NOT NULL, + [JOB_NAME] [nvarchar](150) NOT NULL, + [TRIGGER_NAME] [nvarchar](150) NOT NULL, + [SCHEDULED_FIRE_TIME_UTC] [datetime] NULL, + [ACTUAL_FIRE_TIME_UTC] [datetime] NOT NULL, + [RECOVERING] [bit] NOT NULL, + [VETOED] [bit] NOT NULL, + [FINISHED_TIME_UTC] [datetime] NULL, + [EXCEPTION_MESSAGE] [nvarchar](max) NULL, + CONSTRAINT [PK_QRTZ_EXECUTION_HISTORY_ENTRIES] PRIMARY KEY CLUSTERED +( + [FIRE_INSTANCE_ID] ASC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] +) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] +END +GO +SET ANSI_NULLS ON +GO +SET QUOTED_IDENTIFIER ON +GO +IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_STATS]') AND type in (N'U')) +BEGIN +CREATE TABLE [dbo].[QRTZ_EXECUTION_HISTORY_STATS]( + [SCHED_NAME] [nvarchar](120) NOT NULL, + [STAT_NAME] [nvarchar](120) NOT NULL, + [STAT_VALUE] [bigint] NULL, + CONSTRAINT [PK_EXECUTION_HISTORY_STATS] PRIMARY KEY CLUSTERED +( + [SCHED_NAME] ASC, + [STAT_NAME] ASC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] +) ON [PRIMARY] +END +GO +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_ACTUAL_FIRE_TIME_UTC') +CREATE NONCLUSTERED INDEX [IX_ACTUAL_FIRE_TIME_UTC] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +( + [ACTUAL_FIRE_TIME_UTC] DESC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] +GO +SET ANSI_PADDING ON +GO +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_JOB_NAME_ACTUAL_FIRE_TIME_UTC') +CREATE NONCLUSTERED INDEX [IX_JOB_NAME_ACTUAL_FIRE_TIME_UTC] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +( + [JOB_NAME] ASC, + [ACTUAL_FIRE_TIME_UTC] DESC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] +GO +SET ANSI_PADDING ON +GO +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_SCHED_NAME') +CREATE NONCLUSTERED INDEX [IX_SCHED_NAME] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +( + [SCHED_NAME] ASC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] +GO +SET ANSI_PADDING ON +GO +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES]') AND name = N'IX_TRIGGER_NAME_ACTUAL_FIRE_TIME_UTC') +CREATE NONCLUSTERED INDEX [IX_TRIGGER_NAME_ACTUAL_FIRE_TIME_UTC] ON [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] +( + [TRIGGER_NAME] ASC, + [ACTUAL_FIRE_TIME_UTC] DESC +)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] +GO +IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[DF_QRTZ_EXECUTION_HISTORY_RECOVERING]') AND type = 'D') +BEGIN +ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] ADD CONSTRAINT [DF_QRTZ_EXECUTION_HISTORY_RECOVERING] DEFAULT ((0)) FOR [RECOVERING] +END +GO +IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[DF_QRTZ_EXECUTION_HISTORY_VETOED]') AND type = 'D') +BEGIN +ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY_ENTRIES] ADD CONSTRAINT [DF_QRTZ_EXECUTION_HISTORY_VETOED] DEFAULT ((0)) FOR [VETOED] +END +GO \ No newline at end of file diff --git a/Quartzmin.sln b/Quartzmin.sln index 8e421a0..dbbe537 100644 --- a/Quartzmin.sln +++ b/Quartzmin.sln @@ -19,7 +19,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NetCoreSelfHost", "Source\E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormSelfHost", "Source\Examples\WinFormSelfHost\WinFormSelfHost.csproj", "{C54F4403-F40A-497C-B3A7-D1F1E1830D52}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreDocker", "Source\Examples\AspNetCoreDocker\AspNetCoreDocker.csproj", "{68D816F2-21BF-4376-ABF4-70390E4783C5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreDocker", "Source\Examples\AspNetCoreDocker\AspNetCoreDocker.csproj", "{68D816F2-21BF-4376-ABF4-70390E4783C5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quartz.Plugins.RecentHistory.Tests.Integration", "Source\Quartz.Plugins.RecentHistory.Tests.Integration\Quartz.Plugins.RecentHistory.Tests.Integration.csproj", "{3FC7A772-C5E2-4DAD-B3F2-D2D6894858D5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -59,6 +61,10 @@ Global {68D816F2-21BF-4376-ABF4-70390E4783C5}.Debug|Any CPU.Build.0 = Debug|Any CPU {68D816F2-21BF-4376-ABF4-70390E4783C5}.Release|Any CPU.ActiveCfg = Release|Any CPU {68D816F2-21BF-4376-ABF4-70390E4783C5}.Release|Any CPU.Build.0 = Release|Any CPU + {3FC7A772-C5E2-4DAD-B3F2-D2D6894858D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FC7A772-C5E2-4DAD-B3F2-D2D6894858D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FC7A772-C5E2-4DAD-B3F2-D2D6894858D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FC7A772-C5E2-4DAD-B3F2-D2D6894858D5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,39 +79,4 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0357313D-BD09-4C5D-AF0D-439B3BD33B5A} EndGlobalSection - GlobalSection(TeamFoundationVersionControl) = preSolution - SccNumberOfProjects = 9 - SccEnterpriseProvider = {4CA58AB2-18FA-4F8D-95D4-32DDF27D184C} - SccTeamFoundationServer = http://tfs/defaultcollection - SccLocalPath0 = . - SccProjectUniqueName1 = Source\\Quartzmin\\Quartzmin.csproj - SccProjectName1 = Source/Quartzmin - SccLocalPath1 = Source\\Quartzmin - SccProjectUniqueName2 = Source\\Quartz.Plugins.RecentHistory\\Quartz.Plugins.RecentHistory.csproj - SccProjectName2 = Source/Quartz.Plugins.RecentHistory - SccLocalPath2 = Source\\Quartz.Plugins.RecentHistory - SccProjectUniqueName3 = Source\\Quartzmin.SelfHost\\Quartzmin.SelfHost.csproj - SccProjectName3 = Source/Quartzmin.SelfHost - SccLocalPath3 = Source\\Quartzmin.SelfHost - SccProjectUniqueName4 = Source\\Examples\\NetCoreSelfHost\\NetCoreSelfHost.csproj - SccProjectTopLevelParentUniqueName4 = Quartzmin.sln - SccProjectName4 = Source/Examples/NetCoreSelfHost - SccLocalPath4 = Source\\Examples\\NetCoreSelfHost - SccProjectUniqueName5 = Source\\Examples\\AspNetCoreHost\\AspNetCoreHost.csproj - SccProjectTopLevelParentUniqueName5 = Quartzmin.sln - SccProjectName5 = Source/Examples/AspNetCoreHost - SccLocalPath5 = Source\\Examples\\AspNetCoreHost - SccProjectUniqueName6 = Source\\Examples\\AspNetWebHost\\AspNetWebHost.csproj - SccProjectTopLevelParentUniqueName6 = Quartzmin.sln - SccProjectName6 = Source/Examples/AspNetWebHost - SccLocalPath6 = Source\\Examples\\AspNetWebHost - SccProjectUniqueName7 = Source\\Examples\\WinFormSelfHost\\WinFormSelfHost.csproj - SccProjectTopLevelParentUniqueName7 = Quartzmin.sln - SccProjectName7 = Source/Examples/WinFormSelfHost - SccLocalPath7 = Source\\Examples\\WinFormSelfHost - SccProjectUniqueName8 = Source\\Examples\\AspNetCoreDocker\\AspNetCoreDocker.csproj - SccProjectTopLevelParentUniqueName8 = Quartzmin.sln - SccProjectName8 = Source/Examples/AspNetCoreDocker - SccLocalPath8 = Source\\Examples\\AspNetCoreDocker - EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 85d4b1d..76d78e4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,29 @@ public void Configure(IApplicationBuilder app) } ``` +### SQL server recent job history persistence +Execute the SQL server deployment script from the `/Database` folder of this repo in a database of your choosing. + +Add `SqlServerExecutionHistoryPlugin` specific settings to your `App.config` file: +```xml + + +
+ + + + + + + + + + + + +``` + + ## Notes In clustered environment, it make more sense to host Quarzmin on single dedicated Quartz.NET node in standby mode and implement own `IExecutionHistoryStore` depending on database or ORM framework you typically incorporate. Every clustered Quarz.NET node should be configured with `ExecutionHistoryPlugin` and only dedicated node for management may have `QuartzminPlugin`. diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryStoreTests.cs b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryStoreTests.cs new file mode 100644 index 0000000..ee81f8b --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryStoreTests.cs @@ -0,0 +1,407 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Quartz.Plugins.RecentHistory.Impl.SqlServer; +using Xunit; + +namespace Quartz.Plugins.RecentHistory.Tests.Integration.Impl.SqlServer +{ + public class SqlServerExecutionHistoryStoreTests + { + protected string ConnectionString { get; set; } + public string SchedulerName { get; set; } + public string TablePrefix { get; set; } + + public SqlServerExecutionHistoryStoreTests() + { + var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.test.json") + .Build(); + + ConnectionString = config["Tests:ConnectionString"]; + SchedulerName = config["Tests:SchedulerName"]; + TablePrefix = config["Tests:TablePrefix"]; + } + + [Fact] + public async void GetsEntry() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(1), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(2), + ExceptionMessage = Guid.NewGuid().ToString(), + Vetoed = true, + Recovering= true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = Guid.NewGuid().ToString() + }; + await store.Save(newEntry); + + var fetchedEntry = await store.Get(newEntry.FireInstanceId); + + Assert.NotNull(fetchedEntry); + Assert.NotEqual(newEntry, fetchedEntry); + } + + [Fact] + public async void SavesEntry() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(1), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(2), + ExceptionMessage = Guid.NewGuid().ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = Guid.NewGuid().ToString() + }; + await store.Save(newEntry); + + var fetchedEntry = await store.Get(newEntry.FireInstanceId); + + Assert.NotNull(fetchedEntry); + Assert.NotEqual(newEntry, fetchedEntry); + + Assert.Equal(ClearMilliseconds(newEntry.ActualFireTimeUtc), ClearMilliseconds(fetchedEntry.ActualFireTimeUtc)); + Assert.Equal(ClearMilliseconds(newEntry.ScheduledFireTimeUtc), ClearMilliseconds(fetchedEntry.ScheduledFireTimeUtc)); + Assert.Equal(ClearMilliseconds(newEntry.FinishedTimeUtc), ClearMilliseconds(fetchedEntry.FinishedTimeUtc)); + Assert.Equal(newEntry.ExceptionMessage, fetchedEntry.ExceptionMessage); + Assert.Equal(newEntry.Vetoed, fetchedEntry.Vetoed); + Assert.Equal(newEntry.Recovering, fetchedEntry.Recovering); + Assert.Equal(newEntry.FireInstanceId, fetchedEntry.FireInstanceId); + Assert.Equal(newEntry.Job, fetchedEntry.Job); + Assert.Equal(newEntry.SchedulerInstanceId, fetchedEntry.SchedulerInstanceId); + Assert.Equal(newEntry.SchedulerName, fetchedEntry.SchedulerName); + Assert.Equal(newEntry.Trigger, fetchedEntry.Trigger); + } + + [Fact] + public async void PurgesEntry() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.Subtract(TimeSpan.FromDays(100)), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(2), + ExceptionMessage = Guid.NewGuid().ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = Guid.NewGuid().ToString() + }; + await store.Save(newEntry); + + var fetchedEntryBeforePurge = await store.Get(newEntry.FireInstanceId); + Assert.NotNull(fetchedEntryBeforePurge); + Assert.NotEqual(newEntry, fetchedEntryBeforePurge); + + await store.Purge(); + + var fetchedEntryAfterPurge = await store.Get(newEntry.FireInstanceId); + Assert.Null(fetchedEntryAfterPurge); + } + + [Fact] + public async void FiltersLastOfEveryJob() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + var job1 = Guid.NewGuid().ToString(); + for (int i = 0; i < 5; i++) + { + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(4 - i), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(5), + ExceptionMessage = i.ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = job1, + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = Guid.NewGuid().ToString() + }; + + await store.Save(newEntry); + } + + var job2 = Guid.NewGuid().ToString(); + for (int i = 0; i < 5; i++) + { + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(4 - i), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(5), + ExceptionMessage = i.ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = job2, + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = Guid.NewGuid().ToString() + }; + + await store.Save(newEntry); + } + + var allEntries = await store.FilterLastOfEveryJob(3); + + var job1Entries = allEntries + .Where(f => f.Job == job1) + .ToList(); + Assert.Equal(3, job1Entries.Count); + Assert.Equal("0", job1Entries[0].ExceptionMessage); + Assert.Equal("1", job1Entries[1].ExceptionMessage); + Assert.Equal("2", job1Entries[2].ExceptionMessage); + + var job2Entries = allEntries + .Where(f => f.Job == job2) + .ToList(); + Assert.Equal(3, job2Entries.Count); + Assert.Equal("0", job2Entries[0].ExceptionMessage); + Assert.Equal("1", job2Entries[1].ExceptionMessage); + Assert.Equal("2", job2Entries[2].ExceptionMessage); + } + + [Fact] + public async void FiltersLastOfEveryTrigger() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + var trigger1 = Guid.NewGuid().ToString(); + for (int i = 0; i < 5; i++) + { + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(4 - i), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(5), + ExceptionMessage = i.ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = trigger1 + }; + + await store.Save(newEntry); + } + + var trigger2 = Guid.NewGuid().ToString(); + for (int i = 0; i < 5; i++) + { + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(4 - i), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(5), + ExceptionMessage = i.ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = trigger2 + }; + + await store.Save(newEntry); + } + + var allEntries = await store.FilterLastOfEveryTrigger(3); + + var trigger1Entries = allEntries + .Where(f => f.Trigger == trigger1) + .ToList(); + Assert.Equal(3, trigger1Entries.Count); + Assert.Equal("0", trigger1Entries[0].ExceptionMessage); + Assert.Equal("1", trigger1Entries[1].ExceptionMessage); + Assert.Equal("2", trigger1Entries[2].ExceptionMessage); + + var trigger2Entries = allEntries + .Where(f => f.Trigger == trigger2) + .ToList(); + Assert.Equal(3, trigger2Entries.Count); + Assert.Equal("0", trigger2Entries[0].ExceptionMessage); + Assert.Equal("1", trigger2Entries[1].ExceptionMessage); + Assert.Equal("2", trigger2Entries[2].ExceptionMessage); + } + + [Fact] + public async void FiltersLast() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + var trigger1 = Guid.NewGuid().ToString(); + for (int i = 0; i < 5; i++) + { + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(5 + 4 - i), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(5), + ExceptionMessage = i.ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = trigger1 + }; + + await store.Save(newEntry); + } + + var trigger2 = Guid.NewGuid().ToString(); + for (int i = 0; i < 5; i++) + { + var newEntry = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.AddMinutes(4 - i), + ScheduledFireTimeUtc = DateTime.UtcNow, + FinishedTimeUtc = DateTime.UtcNow.AddMinutes(5), + ExceptionMessage = i.ToString(), + Vetoed = true, + Recovering = true, + FireInstanceId = Guid.NewGuid().ToString(), + Job = Guid.NewGuid().ToString(), + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = trigger2 + }; + + await store.Save(newEntry); + } + + var allEntries = await store.FilterLast(8); + + var trigger1Entries = allEntries + .Where(f => f.Trigger == trigger1) + .ToList(); + Assert.Equal(5, trigger1Entries.Count); + Assert.Equal("0", trigger1Entries[0].ExceptionMessage); + Assert.Equal("1", trigger1Entries[1].ExceptionMessage); + Assert.Equal("2", trigger1Entries[2].ExceptionMessage); + Assert.Equal("3", trigger1Entries[3].ExceptionMessage); + Assert.Equal("4", trigger1Entries[4].ExceptionMessage); + + var trigger2Entries = allEntries + .Where(f => f.Trigger == trigger2) + .ToList(); + Assert.Equal(3, trigger2Entries.Count); + Assert.Equal("0", trigger2Entries[0].ExceptionMessage); + Assert.Equal("1", trigger2Entries[1].ExceptionMessage); + Assert.Equal("2", trigger2Entries[2].ExceptionMessage); + } + + [Fact] + public async void IncrementTotalJobsExecuted() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + int preCount = await store.GetTotalJobsExecuted(); + + await store.IncrementTotalJobsExecuted(); + + int afterCount = await store.GetTotalJobsExecuted(); + + Assert.Equal(preCount + 1, afterCount); + } + + [Fact] + public async void IncrementTotalJobsFailed() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + int preCount = await store.GetTotalJobsFailed(); + + await store.IncrementTotalJobsFailed(); + + int afterCount = await store.GetTotalJobsFailed(); + + Assert.Equal(preCount + 1, afterCount); + } + + [Fact] + public async void GetTotalJobsExecuted() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + int count = await store.GetTotalJobsExecuted(); + + Assert.Equal(0, count); + } + + [Fact] + public async void GetTotalJobsFailed() + { + var store = CreateStore(); + await store.ClearSchedulerData(); + + int count = await store.GetTotalJobsFailed(); + + Assert.Equal(0, count); + } + + private DateTime? ClearMilliseconds(DateTime? dateTime) + { + if (dateTime == null) + { + return null; + } + + var newDateTime = new DateTime( + dateTime.Value.Year, + dateTime.Value.Month, + dateTime.Value.Day, + dateTime.Value.Hour, + dateTime.Value.Minute, + dateTime.Value.Second, DateTimeKind.Utc); + return newDateTime; + } + + private SqlServerExecutionHistoryStore CreateStore() + { + var store = new SqlServerExecutionHistoryStore(); + store.SchedulerName = SchedulerName; + store.ConnectionString = ConnectionString; + store.TablePrefix = TablePrefix; + return store; + } + } +} \ No newline at end of file diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryTests.cs b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryTests.cs new file mode 100644 index 0000000..1bc6128 --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Impl/SqlServer/SqlServerExecutionHistoryTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Quartz.Impl; +using Quartz.Impl.Matchers; +using Quartz.Plugins.RecentHistory.Impl.SqlServer; +using Xunit; + +namespace Quartz.Plugins.RecentHistory.Tests.Integration.Impl.SqlServer +{ + public class SqlServerExecutionHistoryTests : IDisposable + { + protected string ConnectionString { get; set; } + public string SchedulerName { get; set; } + public string TablePrefix { get; set; } + + public SqlServerExecutionHistoryTests() + { + var config = new ConfigurationBuilder() + .AddJsonFile("appsettings.test.json") + .Build(); + + ConnectionString = config["Tests:ConnectionString"]; + SchedulerName = config["Tests:SchedulerName"]; + TablePrefix = config["Tests:TablePrefix"]; + } + + [Fact] + public async void SavesJob() + { + var sched = await SetupScheduler(); + try + { + var jobName = "JB" + Guid.NewGuid(); + await ScheduleTestJob(sched, jobName: jobName, jobGroup: "TEST"); + + sched.ListenerManager.AddJobListener(new TestJobListener(), EverythingMatcher.AllJobs()); + + var resetEvent = new ManualResetEventSlim(); + TestJobListener.JobWasExecutedCallback = (c, e) => { resetEvent.Set(); }; + + var store = CreateStore(); + await store.ClearSchedulerData(); + + var lastJob = (await store.FilterLast(1)).FirstOrDefault(); + + await sched.Start(); + + resetEvent.Wait(3 * 1000); + Assert.True(resetEvent.IsSet); + + var newLastJob = (await store.FilterLast(1)).FirstOrDefault(); + + Assert.NotNull(newLastJob); + if (lastJob != null) + { + Assert.NotEqual(lastJob.FireInstanceId, newLastJob.FireInstanceId); + } + + Assert.Equal($"TEST.{jobName}", newLastJob.Job); + } + finally + { + await sched.Shutdown(false); + } + } + + [Fact] + public async void Purges() + { + var sched = await SetupScheduler(); + try + { + var jobName = "JB" + Guid.NewGuid(); + await ScheduleTestJob(sched, jobName: jobName, jobGroup: "TEST"); + + sched.ListenerManager.AddJobListener(new TestJobListener(), EverythingMatcher.AllJobs()); + + var resetEvent = new ManualResetEventSlim(); + TestJobListener.JobWasExecutedCallback = (c, e) => { resetEvent.Set(); }; + + var store = CreateStore(); + await store.ClearSchedulerData(); + + var entryToBePurged = new ExecutionHistoryEntry + { + ActualFireTimeUtc = DateTime.UtcNow.Subtract(TimeSpan.FromDays(100)), + FireInstanceId = Guid.NewGuid().ToString(), + Job = "TEST.PurgeMe", + SchedulerInstanceId = Guid.NewGuid().ToString(), + SchedulerName = SchedulerName, + Trigger = Guid.NewGuid().ToString() + }; + await store.Save(entryToBePurged); + + var lastJobs = await store.FilterLast(2); + Assert.Contains(lastJobs, j => j.FireInstanceId == entryToBePurged.FireInstanceId); + + await sched.Start(); + + resetEvent.Wait(3 * 1000); + Assert.True(resetEvent.IsSet); + + var newLastJobs = await store.FilterLast(2); + Assert.DoesNotContain(newLastJobs, j => j.FireInstanceId == entryToBePurged.FireInstanceId); + } + finally + { + await sched.Shutdown(false); + } + } + + [Fact] + public async void IncrementsTotalJobsExecuted() + { + var sched = await SetupScheduler(); + try + { + await ScheduleTestJob(sched); + + sched.ListenerManager.AddJobListener(new TestJobListener(), EverythingMatcher.AllJobs()); + + var resetEvent = new ManualResetEventSlim(); + TestJobListener.JobWasExecutedCallback = (c, e) => + { + resetEvent.Set(); + }; + + var store = CreateStore(); + await store.ClearSchedulerData(); + + int currentCount = await store.GetTotalJobsExecuted(); + + await sched.Start(); + + resetEvent.Wait(3 * 1000); + Assert.True(resetEvent.IsSet); + + int newCount = await store.GetTotalJobsExecuted(); + Assert.Equal(currentCount + 1, newCount); + } + finally + { + await sched.Shutdown(false); + } + } + + [Fact] + public async void IncrementsTotalJobsFailed() + { + var sched = await SetupScheduler(); + try + { + await ScheduleTestJob(sched); + + sched.ListenerManager.AddJobListener(new TestJobListener(), EverythingMatcher.AllJobs()); + + var resetEvent = new ManualResetEventSlim(); + TestJobListener.JobWasExecutedCallback = (c, e) => + { + if (e != null) + { + resetEvent.Set(); + } + }; + TestJob.Callback = c => throw new Exception("FAILURE!"); + + var store = CreateStore(); + await store.ClearSchedulerData(); + + int currentCount = await store.GetTotalJobsFailed(); + + await sched.Start(); + + resetEvent.Wait(3 * 1000); + Assert.True(resetEvent.IsSet); + + int newCount = await store.GetTotalJobsFailed(); + Assert.Equal(currentCount + 1, newCount); + } + finally + { + await sched.Shutdown(false); + } + } + + private async Task SetupScheduler() + { + var properties = new NameValueCollection + { + ["quartz.scheduler.instanceName"] = SchedulerName, + ["quartz.plugin.recentHistory.type"] = typeof(SqlServerExecutionHistoryPlugin).AssemblyQualifiedName, + ["quartz.plugin.recentHistory.connectionString"] = ConnectionString + }; + + var sf = new StdSchedulerFactory(properties); + var sched = await sf.GetScheduler(); + return sched; + } + + private async Task ScheduleTestJob(IScheduler sched, + string jobName = "job1", + string jobGroup = "group1", + string triggerName = "trigger1", + string triggerGroup = "group1") + { + var job = JobBuilder.Create() + .WithIdentity(jobName, jobGroup) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(triggerName, triggerGroup) + .StartNow() + .Build(); + + await sched.ScheduleJob(job, trigger); + } + + private SqlServerExecutionHistoryStore CreateStore() + { + var store = new SqlServerExecutionHistoryStore(); + store.SchedulerName = SchedulerName; + store.ConnectionString = ConnectionString; + store.TablePrefix = TablePrefix; + return store; + } + + private void ResetCallbacks() + { + TestJobListener.JobWasExecutedCallback = null; + TestJob.Callback = null; + } + + public void Dispose() + { + ResetCallbacks(); + } + } +} diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj new file mode 100644 index 0000000..8a8792a --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj @@ -0,0 +1,41 @@ + + + + netcoreapp2.1 + + false + + + + + + + + + + + + + + + + + + + ..\..\..\..\..\Users\Jordan\.nuget\packages\microsoft.extensions.configuration\2.1.0\lib\netstandard2.0\Microsoft.Extensions.Configuration.dll + + + System + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/README.txt b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/README.txt new file mode 100644 index 0000000..1feea84 --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/README.txt @@ -0,0 +1,5 @@ +README +------ +Before running these integration tests you should first create the required database tables +by using the scripts from /database folder in the Git repo. After that, you should update +the connection string contained within appSettings.test.json. \ No newline at end of file diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJob.cs b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJob.cs new file mode 100644 index 0000000..03f429e --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJob.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading.Tasks; + +namespace Quartz.Plugins.RecentHistory.Tests.Integration +{ + public class TestJob : IJob + { + public static Action Callback { get; set; } + + public Task Execute(IJobExecutionContext context) + { + if (Callback != null) + { + Callback(context); + } + + return Task.CompletedTask; + } + } +} diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJobListener.cs b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJobListener.cs new file mode 100644 index 0000000..7e440e7 --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/TestJobListener.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Quartz.Plugins.RecentHistory.Tests.Integration +{ + public class TestJobListener : IJobListener + { + public static Action JobWasExecutedCallback { get; set; } + + public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = new CancellationToken()) + { + return Task.CompletedTask; + } + + public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = new CancellationToken()) + { + return Task.CompletedTask; + } + + public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException jobException, + CancellationToken cancellationToken = new CancellationToken()) + { + JobWasExecutedCallback(context, jobException); + return Task.CompletedTask; + } + + public string Name { get; set; } = nameof(TestJobListener); + } +} diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/appSettings.test.json b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/appSettings.test.json new file mode 100644 index 0000000..a5d0bd3 --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/appSettings.test.json @@ -0,0 +1,7 @@ +{ + "Tests": { + "SchedulerName": "Test", + "TablePrefix": "QRTZ_", + "ConnectionString": "Server=.\\SQLEXPRESS;Database=Scheduler;Trusted_Connection=True;" + } +} \ No newline at end of file diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/xunit.runner.json b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/xunit.runner.json new file mode 100644 index 0000000..015166b --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} \ No newline at end of file diff --git a/Source/Quartz.Plugins.RecentHistory/ExecutionHistoryPlugin.cs b/Source/Quartz.Plugins.RecentHistory/ExecutionHistoryPlugin.cs index 6729b29..79143a9 100644 --- a/Source/Quartz.Plugins.RecentHistory/ExecutionHistoryPlugin.cs +++ b/Source/Quartz.Plugins.RecentHistory/ExecutionHistoryPlugin.cs @@ -13,7 +13,7 @@ public class ExecutionHistoryPlugin : ISchedulerPlugin, IJobListener public string Name { get; set; } public Type StoreType { get; set; } - + public Task Initialize(string pluginName, IScheduler scheduler, CancellationToken cancellationToken = default(CancellationToken)) { Name = pluginName; @@ -29,11 +29,7 @@ public class ExecutionHistoryPlugin : ISchedulerPlugin, IJobListener if (_store == null) { - if (StoreType != null) - _store = (IExecutionHistoryStore)Activator.CreateInstance(StoreType); - - if (_store == null) - throw new Exception(nameof(StoreType) + " is not set."); + _store = CreateExecutionHistoryStore(); _scheduler.Context.SetExecutionHistoryStore(_store); } @@ -42,7 +38,7 @@ public class ExecutionHistoryPlugin : ISchedulerPlugin, IJobListener await _store.Purge(); } - + public Task Shutdown(CancellationToken cancellationToken = default(CancellationToken)) { return Task.FromResult(0); @@ -88,5 +84,13 @@ public class ExecutionHistoryPlugin : ISchedulerPlugin, IJobListener await _store.Save(entry); } } + + protected virtual IExecutionHistoryStore CreateExecutionHistoryStore() + { + if (StoreType != null) + return (IExecutionHistoryStore)Activator.CreateInstance(StoreType); + + throw new Exception(nameof(StoreType) + " is not set."); + } } } diff --git a/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerConstants.cs b/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerConstants.cs new file mode 100644 index 0000000..ca5049d --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerConstants.cs @@ -0,0 +1,30 @@ +namespace Quartz.Plugins.RecentHistory.Impl.SqlServer +{ + internal static class SqlServerConstants + { + internal const string TableExecutionHistoryEntries = "EXECUTION_HISTORY_ENTRIES"; + internal const string TableExecutionHistoryStats = "EXECUTION_HISTORY_STATS"; + + // TableExecutionHistoryEntries columns: + internal const string ColumnFireInstanceId = "FIRE_INSTANCE_ID"; + internal const string ColumnSchedulerInstanceId = "SCHEDULER_INSTANCE_ID"; + internal const string ColumnSchedulerName = "SCHED_NAME"; + internal const string ColumnJob = "JOB_NAME"; + internal const string ColumnTrigger = "TRIGGER_NAME"; + internal const string ColumnScheduledFireTimeUtc = "SCHEDULED_FIRE_TIME_UTC"; + internal const string ColumnActualFireTimeUtc = "ACTUAL_FIRE_TIME_UTC"; + internal const string ColumnRecovering = "RECOVERING"; + internal const string ColumnVetoed = "VETOED"; + internal const string ColumnFinishedTimeUtc = "FINISHED_TIME_UTC"; + internal const string ColumnExceptionMessage = "EXCEPTION_MESSAGE"; + + // TableExecutionHistoryStats columns: + // internal const string ColumnSchedulerName = "SCHED_NAME"; + internal const string ColumnStatName = "STAT_NAME"; + internal const string ColumnStatValue = "STAT_VALUE"; + + // Stat names: + internal const string StatTotalJobsExecuted = "TOTAL_JOBS_EXECUTED"; + internal const string StatTotalJobsFailed = "TOTAL_JOBS_FAILED"; + } +} diff --git a/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryPlugin.cs b/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryPlugin.cs new file mode 100644 index 0000000..2f7caf8 --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryPlugin.cs @@ -0,0 +1,29 @@ +using System; + +namespace Quartz.Plugins.RecentHistory.Impl.SqlServer +{ + public class SqlServerExecutionHistoryPlugin : ExecutionHistoryPlugin + { + public string ConnectionString { get; set; } + public string TablePrefix { get; set; } = "QRTZ_"; + public int PurgeIntervalInMinutes { get; set; } = 1; + public int EntryTTLInMinutes { get; set; } = 2; + + protected override IExecutionHistoryStore CreateExecutionHistoryStore() + { + if (StoreType != null && StoreType != typeof(SqlServerExecutionHistoryStore)) + { + throw new InvalidOperationException($"{nameof(SqlServerExecutionHistoryPlugin)} is only compatible with the {nameof(SqlServerExecutionHistoryStore)} store type"); + } + + var store = new SqlServerExecutionHistoryStore + { + ConnectionString = ConnectionString, + TablePrefix = TablePrefix, + PurgeIntervalInMinutes = PurgeIntervalInMinutes, + EntryTTLInMinutes = EntryTTLInMinutes + }; + return store; + } + } +} diff --git a/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryStore.cs b/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryStore.cs new file mode 100644 index 0000000..8e90af0 --- /dev/null +++ b/Source/Quartz.Plugins.RecentHistory/Impl/SqlServer/SqlServerExecutionHistoryStore.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Linq; +using System.Threading.Tasks; +using C = Quartz.Plugins.RecentHistory.Impl.SqlServer.SqlServerConstants; + +namespace Quartz.Plugins.RecentHistory.Impl.SqlServer +{ + public class SqlServerExecutionHistoryStore : IExecutionHistoryStore + { + private DateTime _nextPurgeTime = DateTime.UtcNow; + + public string SchedulerName { get; set; } + public string ConnectionString { get; set; } + public string TablePrefix { get; set; } + public int PurgeIntervalInMinutes { get; set; } + public int EntryTTLInMinutes { get; set; } + + public async Task Get(string fireInstanceId) + { + if (fireInstanceId == null) throw new ArgumentNullException(nameof(fireInstanceId)); + + string query = + $"SELECT * FROM {GetTableName(C.TableExecutionHistoryEntries)} \n" + + $"WHERE {C.ColumnFireInstanceId} = @FireInstanceId"; + + var entries = await ExecuteExecutionHistoryEntryQuery(query, c => c.Parameters.AddWithValue("@FireInstanceId", fireInstanceId)); + return entries.FirstOrDefault(); + } + + public async Task Save(ExecutionHistoryEntry entry) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + + if (_nextPurgeTime < DateTime.UtcNow) + { + _nextPurgeTime = DateTime.UtcNow.AddMinutes(PurgeIntervalInMinutes); + await Purge(); + } + + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await sqlConnection.OpenAsync(); + + string query = + $"MERGE {GetTableName(C.TableExecutionHistoryEntries)} AS [Target] \n" + + $"USING (SELECT @FireInstanceId AS {C.ColumnFireInstanceId}) AS [Source] \n" + + $"ON [Source].{C.ColumnFireInstanceId} = [Target].{C.ColumnFireInstanceId} \n" + + $"WHEN MATCHED THEN UPDATE SET \n" + + $"{C.ColumnFireInstanceId} = @FireInstanceId, {C.ColumnSchedulerInstanceId} = @SchedulerInstanceId, {C.ColumnSchedulerName} = @SchedulerName, \n" + + $"{C.ColumnJob} = @Job, {C.ColumnTrigger} = @Trigger, {C.ColumnScheduledFireTimeUtc} = @ScheduledFireTimeUtc, {C.ColumnActualFireTimeUtc} = @ActualFireTimeUtc, \n" + + $"{C.ColumnRecovering} = @Recovering, {C.ColumnVetoed} = @Vetoed, {C.ColumnFinishedTimeUtc} = @FinishedTimeUtc, {C.ColumnExceptionMessage} = @ExceptionMessage \n" + + $"WHEN NOT MATCHED THEN INSERT \n" + + $"({C.ColumnFireInstanceId}, {C.ColumnSchedulerInstanceId}, {C.ColumnSchedulerName}, \n" + + $"{C.ColumnJob}, {C.ColumnTrigger}, {C.ColumnScheduledFireTimeUtc}, {C.ColumnActualFireTimeUtc}, \n" + + $"{C.ColumnRecovering}, {C.ColumnVetoed}, {C.ColumnFinishedTimeUtc}, {C.ColumnExceptionMessage}) \n" + + $"VALUES (@FireInstanceId, @SchedulerInstanceId, @SchedulerName, @Job, @Trigger, @ScheduledFireTimeUtc, \n" + + $"@ActualFireTimeUtc, @Recovering, @Vetoed, @FinishedTimeUtc, @ExceptionMessage);"; + + using (var sqlCommand = new SqlCommand(query, sqlConnection)) + { + sqlCommand.Parameters.AddWithValue("@FireInstanceId", entry.FireInstanceId); + sqlCommand.Parameters.AddWithValue("@SchedulerInstanceId", entry.SchedulerInstanceId); + sqlCommand.Parameters.AddWithValue("@SchedulerName", entry.SchedulerName); + sqlCommand.Parameters.AddWithValue("@Job", entry.Job); + sqlCommand.Parameters.AddWithValue("@Trigger", entry.Trigger); + sqlCommand.Parameters.AddWithValue("@ScheduledFireTimeUtc", entry.ScheduledFireTimeUtc != null ? (object)entry.ScheduledFireTimeUtc : DBNull.Value); + sqlCommand.Parameters.AddWithValue("@ActualFireTimeUtc", entry.ActualFireTimeUtc); + sqlCommand.Parameters.AddWithValue("@Recovering", entry.Recovering); + sqlCommand.Parameters.AddWithValue("@Vetoed", entry.Vetoed); + sqlCommand.Parameters.AddWithValue("@FinishedTimeUtc", entry.FinishedTimeUtc != null ? (object)entry.FinishedTimeUtc : DBNull.Value); + sqlCommand.Parameters.AddWithValue("@ExceptionMessage", entry.ExceptionMessage != null ? (object)entry.ExceptionMessage : DBNull.Value); + + await sqlCommand.ExecuteNonQueryAsync(); + } + } + } + + public async Task Purge() + { + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await sqlConnection.OpenAsync(); + + string commandText = $"DELETE FROM {GetTableName(C.TableExecutionHistoryEntries)} WHERE {C.ColumnActualFireTimeUtc} < @PurgeThreshold"; + using (var sqlCommand = new SqlCommand(commandText, sqlConnection)) + { + sqlCommand.Parameters.AddWithValue("@PurgeThreshold", DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(EntryTTLInMinutes))); + + await sqlCommand.ExecuteNonQueryAsync(); + } + } + } + + public async Task> FilterLastOfEveryJob(int limitPerJob) + { + return await FilterLastOf(C.ColumnJob, limitPerJob); + } + + public async Task> FilterLastOfEveryTrigger(int limitPerTrigger) + { + return await FilterLastOf(C.ColumnTrigger, limitPerTrigger); + } + + protected async Task> FilterLastOf(string columnName, int limit) + { + string query = + $"WITH SELECTION AS ( \n" + + $" SELECT *, \n" + + $" ROW_NUMBER() OVER(PARTITION BY {columnName} ORDER BY {C.ColumnActualFireTimeUtc} DESC) AS ROW_KEY \n" + + $" FROM {GetTableName(C.TableExecutionHistoryEntries)} \n" + + $" WHERE {C.ColumnSchedulerName} = @SchedulerName \n" + + $") \n" + + $"SELECT * \n" + + $"FROM SELECTION \n" + + $"WHERE ROW_KEY <= {limit}"; + + return await ExecuteExecutionHistoryEntryQuery(query, c => c.Parameters.AddWithValue("@SchedulerName", SchedulerName)); + } + + public async Task> FilterLast(int limit) + { + string query = + $"SELECT TOP {limit} * FROM {GetTableName(C.TableExecutionHistoryEntries)} \n" + + $"WHERE {C.ColumnSchedulerName} = @SchedulerName \n" + + $"ORDER BY {C.ColumnActualFireTimeUtc} DESC"; + + return await ExecuteExecutionHistoryEntryQuery(query, c => c.Parameters.AddWithValue("@SchedulerName", SchedulerName)); + } + + public async Task GetTotalJobsExecuted() + { + try + { + return (int)await GetStatValue(C.StatTotalJobsExecuted); + } + catch (OverflowException) + { + /* should actually log here, but Quartz does not expose its + logging facilities to external plugins */ + return -1; + } + } + + public async Task GetTotalJobsFailed() + { + try + { + return (int)await GetStatValue(C.StatTotalJobsFailed); + } + catch (OverflowException) + { + /* should actually log here, but Quartz does not expose its + logging facilities to external plugins */ + return -1; + } + } + + public async Task IncrementTotalJobsExecuted() + { + await IncrementStatValue(C.StatTotalJobsExecuted); + } + + public async Task IncrementTotalJobsFailed() + { + await IncrementStatValue(C.StatTotalJobsFailed); + } + + public async Task ClearSchedulerData() + { + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await sqlConnection.OpenAsync(); + + string commandText = + $"DELETE FROM {GetTableName(C.TableExecutionHistoryEntries)} WHERE {C.ColumnSchedulerName} = @SchedulerName;\n" + + $"DELETE FROM {GetTableName(C.TableExecutionHistoryStats)} WHERE {C.ColumnSchedulerName} = @SchedulerName;"; + using (var sqlCommand = new SqlCommand(commandText, sqlConnection)) + { + sqlCommand.Parameters.AddWithValue("@SchedulerName", SchedulerName); + + await sqlCommand.ExecuteNonQueryAsync(); + } + } + } + + protected string GetTableName(string tableNameWithoutPrefix) + { + if (tableNameWithoutPrefix == null) throw new ArgumentNullException(nameof(tableNameWithoutPrefix)); + + return $"{TablePrefix}{tableNameWithoutPrefix}"; + } + + protected async Task> ExecuteExecutionHistoryEntryQuery(string query, Action sqlCommandModifier = null) + { + if (query == null) throw new ArgumentNullException(nameof(query)); + + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await sqlConnection.OpenAsync(); + + using (var sqlCommand = new SqlCommand(query, sqlConnection)) + { + sqlCommandModifier?.Invoke(sqlCommand); + + using (var sqlDataReader = await sqlCommand.ExecuteReaderAsync()) + { + var entries = new List(); + + while (await sqlDataReader.ReadAsync()) + { + var entry = new ExecutionHistoryEntry(); + await HydrateExecutionHistoryEntry(sqlDataReader, entry); + entries.Add(entry); + } + + return entries; + } + } + } + } + + protected async Task GetStatValue(string statName) + { + if (statName == null) throw new ArgumentNullException(nameof(statName)); + + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await sqlConnection.OpenAsync(); + + string query = + $"SELECT {C.ColumnStatValue} FROM {GetTableName(C.TableExecutionHistoryStats)} \n" + + $"WHERE {C.ColumnStatName} = @StatName AND {C.ColumnSchedulerName} = @SchedulerName"; + + using (var sqlCommand = new SqlCommand(query, sqlConnection)) + { + sqlCommand.Parameters.AddWithValue("@SchedulerName", SchedulerName); + sqlCommand.Parameters.AddWithValue("@StatName", statName); + + var scalar = await sqlCommand.ExecuteScalarAsync(); + if (scalar != null) + { + return (long)scalar; + } + + return 0; + } + } + } + + protected async Task IncrementStatValue(string statName) + { + if (statName == null) throw new ArgumentNullException(nameof(statName)); + + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await sqlConnection.OpenAsync(); + + string query = + $"MERGE {GetTableName(C.TableExecutionHistoryStats)} AS [Target] \n" + + $"USING (SELECT @StatName AS {C.ColumnStatName}, @SchedulerName AS {C.ColumnSchedulerName}) AS [Source] \n" + + $"ON [Source].{C.ColumnStatName} = [Target].{C.ColumnStatName} AND [Source].{C.ColumnSchedulerName} = [Target].{C.ColumnSchedulerName} \n" + + $"WHEN MATCHED THEN UPDATE SET \n" + + $"{C.ColumnStatValue} = {C.ColumnStatValue} + 1 \n" + + $"WHEN NOT MATCHED THEN INSERT \n" + + $"({C.ColumnSchedulerName}, {C.ColumnStatName}, {C.ColumnStatValue}) \n" + + $"VALUES (@SchedulerName, @StatName, 1);"; + + using (var sqlCommand = new SqlCommand(query, sqlConnection)) + { + sqlCommand.Parameters.AddWithValue("@SchedulerName", SchedulerName); + sqlCommand.Parameters.AddWithValue("@StatName", statName); + + try + { + await sqlCommand.ExecuteNonQueryAsync(); + } + catch (SqlException e) when (e.Number == 8115) // SQL overflow exception + { + /* should actually log here, but Quartz does not expose its + logging facilities to external plugins */ + } + } + } + } + + private async Task HydrateExecutionHistoryEntry(SqlDataReader sqlDataReader, ExecutionHistoryEntry entry) + { + var r = sqlDataReader; + + entry.ActualFireTimeUtc = r.GetDateTime(r.GetOrdinal(C.ColumnActualFireTimeUtc)); + entry.ExceptionMessage = await r.IsDBNullAsync(r.GetOrdinal(C.ColumnExceptionMessage)) ? + null : r.GetString(r.GetOrdinal(C.ColumnExceptionMessage)); + entry.FinishedTimeUtc = await r.IsDBNullAsync(r.GetOrdinal(C.ColumnFinishedTimeUtc)) ? + (DateTime?)null : r.GetDateTime(r.GetOrdinal(C.ColumnFinishedTimeUtc)); + entry.FireInstanceId = r.GetString(r.GetOrdinal(C.ColumnFireInstanceId)); + entry.Job = r.GetString(r.GetOrdinal(C.ColumnJob)); + entry.Recovering = r.GetBoolean(r.GetOrdinal(C.ColumnRecovering)); + entry.ScheduledFireTimeUtc = await r.IsDBNullAsync(r.GetOrdinal(C.ColumnScheduledFireTimeUtc)) ? + (DateTime?)null : r.GetDateTime(r.GetOrdinal(C.ColumnScheduledFireTimeUtc)); + entry.SchedulerInstanceId = r.GetString(r.GetOrdinal(C.ColumnSchedulerInstanceId)); + entry.SchedulerName = r.GetString(r.GetOrdinal(C.ColumnSchedulerName)); + entry.Trigger = r.GetString(r.GetOrdinal(C.ColumnTrigger)); + entry.Vetoed = r.GetBoolean(r.GetOrdinal(C.ColumnVetoed)); + } + } +} From 1261bf28f8951a44eb0c27cd1e76496eb8174329 Mon Sep 17 00:00:00 2001 From: Jordan van Gogh Date: Sun, 17 Mar 2019 11:47:10 +0100 Subject: [PATCH 2/2] removed invalid reference --- .../Quartz.Plugins.RecentHistory.Tests.Integration.csproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj index 8a8792a..ed58305 100644 --- a/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj +++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj @@ -7,7 +7,6 @@ - @@ -21,9 +20,6 @@ - - ..\..\..\..\..\Users\Jordan\.nuget\packages\microsoft.extensions.configuration\2.1.0\lib\netstandard2.0\Microsoft.Extensions.Configuration.dll - System