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..ed58305
--- /dev/null
+++ b/Source/Quartz.Plugins.RecentHistory.Tests.Integration/Quartz.Plugins.RecentHistory.Tests.Integration.csproj
@@ -0,0 +1,37 @@
+
+
+
+ netcoreapp2.1
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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));
+ }
+ }
+}