Repository: quartznet/quartznet (2026-03-29T14:09:20Z)

Issue:
Load nextFireTime for custom triggers.
**Is your feature request related to a problem? Please describe.**
I implemented a custom ITrigger for scheduling with rfc 5545 rrules.
I based my implementation mostly upon the `Quartz.Impl.Triggers.CronTriggerImpl` as suggested in this SO post: [https://stackoverflow.com/a/67082995/11879204](https://stackoverflow.com/a/67082995/11879204)

I can now schedule a job with such a trigger. The Trigger gets created and i can find the expected entries at the tables QRTZ_TRIGGERS and QRTZ_BLOB_TRIGGERS. The record in QRTZ_TRIGGERS has the expected nextFireTime value.

When the trigger is fired, a warning gets logged: `Trigger DEFAULT.TRIGGER returned null on nextFireTime and yet still exists in DB!` 

As far as i could observe the `SetNextFireTimeUtc` method is never called. The same issue exists for the previousFireTimeUtc.

After adding a public property for each field, the values get loaded as expected.

**Describe the solution you'd like**
Load the next and previous fire time values by default. 
Either use the appropriate setters or add properties to the `AbstractTrigger` class.



Discussion:

Patch:
diff --git a/src/Quartz/Impl/AdoJobStore/StdAdoDelegate.Triggers.cs b/src/Quartz/Impl/AdoJobStore/StdAdoDelegate.Triggers.cs
index af3300e17..e285d736d 100644
--- a/src/Quartz/Impl/AdoJobStore/StdAdoDelegate.Triggers.cs
+++ b/src/Quartz/Impl/AdoJobStore/StdAdoDelegate.Triggers.cs
@@ -757,6 +757,18 @@ protected virtual async ValueTask DeleteTriggerExtension(
             {
                 trigger = await GetObjectFromBlob<IOperableTrigger>(rs2, 0, cancellationToken).ConfigureAwait(false);
             }
+
+            if (trigger is not null)
+            {
+                trigger.MisfireInstruction = misFireInstr;
+                trigger.SetNextFireTimeUtc(nextFireTimeUtc);
+                trigger.SetPreviousFireTimeUtc(previousFireTimeUtc);
+
+                if (misfireOrigFireTime.HasValue && trigger is AbstractTrigger at)
+                {
+                    at.MisfiredFromFireTimeUtc = misfireOrigFireTime;
+                }
+            }
         }
         else
         {


Test patch:
diff --git a/src/Quartz.Tests.Unit/Impl/AdoJobStore/StdAdoDelegateTest.cs b/src/Quartz.Tests.Unit/Impl/AdoJobStore/StdAdoDelegateTest.cs
index f1dfbdbe3..123ed6d68 100644
--- a/src/Quartz.Tests.Unit/Impl/AdoJobStore/StdAdoDelegateTest.cs
+++ b/src/Quartz.Tests.Unit/Impl/AdoJobStore/StdAdoDelegateTest.cs
@@ -20,6 +20,7 @@
 #endregion
 
 using System.Collections;
+using System.Data;
 using System.Data.Common;
 using System.Runtime.Serialization;
 
@@ -29,6 +30,7 @@
 
 using Quartz.Impl.AdoJobStore;
 using Quartz.Impl.AdoJobStore.Common;
+using Quartz.Impl.Triggers;
 using Quartz.Simpl;
 using Quartz.Spi;
 using Quartz.Util;
@@ -370,6 +372,159 @@ public async Task TestSelectSimpleTriggerWithDeleteBeforeSelectExtendedProps()
         A.CallTo(() => persistenceDelegate.LoadExtendedTriggerProperties(A<ConnectionAndTransactionHolder>.Ignored, A<TriggerKey>.Ignored, CancellationToken.None)).MustHaveHappened();
     }
 
+    [Test]
+    public async Task TestSelectBlobTriggerPopulatesFireTimesFromDb()
+    {
+        var nextFireTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
+        var prevFireTime = new DateTimeOffset(2023, 12, 31, 12, 0, 0, TimeSpan.Zero);
+
+        var dbProvider = A.Fake<IDbProvider>();
+        var connection = A.Fake<DbConnection>();
+        var transaction = A.Fake<DbTransaction>();
+        var command = (DbCommand) A.Fake<StubCommand>();
+        var dbMetadata = new DbMetadata();
+        A.CallTo(() => dbProvider.Metadata).Returns(dbMetadata);
+        A.CallTo(() => dbProvider.CreateCommand()).Returns(command);
+
+        var dataReader = A.Fake<DbDataReader>();
+        A.CallTo(command).Where(x => x.Method.Name == "ExecuteDbDataReaderAsync")
+            .WithReturnType<Task<DbDataReader>>()
+            .Returns(Task.FromResult(dataReader));
+
+        A.CallTo(command).Where(x => x.Method.Name == "get_DbParameterCollection")
+            .WithReturnType<DbParameterCollection>()
+            .Returns(new StubParameterCollection());
+
+        A.CallTo(() => command.CommandText).Returns("");
+
+        A.CallTo(command).Where(x => x.Method.Name == "CreateDbParameter")
+            .WithReturnType<DbParameter>()
+            .Returns(new SqlParameter());
+
+        // Both reads succeed (first for TRIGGERS table, second for BLOB_TRIGGERS table)
+        A.CallTo(() => dataReader.ReadAsync(CancellationToken.None)).Returns(true);
+
+        A.CallTo(() => dataReader[AdoConstants.ColumnTriggerType]).Returns(AdoConstants.TriggerTypeBlob);
+        A.CallTo(() => dataReader[AdoConstants.ColumnJobName]).Returns("testJob");
+        A.CallTo(() => dataReader[AdoConstants.ColumnJobGroup]).Returns("DEFAULT");
+        A.CallTo(() => dataReader[AdoConstants.ColumnDescription]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnCalendarName]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnMifireInstruction]).Returns(2);
+        A.CallTo(() => dataReader[AdoConstants.ColumnPriority]).Returns(5);
+        A.CallTo(() => dataReader[AdoConstants.ColumnNextFireTime]).Returns(nextFireTime.UtcTicks);
+        A.CallTo(() => dataReader[AdoConstants.ColumnPreviousFireTime]).Returns(prevFireTime.UtcTicks);
+        A.CallTo(() => dataReader[AdoConstants.ColumnStartTime]).Returns(DateTimeOffset.UtcNow.UtcTicks);
+        A.CallTo(() => dataReader[AdoConstants.ColumnEndTime]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnMisfireOriginalFireTime]).Returns(DBNull.Value);
+        // Return true for IsDBNull on job data map column so that the map is read as null
+        A.CallTo(() => dataReader.IsDBNull(11)).Returns(true);
+
+        // Create a blob trigger with no fire times set (simulating a freshly deserialized custom trigger)
+        var blobTrigger = new SimpleTriggerImpl
+        {
+            Key = new TriggerKey("test", "DEFAULT"),
+            JobKey = new JobKey("testJob", "DEFAULT"),
+        };
+
+        var adoDelegate = new BlobTriggerOverrideDelegate(blobTrigger);
+        adoDelegate.Initialize(new DelegateInitializationArgs
+        {
+            TablePrefix = "QRTZ_",
+            InstanceId = "TESTSCHED",
+            InstanceName = "INSTANCE",
+            TypeLoadHelper = new SimpleTypeLoadHelper(),
+            UseProperties = false,
+            InitString = "",
+            DbProvider = dbProvider
+        });
+
+        var conn = new ConnectionAndTransactionHolder(connection, transaction);
+        IOperableTrigger trigger = await adoDelegate.SelectTrigger(conn, new TriggerKey("test"));
+
+        Assert.That(trigger, Is.Not.Null);
+        Assert.Multiple(() =>
+        {
+            Assert.That(trigger.GetNextFireTimeUtc(), Is.EqualTo(nextFireTime));
+            Assert.That(trigger.GetPreviousFireTimeUtc(), Is.EqualTo(prevFireTime));
+            Assert.That(trigger.MisfireInstruction, Is.EqualTo(2));
+        });
+    }
+
+    [Test]
+    public async Task TestSelectBlobTriggerPopulatesMisfiredFromFireTimeUtcFromDb()
+    {
+        var nextFireTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
+        var misfireOrigFireTime = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero);
+
+        var dbProvider = A.Fake<IDbProvider>();
+        var connection = A.Fake<DbConnection>();
+        var transaction = A.Fake<DbTransaction>();
+        var command = (DbCommand) A.Fake<StubCommand>();
+        var dbMetadata = new DbMetadata();
+        A.CallTo(() => dbProvider.Metadata).Returns(dbMetadata);
+        A.CallTo(() => dbProvider.CreateCommand()).Returns(command);
+
+        var dataReader = A.Fake<DbDataReader>();
+        A.CallTo(command).Where(x => x.Method.Name == "ExecuteDbDataReaderAsync")
+            .WithReturnType<Task<DbDataReader>>()
+            .Returns(Task.FromResult(dataReader));
+
+        A.CallTo(command).Where(x => x.Method.Name == "get_DbParameterCollection")
+            .WithReturnType<DbParameterCollection>()
+            .Returns(new StubParameterCollection());
+
+        A.CallTo(() => command.CommandText).Returns("");
+
+        A.CallTo(command).Where(x => x.Method.Name == "CreateDbParameter")
+            .WithReturnType<DbParameter>()
+            .Returns(new SqlParameter());
+
+        A.CallTo(() => dataReader.ReadAsync(CancellationToken.None)).Returns(true);
+
+        A.CallTo(() => dataReader[AdoConstants.ColumnTriggerType]).Returns(AdoConstants.TriggerTypeBlob);
+        A.CallTo(() => dataReader[AdoConstants.ColumnJobName]).Returns("testJob");
+        A.CallTo(() => dataReader[AdoConstants.ColumnJobGroup]).Returns("DEFAULT");
+        A.CallTo(() => dataReader[AdoConstants.ColumnDescription]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnCalendarName]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnMifireInstruction]).Returns(1);
+        A.CallTo(() => dataReader[AdoConstants.ColumnPriority]).Returns(5);
+        A.CallTo(() => dataReader[AdoConstants.ColumnNextFireTime]).Returns(nextFireTime.UtcTicks);
+        A.CallTo(() => dataReader[AdoConstants.ColumnPreviousFireTime]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnStartTime]).Returns(DateTimeOffset.UtcNow.UtcTicks);
+        A.CallTo(() => dataReader[AdoConstants.ColumnEndTime]).Returns(DBNull.Value);
+        A.CallTo(() => dataReader[AdoConstants.ColumnMisfireOriginalFireTime]).Returns(misfireOrigFireTime.UtcTicks);
+        A.CallTo(() => dataReader.IsDBNull(11)).Returns(true);
+
+        var blobTrigger = new SimpleTriggerImpl
+        {
+            Key = new TriggerKey("test", "DEFAULT"),
+            JobKey = new JobKey("testJob", "DEFAULT"),
+        };
+
+        var adoDelegate = new BlobTriggerOverrideDelegate(blobTrigger);
+        adoDelegate.Initialize(new DelegateInitializationArgs
+        {
+            TablePrefix = "QRTZ_",
+            InstanceId = "TESTSCHED",
+            InstanceName = "INSTANCE",
+            TypeLoadHelper = new SimpleTypeLoadHelper(),
+            UseProperties = false,
+            InitString = "",
+            DbProvider = dbProvider
+        });
+
+        var conn = new ConnectionAndTransactionHolder(connection, transaction);
+        IOperableTrigger trigger = await adoDelegate.SelectTrigger(conn, new TriggerKey("test"));
+
+        Assert.That(trigger, Is.Not.Null);
+        Assert.Multiple(() =>
+        {
+            Assert.That(trigger.GetNextFireTimeUtc(), Is.EqualTo(nextFireTime));
+            Assert.That(trigger.MisfireInstruction, Is.EqualTo(1));
+            Assert.That(((AbstractTrigger) trigger).MisfiredFromFireTimeUtc, Is.EqualTo(misfireOrigFireTime));
+        });
+    }
+
     [Test]
     public void ShouldSupportAssemblyQualifiedTriggerPersistenceDelegates()
     {
@@ -402,6 +557,31 @@ protected override ITriggerPersistenceDelegate FindTriggerPersistenceDelegate(st
             return testDelegate;
         }
     }
+
+    /// <summary>
+    /// Test subclass that bypasses actual blob deserialization to return a pre-built trigger,
+    /// allowing tests to verify that SelectTrigger sets fire times from DB columns on blob triggers.
+    /// </summary>
+    private class BlobTriggerOverrideDelegate : StdAdoDelegate
+    {
+        private readonly IOperableTrigger blobTrigger;
+
+        public BlobTriggerOverrideDelegate(IOperableTrigger blobTrigger)
+        {
+            this.blobTrigger = blobTrigger;
+        }
+
+#pragma warning disable CS8632
+        protected override ValueTask<T?> GetObjectFromBlob<T>(DbDataReader rs, int colIndex, CancellationToken cancellationToken = default) where T : class
+        {
+            if (typeof(T) == typeof(IOperableTrigger))
+            {
+                return new ValueTask<T?>((T?) (object) blobTrigger);
+            }
+            return base.GetObjectFromBlob<T>(rs, colIndex, cancellationToken);
+        }
+#pragma warning restore CS8632
+    }
 }
 
 public abstract class StubCommand : DbCommand