Repository: quartznet/quartznet (2026-03-29T09:41:40Z)

Issue:
IJobStore.TriggersFired(...) is expected not to skip any triggers
In `RAMJobStore.TriggersFired(...)`, we skip a trigger if - for example - it was deleted since it was acquired.
However, in [this](https://github.com/quartznet/quartznet/blob/b8dda01ca01fdcffc9b1b38a32050aa15198b2b0/src/Quartz/Core/QuartzSchedulerThread.cs#L406) line of `QuartzSchedulerThread.Run()` we expect the index of the bundle to correspond with the index in `triggers`. If not we end up working with the wrong trigger.

@lahma Can you have a look at this? I'd be happy to discuss possible fixes with you.

The documentation for `IJobStore.TriggersFired(...)` could also use an update:

```cs
       /// <summary>
        /// Inform the <see cref="IJobStore" /> that the scheduler is now firing the
        /// given <see cref="ITrigger" /> (executing its associated <see cref="IJob" />),
        /// that it had previously acquired (reserved).
        /// </summary>
        /// <returns>
        /// May return null if all the triggers or their calendars no longer exist, or
        /// if the trigger was not successfully put into the 'executing'
        /// state.  Preference is to return an empty list if none of the triggers
        /// could be fired.
        /// </returns>
        Task<IReadOnlyCollection<TriggerFiredResult>> TriggersFired(
            IReadOnlyCollection<IOperableTrigger> triggers,
            CancellationToken cancellationToken = default);
```

**Note:**
For performance and memory usage, it would be probably be great if we could change `IJobStore.AcquireNextTriggers(...)` return a **Task<IReadOnlyList<IOperableTrigger>>** and change `IJobStore.TriggersFired(...)` to take a **IReadOnlyList<IOperableTrigger>** as argument and return a **Task<IReadOnlyCollection<TriggerFiredResult>>**.

If we indeed expect `IJobStore.TriggersFired(...)` to return the same number of entries than it received as input for the `triggers` argument, we can even have it return **Task<TriggerFiredResult[]>**.

If you want to consider this (for **v4**), we should of course first measure the benefit.




Discussion:
I'm not sure how corner case the problem is, so far I can't remember getting feedback about problems..

I think job store could also return concrete types, like List<T>, especially when we usually never share internal state and always create a new list. End users interact with IScheduler and not IJobStore.
For all **IJobStore** implementation that we ship, it can only happen for **RAMJoreStore** and this only in one of the following cases:

* The acquired trigger has been deleted since.
* The acquired trigger is no longer in state **Acquired**.
* The calender of the trigger no longer exists.

These conditions can also happen for other job stores (that derive from **JobStoreSupport**), but there we handle this correctly by returning **TriggerFiredResult** with neither **TriggerFiredBundle** nor **Exception** set.

I propose to do the same for **RAMJobStore** and fix and clarify the documentation for `IJobStore.TriggersFired(...)`.

Are you ok with me creating a separate PR (with - in this case - a P for proposal) that changes some methods of **IJobStore** (and preferrable also **IListenerManager**) to either use concrete types, or **IReadOnlyList\<T>** (instead of **IReadOnlyCollection\<T>**?  In general, I prefer returning an array instead of **List\<T>** as it better conveys the fact that the value is read-only (even though you can still update individual entries, of course).
If ok, I'll make a proposal, benchmark it and we should discuss the changes case by case.



Patch:
diff --git a/src/Quartz/Simpl/RAMJobStore.cs b/src/Quartz/Simpl/RAMJobStore.cs
index 00c48dd90..d4f0dee06 100644
--- a/src/Quartz/Simpl/RAMJobStore.cs
+++ b/src/Quartz/Simpl/RAMJobStore.cs
@@ -1906,19 +1906,21 @@ public virtual async ValueTask<List<TriggerFiredResult>> TriggersFired(IReadOnly
         await lockObject.WaitAsync(cancellationToken).ConfigureAwait(false);
         try
         {
-            List<TriggerFiredResult> results = [];
+            List<TriggerFiredResult> results = new(triggers.Count);
 
             foreach (IOperableTrigger trigger in triggers)
             {
                 // was the trigger deleted since being acquired?
                 if (!triggersByKey.TryGetValue(trigger.Key, out var tw))
                 {
+                    results.Add(new TriggerFiredResult((TriggerFiredBundle?) null));
                     continue;
                 }
 
                 // was the trigger completed, paused, blocked, etc. since being acquired?
                 if (tw.state != InternalTriggerState.Acquired)
                 {
+                    results.Add(new TriggerFiredResult((TriggerFiredBundle?) null));
                     continue;
                 }
 
@@ -1928,6 +1930,7 @@ public virtual async ValueTask<List<TriggerFiredResult>> TriggersFired(IReadOnly
                     calendarsByName.TryGetValue(tw.Trigger.CalendarName, out cal);
                     if (cal is null)
                     {
+                        results.Add(new TriggerFiredResult((TriggerFiredBundle?) null));
                         continue;
                     }
                 }


Test patch:
diff --git a/src/Quartz.Tests.Unit/Simpl/RAMJobStoreTest.cs b/src/Quartz.Tests.Unit/Simpl/RAMJobStoreTest.cs
index 3c1da696c..027274fc9 100644
--- a/src/Quartz.Tests.Unit/Simpl/RAMJobStoreTest.cs
+++ b/src/Quartz.Tests.Unit/Simpl/RAMJobStoreTest.cs
@@ -660,6 +660,88 @@ public async Task TestScheduledFireTimeUtc_NoMisfire_ReturnsScheduledTime()
             "ScheduledFireTimeUtc should match the trigger's scheduled fire time when no misfire occurred");
     }
 
+    /// <summary>
+    /// Regression test for #1386: TriggersFired must return a result entry for every input trigger,
+    /// using a null bundle for skipped triggers, so that QuartzSchedulerThread can correlate
+    /// results by index position.
+    /// </summary>
+    [Test]
+    public async Task TriggersFired_DeletedTrigger_ReturnsNullBundleInsteadOfSkipping()
+    {
+        DateTimeOffset d = DateTimeOffset.UtcNow;
+
+        IOperableTrigger trigger1 = new SimpleTriggerImpl("trigger1", "triggerGroup1", fJobDetail.Key.Name, fJobDetail.Key.Group, d.AddSeconds(1), d.AddSeconds(200), 2, TimeSpan.FromSeconds(2));
+        IOperableTrigger trigger2 = new SimpleTriggerImpl("trigger2", "triggerGroup1", fJobDetail.Key.Name, fJobDetail.Key.Group, d.AddSeconds(1), d.AddSeconds(200), 2, TimeSpan.FromSeconds(2));
+        IOperableTrigger trigger3 = new SimpleTriggerImpl("trigger3", "triggerGroup1", fJobDetail.Key.Name, fJobDetail.Key.Group, d.AddSeconds(1), d.AddSeconds(200), 2, TimeSpan.FromSeconds(2));
+
+        trigger1.ComputeFirstFireTimeUtc(null);
+        trigger2.ComputeFirstFireTimeUtc(null);
+        trigger3.ComputeFirstFireTimeUtc(null);
+
+        await fJobStore.StoreTrigger(trigger1, false);
+        await fJobStore.StoreTrigger(trigger2, false);
+        await fJobStore.StoreTrigger(trigger3, false);
+
+        // Acquire all three triggers
+        DateTimeOffset firstFireTime = trigger1.GetNextFireTimeUtc()!.Value;
+        var acquired = await fJobStore.AcquireNextTriggers(firstFireTime.AddSeconds(10), 3, TimeSpan.Zero);
+        Assert.That(acquired, Has.Count.EqualTo(3), "Should acquire all 3 triggers");
+
+        // Delete trigger2 between acquire and fire — simulates the race condition
+        Assert.That(await fJobStore.RemoveTrigger(trigger2.Key), Is.True, "trigger2 should be removed");
+
+        // Fire all acquired triggers
+        var results = await fJobStore.TriggersFired(acquired);
+
+        // Result count must match input count for correct index correlation
+        Assert.That(results, Has.Count.EqualTo(3),
+            "TriggersFired must return one result per input trigger to maintain index alignment with QuartzSchedulerThread");
+
+        // trigger1 and trigger3 should have non-null bundles
+        Assert.That(results[0].TriggerFiredBundle, Is.Not.Null, "trigger1 should have fired successfully");
+        Assert.That(results[2].TriggerFiredBundle, Is.Not.Null, "trigger3 should have fired successfully");
+
+        // trigger2 (deleted) should have a null bundle
+        Assert.That(results[1].TriggerFiredBundle, Is.Null,
+            "Deleted trigger should produce a null bundle, not be omitted from results");
+    }
+
+    /// <summary>
+    /// Regression test for #1386: TriggersFired returns null bundle for triggers
+    /// that changed state (e.g., paused) between acquire and fire.
+    /// </summary>
+    [Test]
+    public async Task TriggersFired_PausedTrigger_ReturnsNullBundleInsteadOfSkipping()
+    {
+        DateTimeOffset d = DateTimeOffset.UtcNow;
+
+        IOperableTrigger trigger1 = new SimpleTriggerImpl("trigger1", "triggerGroup1", fJobDetail.Key.Name, fJobDetail.Key.Group, d.AddSeconds(1), d.AddSeconds(200), 2, TimeSpan.FromSeconds(2));
+        IOperableTrigger trigger2 = new SimpleTriggerImpl("trigger2", "triggerGroup1", fJobDetail.Key.Name, fJobDetail.Key.Group, d.AddSeconds(1), d.AddSeconds(200), 2, TimeSpan.FromSeconds(2));
+
+        trigger1.ComputeFirstFireTimeUtc(null);
+        trigger2.ComputeFirstFireTimeUtc(null);
+
+        await fJobStore.StoreTrigger(trigger1, false);
+        await fJobStore.StoreTrigger(trigger2, false);
+
+        // Acquire both triggers
+        DateTimeOffset firstFireTime = trigger1.GetNextFireTimeUtc()!.Value;
+        var acquired = await fJobStore.AcquireNextTriggers(firstFireTime.AddSeconds(10), 2, TimeSpan.Zero);
+        Assert.That(acquired, Has.Count.EqualTo(2));
+
+        // Pause trigger group between acquire and fire
+        await fJobStore.PauseTriggers(GroupMatcher<TriggerKey>.GroupEquals("triggerGroup1"));
+
+        // Fire all acquired triggers
+        var results = await fJobStore.TriggersFired(acquired);
+
+        // Both triggers should have entries — paused ones get null bundles
+        Assert.That(results, Has.Count.EqualTo(2),
+            "TriggersFired must return one result per input trigger even when some are paused");
+        Assert.That(results[0].TriggerFiredBundle, Is.Null, "paused trigger1 should have null bundle");
+        Assert.That(results[1].TriggerFiredBundle, Is.Null, "paused trigger2 should have null bundle");
+    }
+
     [DisallowConcurrentExecution]
     private class DisallowConcurrentNoOpJob : IJob
     {