From 460794d21d6a703e41c4a201be6bd68303924153 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Wed, 9 Sep 2026 11:29:14 +0200 Subject: [PATCH 1/8] Fix retention continuation scheduling for waiting jobs Recognize Waiting retention jobs as pending continuations without restarting them or replacing their scheduled-task IDs. Preserve Ready and On Hold restart behavior and add deterministic regression coverage. AB#649571 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RetentionPolicyJQ.Codeunit.al | 16 ++- .../Tests/Misc/JobQueueEntryTests.Codeunit.al | 130 ++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index da7d1bf3fcb..713a5cf509b 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -36,7 +36,6 @@ codeunit 3997 "Retention Policy JQ" [EventSubscriber(ObjectType::Codeunit, Codeunit::"Apply Retention Policy", 'OnApplyRetentionPolicyRecordLimitExceeded', '', true, true)] local procedure ScheduleJobQueueEntryOnApplyRetentionPolicyRecordLimitExceeded(ApplyAllRetentionPolicies: Boolean; UserInvokedRun: Boolean; var Handled: Boolean) var - JobQueueEntry: Record "Job Queue Entry"; RetentionPolicyLog: Codeunit "Retention Policy Log"; begin if Handled then begin @@ -61,18 +60,27 @@ codeunit 3997 "Retention Policy JQ" RetentionPolicyLog.LogInfo(RetentionPolicyLogCategory::"Retention Policy - Schedule", RescheduleOnLimitExceededLbl); + ScheduleContinuation(); + Handled := true; + end; + + internal procedure ScheduleContinuation() + var + JobQueueEntry: Record "Job Queue Entry"; + begin JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); - JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"); + JobQueueEntry.SetFilter(Status, '%1|%2|%3', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold", JobQueueEntry.Status::Waiting); if JobQueueEntry.IsEmpty() then JobQueueEntry.ScheduleJobQueueEntryForLater(Codeunit::"Retention Policy JQ", CurrentDateTime(), JobQueueCategoryTok, '') else begin JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); JobQueueEntry.FindFirst(); - JobQueueEntry.Restart(); + // The dispatcher activates Waiting entries when their category is available. + if JobQueueEntry.Status <> JobQueueEntry.Status::Waiting then + JobQueueEntry.Restart(); end; - Handled := true; end; internal procedure SetSessionId(SessionId: Integer) diff --git a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al index 0ece5d4d19e..90342cf005d 100644 --- a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al +++ b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al @@ -1,3 +1,5 @@ +using System.DataAdministration; + codeunit 139018 "Job Queue Entry Tests" { Subtype = Test; @@ -748,6 +750,134 @@ codeunit 139018 "Job Queue Entry Tests" Assert.AreNotEqual(0DT, JobQueueLogEntry."End Date/Time", 'End Date/Time should be set after finalization'); end; + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [Scope('OnPrem')] + procedure RetentionContinuationPreservesWaitingJob() + var + JobQueueEntry: Record "Job Queue Entry"; + RunningJobQueueEntry: Record "Job Queue Entry"; + WaitingJobQueueEntry: Record "Job Queue Entry"; + ExpectedJobQueueEntry: Record "Job Queue Entry"; + RetentionPolicyJQ: Codeunit "Retention Policy JQ"; + begin + // [SCENARIO 649571] Repeated continuation requests preserve an existing Waiting job and its scheduled task. + InitializeRetentionPolicyJobQueue(JobQueueEntry); + CreateRetentionPolicyJobQueueEntry(RunningJobQueueEntry, RunningJobQueueEntry.Status::"In Process"); + CreateRetentionPolicyJobQueueEntry(WaitingJobQueueEntry, WaitingJobQueueEntry.Status::Waiting); + ExpectedJobQueueEntry := WaitingJobQueueEntry; + + BindSubscription(this); + RetentionPolicyJQ.ScheduleContinuation(); + RetentionPolicyJQ.ScheduleContinuation(); + UnbindSubscription(this); + + Assert.AreEqual(2, JobQueueEntry.Count(), 'A Waiting continuation must prevent duplicate retention jobs.'); + WaitingJobQueueEntry.Get(ExpectedJobQueueEntry.ID); + WaitingJobQueueEntry.TestField(Status, WaitingJobQueueEntry.Status::Waiting); + WaitingJobQueueEntry.TestField("System Task ID", ExpectedJobQueueEntry."System Task ID"); + WaitingJobQueueEntry.TestField("Earliest Start Date/Time", ExpectedJobQueueEntry."Earliest Start Date/Time"); + WaitingJobQueueEntry.TestField("No. of Attempts to Run", ExpectedJobQueueEntry."No. of Attempts to Run"); + RunningJobQueueEntry.Get(RunningJobQueueEntry.ID); + RunningJobQueueEntry.TestField(Status, RunningJobQueueEntry.Status::"In Process"); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [Scope('OnPrem')] + procedure RetentionContinuationRestartsReadyJob() + var + JobQueueEntry: Record "Job Queue Entry"; + begin + // [SCENARIO 649571] A Ready retention job is still restarted rather than duplicated. + VerifyRetentionContinuationRestartsJob(JobQueueEntry.Status::Ready); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [Scope('OnPrem')] + procedure RetentionContinuationRestartsOnHoldJob() + var + JobQueueEntry: Record "Job Queue Entry"; + begin + // [SCENARIO 649571] An On Hold retention job is still restarted rather than duplicated. + VerifyRetentionContinuationRestartsJob(JobQueueEntry.Status::"On Hold"); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [Scope('OnPrem')] + procedure RetentionContinuationCreatedWithoutPendingJob() + var + JobQueueEntry: Record "Job Queue Entry"; + RunningJobQueueEntry: Record "Job Queue Entry"; + UnrelatedWaitingJobQueueEntry: Record "Job Queue Entry"; + RetentionPolicyJQ: Codeunit "Retention Policy JQ"; + begin + // [SCENARIO 649571] Running retention jobs and Waiting jobs for another object do not prevent a continuation. + InitializeRetentionPolicyJobQueue(JobQueueEntry); + CreateRetentionPolicyJobQueueEntry(RunningJobQueueEntry, RunningJobQueueEntry.Status::"In Process"); + CreateRetentionPolicyJobQueueEntry(UnrelatedWaitingJobQueueEntry, UnrelatedWaitingJobQueueEntry.Status::Waiting); + UnrelatedWaitingJobQueueEntry."Object ID to Run" := Codeunit::"Job Queue - Enqueue"; + UnrelatedWaitingJobQueueEntry.Modify(); + + BindSubscription(this); + RetentionPolicyJQ.ScheduleContinuation(); + UnbindSubscription(this); + + Assert.AreEqual(2, JobQueueEntry.Count(), 'A continuation must be created when no retention job is pending.'); + JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Ready); + JobQueueEntry.FindFirst(); + JobQueueEntry.TestField("Job Queue Category Code", 'RETENTION'); + JobQueueEntry.TestField("Recurring Job", false); + JobQueueEntry.TestField("System Task ID"); + RunningJobQueueEntry.Get(RunningJobQueueEntry.ID); + RunningJobQueueEntry.TestField(Status, RunningJobQueueEntry.Status::"In Process"); + UnrelatedWaitingJobQueueEntry.Get(UnrelatedWaitingJobQueueEntry.ID); + UnrelatedWaitingJobQueueEntry.TestField(Status, UnrelatedWaitingJobQueueEntry.Status::Waiting); + end; + + local procedure InitializeRetentionPolicyJobQueue(var JobQueueEntry: Record "Job Queue Entry") + begin + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); + JobQueueEntry.DeleteAll(); + end; + + local procedure CreateRetentionPolicyJobQueueEntry(var JobQueueEntry: Record "Job Queue Entry"; InitialStatus: Option) + begin + Clear(JobQueueEntry); + CreateJobQueueEntry(JobQueueEntry, InitialStatus); + JobQueueEntry."Object Type to Run" := JobQueueEntry."Object Type to Run"::Codeunit; + JobQueueEntry."Object ID to Run" := Codeunit::"Retention Policy JQ"; + JobQueueEntry."Job Queue Category Code" := 'RETENTION'; + JobQueueEntry."System Task ID" := CreateGuid(); + JobQueueEntry."Earliest Start Date/Time" := CurrentDateTime() + 60000; + JobQueueEntry.Modify(); + end; + + local procedure VerifyRetentionContinuationRestartsJob(InitialStatus: Option) + var + JobQueueEntry: Record "Job Queue Entry"; + ExistingJobQueueEntry: Record "Job Queue Entry"; + RetentionPolicyJQ: Codeunit "Retention Policy JQ"; + SystemTaskId: Guid; + begin + InitializeRetentionPolicyJobQueue(JobQueueEntry); + CreateRetentionPolicyJobQueueEntry(ExistingJobQueueEntry, InitialStatus); + SystemTaskId := ExistingJobQueueEntry."System Task ID"; + + BindSubscription(this); + RetentionPolicyJQ.ScheduleContinuation(); + UnbindSubscription(this); + + Assert.AreEqual(1, JobQueueEntry.Count(), 'The existing retention job must be reused.'); + ExistingJobQueueEntry.Get(ExistingJobQueueEntry.ID); + ExistingJobQueueEntry.TestField(Status, ExistingJobQueueEntry.Status::Ready); + ExistingJobQueueEntry.TestField("No. of Attempts to Run", 0); + Assert.AreNotEqual(SystemTaskId, ExistingJobQueueEntry."System Task ID", 'The existing retention job must be restarted.'); + end; + local procedure CreateJobQueueEntry(var JobQueueEntry: Record "Job Queue Entry"; InitialStatus: Option) begin JobQueueEntry.Init(); From c5ce055ff9d7310151dfa31f278eb9c5ff57c540 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Wed, 9 Sep 2026 12:41:31 +0200 Subject: [PATCH 2/8] Fix retention continuation lookup race Replace the separate existence check and bare lookup with a guarded FindFirst under UpdLock. Schedule a continuation when no pending entry matches, while preserving Waiting jobs without restarting them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index 713a5cf509b..99491c64282 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -68,19 +68,16 @@ codeunit 3997 "Retention Policy JQ" var JobQueueEntry: Record "Job Queue Entry"; begin - JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); + JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); JobQueueEntry.SetFilter(Status, '%1|%2|%3', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold", JobQueueEntry.Status::Waiting); - if JobQueueEntry.IsEmpty() then + if not JobQueueEntry.FindFirst() then JobQueueEntry.ScheduleJobQueueEntryForLater(Codeunit::"Retention Policy JQ", CurrentDateTime(), JobQueueCategoryTok, '') - else begin - JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); - JobQueueEntry.FindFirst(); + else // The dispatcher activates Waiting entries when their category is available. if JobQueueEntry.Status <> JobQueueEntry.Status::Waiting then JobQueueEntry.Restart(); - end; end; internal procedure SetSessionId(SessionId: Integer) From aeea331c6531d4648f2115d395bf44df47d92387 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Wed, 9 Sep 2026 15:22:34 +0200 Subject: [PATCH 3/8] Avoid update locks for waiting retention continuations Use a ReadCommitted fast path for Waiting jobs, then guard the update-locked re-read before restarting actionable jobs. Clear stale record identity before creating a replacement continuation. Correct the retention test fixture's attempt counter and remove an ignored using directive that caused AL0789 in Tests-Misc. AB#649571 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RetentionPolicyJQ.Codeunit.al | 22 ++++++++++++++----- .../Tests/Misc/JobQueueEntryTests.Codeunit.al | 3 +-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index 99491c64282..923ad380575 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -68,16 +68,26 @@ codeunit 3997 "Retention Policy JQ" var JobQueueEntry: Record "Job Queue Entry"; begin - JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); + JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); JobQueueEntry.SetFilter(Status, '%1|%2|%3', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold", JobQueueEntry.Status::Waiting); - if not JobQueueEntry.FindFirst() then - JobQueueEntry.ScheduleJobQueueEntryForLater(Codeunit::"Retention Policy JQ", CurrentDateTime(), JobQueueCategoryTok, '') - else + if JobQueueEntry.FindFirst() then begin // The dispatcher activates Waiting entries when their category is available. - if JobQueueEntry.Status <> JobQueueEntry.Status::Waiting then - JobQueueEntry.Restart(); + if JobQueueEntry.Status = JobQueueEntry.Status::Waiting then + exit; + + JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); + if JobQueueEntry.FindFirst() then begin + if JobQueueEntry.Status <> JobQueueEntry.Status::Waiting then + JobQueueEntry.Restart(); + exit; + end; + end; + + // Clear any primary key retained from a match that disappeared before the locked re-read. + Clear(JobQueueEntry); + JobQueueEntry.ScheduleJobQueueEntryForLater(Codeunit::"Retention Policy JQ", CurrentDateTime(), JobQueueCategoryTok, ''); end; internal procedure SetSessionId(SessionId: Integer) diff --git a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al index 90342cf005d..76ce770ed50 100644 --- a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al +++ b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al @@ -1,5 +1,3 @@ -using System.DataAdministration; - codeunit 139018 "Job Queue Entry Tests" { Subtype = Test; @@ -848,6 +846,7 @@ codeunit 139018 "Job Queue Entry Tests" begin Clear(JobQueueEntry); CreateJobQueueEntry(JobQueueEntry, InitialStatus); + JobQueueEntry."No. of Attempts to Run" := 3; JobQueueEntry."Object Type to Run" := JobQueueEntry."Object Type to Run"::Codeunit; JobQueueEntry."Object ID to Run" := Codeunit::"Retention Policy JQ"; JobQueueEntry."Job Queue Category Code" := 'RETENTION'; From d89593b85a46aaaa8810e54c67c3183df2aeefec Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Thu, 10 Sep 2026 09:37:52 +0200 Subject: [PATCH 4/8] Read persisted timestamp in retention continuation tests Reload the retention job fixture after Modify so exact preservation assertions compare the persisted DateTime precision before and after scheduling, not an unrounded CurrentDateTime value against a database read. Keep all assertions and production scheduling behavior unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al index 76ce770ed50..bbb82732678 100644 --- a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al +++ b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al @@ -853,6 +853,8 @@ codeunit 139018 "Job Queue Entry Tests" JobQueueEntry."System Task ID" := CreateGuid(); JobQueueEntry."Earliest Start Date/Time" := CurrentDateTime() + 60000; JobQueueEntry.Modify(); + // Capture the persisted DateTime precision before checking that scheduling preserves it. + JobQueueEntry.Get(JobQueueEntry.ID); end; local procedure VerifyRetentionContinuationRestartsJob(InitialStatus: Option) From ff544d57a7672155bc0dbf735334414f067c5c94 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Fri, 11 Sep 2026 14:58:31 +0200 Subject: [PATCH 5/8] Prioritize waiting retention continuations Check Waiting entries separately before selecting Ready or On Hold jobs. Report whether scheduling occurred so success telemetry follows an actual create or restart, while retaining guarded lookups and safe creation fallback. Add deterministic mixed-state regressions that put restartable jobs first in persisted key order and verify repeated requests preserve both entries and report no scheduling. AB#649571 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RetentionPolicyJQ.Codeunit.al | 39 +++++---- .../Tests/Misc/JobQueueEntryTests.Codeunit.al | 82 ++++++++++++++++++- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index 923ad380575..744dd671adf 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -58,36 +58,43 @@ codeunit 3997 "Retention Policy JQ" exit; end; - RetentionPolicyLog.LogInfo(RetentionPolicyLogCategory::"Retention Policy - Schedule", RescheduleOnLimitExceededLbl); - - ScheduleContinuation(); + if ScheduleContinuation() then + RetentionPolicyLog.LogInfo(RetentionPolicyLogCategory::"Retention Policy - Schedule", RescheduleOnLimitExceededLbl); Handled := true; end; - internal procedure ScheduleContinuation() + internal procedure ScheduleContinuation(): Boolean var JobQueueEntry: Record "Job Queue Entry"; begin JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); - JobQueueEntry.SetFilter(Status, '%1|%2|%3', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold", JobQueueEntry.Status::Waiting); + // The dispatcher activates Waiting entries when their category is available. + JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); + if JobQueueEntry.FindFirst() then + exit(false); + + JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"); + JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); if JobQueueEntry.FindFirst() then begin - // The dispatcher activates Waiting entries when their category is available. - if JobQueueEntry.Status = JobQueueEntry.Status::Waiting then - exit; - - JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); - if JobQueueEntry.FindFirst() then begin - if JobQueueEntry.Status <> JobQueueEntry.Status::Waiting then - JobQueueEntry.Restart(); - exit; - end; + if not (JobQueueEntry.Status in [JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"]) then + exit(false); + + JobQueueEntry.Restart(); + exit(true); end; - // Clear any primary key retained from a match that disappeared before the locked re-read. + // A restartable entry may have become Waiting before the locked lookup. + JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); + JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); + if JobQueueEntry.FindFirst() then + exit(false); + + // Enqueue inserts only when no primary key is retained from a previous lookup. Clear(JobQueueEntry); JobQueueEntry.ScheduleJobQueueEntryForLater(Codeunit::"Retention Policy JQ", CurrentDateTime(), JobQueueCategoryTok, ''); + exit(true); end; internal procedure SetSessionId(SessionId: Integer) diff --git a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al index bbb82732678..5f364e13ed3 100644 --- a/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al +++ b/src/Layers/W1/Tests/Misc/JobQueueEntryTests.Codeunit.al @@ -766,8 +766,8 @@ codeunit 139018 "Job Queue Entry Tests" ExpectedJobQueueEntry := WaitingJobQueueEntry; BindSubscription(this); - RetentionPolicyJQ.ScheduleContinuation(); - RetentionPolicyJQ.ScheduleContinuation(); + Assert.IsFalse(RetentionPolicyJQ.ScheduleContinuation(), 'Preserving a Waiting job must not report scheduling.'); + Assert.IsFalse(RetentionPolicyJQ.ScheduleContinuation(), 'Preserving a Waiting job must not report scheduling.'); UnbindSubscription(this); Assert.AreEqual(2, JobQueueEntry.Count(), 'A Waiting continuation must prevent duplicate retention jobs.'); @@ -780,6 +780,28 @@ codeunit 139018 "Job Queue Entry Tests" RunningJobQueueEntry.TestField(Status, RunningJobQueueEntry.Status::"In Process"); end; + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [Scope('OnPrem')] + procedure RetentionContinuationPrefersWaitingOverReadyJob() + var + JobQueueEntry: Record "Job Queue Entry"; + begin + // [SCENARIO 649571] A Waiting continuation takes priority over a Ready job that sorts before it. + VerifyRetentionContinuationPrefersWaitingJob(JobQueueEntry.Status::Ready); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + [Scope('OnPrem')] + procedure RetentionContinuationPrefersWaitingOverOnHoldJob() + var + JobQueueEntry: Record "Job Queue Entry"; + begin + // [SCENARIO 649571] A Waiting continuation takes priority over an On Hold job that sorts before it. + VerifyRetentionContinuationPrefersWaitingJob(JobQueueEntry.Status::"On Hold"); + end; + [Test] [TransactionModel(TransactionModel::AutoRollback)] [Scope('OnPrem')] @@ -820,7 +842,7 @@ codeunit 139018 "Job Queue Entry Tests" UnrelatedWaitingJobQueueEntry.Modify(); BindSubscription(this); - RetentionPolicyJQ.ScheduleContinuation(); + Assert.IsTrue(RetentionPolicyJQ.ScheduleContinuation(), 'Creating a retention continuation must report scheduling.'); UnbindSubscription(this); Assert.AreEqual(2, JobQueueEntry.Count(), 'A continuation must be created when no retention job is pending.'); @@ -857,6 +879,58 @@ codeunit 139018 "Job Queue Entry Tests" JobQueueEntry.Get(JobQueueEntry.ID); end; + local procedure VerifyRetentionContinuationPrefersWaitingJob(InitialStatus: Option) + var + JobQueueEntry: Record "Job Queue Entry"; + RestartableJobQueueEntry: Record "Job Queue Entry"; + WaitingJobQueueEntry: Record "Job Queue Entry"; + ExpectedRestartableJobQueueEntry: Record "Job Queue Entry"; + ExpectedWaitingJobQueueEntry: Record "Job Queue Entry"; + RetentionPolicyJQ: Codeunit "Retention Policy JQ"; + begin + InitializeRetentionPolicyJobQueue(JobQueueEntry); + CreateRetentionPolicyJobQueueEntry(RestartableJobQueueEntry, InitialStatus); + CreateRetentionPolicyJobQueueEntry(WaitingJobQueueEntry, InitialStatus); + + // Assign Waiting to the last persisted ID so the original mixed-status lookup finds the restartable job first. + JobQueueEntry.SetCurrentKey(ID); + JobQueueEntry.FindLast(); + WaitingJobQueueEntry := JobQueueEntry; + WaitingJobQueueEntry.Status := WaitingJobQueueEntry.Status::Waiting; + WaitingJobQueueEntry.Modify(); + WaitingJobQueueEntry.Get(WaitingJobQueueEntry.ID); + ExpectedWaitingJobQueueEntry := WaitingJobQueueEntry; + + JobQueueEntry.SetFilter(Status, '%1|%2|%3', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold", JobQueueEntry.Status::Waiting); + JobQueueEntry.FindFirst(); + Assert.IsTrue(JobQueueEntry.Status = InitialStatus, 'The mixed-status lookup must return the restartable job first.'); + RestartableJobQueueEntry := JobQueueEntry; + ExpectedRestartableJobQueueEntry := RestartableJobQueueEntry; + JobQueueEntry.SetRange(Status); + Assert.AreEqual(2, JobQueueEntry.Count(), 'The fixture must contain both pending retention jobs.'); + WaitingJobQueueEntry.TestField("System Task ID"); + RestartableJobQueueEntry.TestField("System Task ID"); + WaitingJobQueueEntry.TestField("No. of Attempts to Run"); + RestartableJobQueueEntry.TestField("No. of Attempts to Run"); + + BindSubscription(this); + Assert.IsFalse(RetentionPolicyJQ.ScheduleContinuation(), 'A Waiting job must prevent scheduling a restartable job.'); + Assert.IsFalse(RetentionPolicyJQ.ScheduleContinuation(), 'A Waiting job must prevent scheduling a restartable job.'); + UnbindSubscription(this); + + Assert.AreEqual(2, JobQueueEntry.Count(), 'Both pending retention jobs must be preserved without duplicates.'); + WaitingJobQueueEntry.Get(ExpectedWaitingJobQueueEntry.ID); + WaitingJobQueueEntry.TestField(Status, ExpectedWaitingJobQueueEntry.Status); + WaitingJobQueueEntry.TestField("System Task ID", ExpectedWaitingJobQueueEntry."System Task ID"); + WaitingJobQueueEntry.TestField("Earliest Start Date/Time", ExpectedWaitingJobQueueEntry."Earliest Start Date/Time"); + WaitingJobQueueEntry.TestField("No. of Attempts to Run", ExpectedWaitingJobQueueEntry."No. of Attempts to Run"); + RestartableJobQueueEntry.Get(ExpectedRestartableJobQueueEntry.ID); + RestartableJobQueueEntry.TestField(Status, ExpectedRestartableJobQueueEntry.Status); + RestartableJobQueueEntry.TestField("System Task ID", ExpectedRestartableJobQueueEntry."System Task ID"); + RestartableJobQueueEntry.TestField("Earliest Start Date/Time", ExpectedRestartableJobQueueEntry."Earliest Start Date/Time"); + RestartableJobQueueEntry.TestField("No. of Attempts to Run", ExpectedRestartableJobQueueEntry."No. of Attempts to Run"); + end; + local procedure VerifyRetentionContinuationRestartsJob(InitialStatus: Option) var JobQueueEntry: Record "Job Queue Entry"; @@ -869,7 +943,7 @@ codeunit 139018 "Job Queue Entry Tests" SystemTaskId := ExistingJobQueueEntry."System Task ID"; BindSubscription(this); - RetentionPolicyJQ.ScheduleContinuation(); + Assert.IsTrue(RetentionPolicyJQ.ScheduleContinuation(), 'Restarting an existing retention job must report scheduling.'); UnbindSubscription(this); Assert.AreEqual(1, JobQueueEntry.Count(), 'The existing retention job must be reused.'); From 1673a42dff00d7fdf13b632a6b617cf43d885232 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Wed, 16 Sep 2026 13:09:11 +0200 Subject: [PATCH 6/8] Use existence checks for waiting retention jobs Replace both read-only Waiting probes with IsEmpty to avoid materializing unused rows. Preserve the guarded update-locked lookup for restartable jobs and existing Waiting-first scheduling behavior. AB#649571 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index 744dd671adf..909d2e3e6a4 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -72,7 +72,7 @@ codeunit 3997 "Retention Policy JQ" JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); // The dispatcher activates Waiting entries when their category is available. JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); - if JobQueueEntry.FindFirst() then + if not JobQueueEntry.IsEmpty() then exit(false); JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"); @@ -88,7 +88,7 @@ codeunit 3997 "Retention Policy JQ" // A restartable entry may have become Waiting before the locked lookup. JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); - if JobQueueEntry.FindFirst() then + if not JobQueueEntry.IsEmpty() then exit(false); // Enqueue inserts only when no primary key is retained from a previous lookup. From d30b84afaa03d8530dc61e33ca961b3132b30112 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Thu, 17 Sep 2026 09:16:40 +0200 Subject: [PATCH 7/8] Remove redundant retention continuation status check The update-locked FindFirst already filters for Ready or On Hold entries. Remove the unreachable status fallback while preserving both Waiting checks and the existing restart and creation paths. AB#649571 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index 909d2e3e6a4..3f059da332b 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -78,9 +78,6 @@ codeunit 3997 "Retention Policy JQ" JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"); JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); if JobQueueEntry.FindFirst() then begin - if not (JobQueueEntry.Status in [JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"]) then - exit(false); - JobQueueEntry.Restart(); exit(true); end; From 8369aa7e5e55bcca06a23e7f79bb049ba9ac8a49 Mon Sep 17 00:00:00 2001 From: Darrick Joo Date: Thu, 17 Sep 2026 09:21:57 +0200 Subject: [PATCH 8/8] Extract retention Waiting continuation check Share the repeated read-only Waiting query in a local helper with its own ReadCommitted record. Preserve both Waiting probes and keep the locked restart/create decision in ScheduleContinuation. AB#649571 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../RetentionPolicyJQ.Codeunit.al | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al index 3f059da332b..3bd1ebeab30 100644 --- a/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al +++ b/src/Layers/W1/BaseApp/System/RetentionPolicy/RetentionPolicyJQ.Codeunit.al @@ -67,14 +67,12 @@ codeunit 3997 "Retention Policy JQ" var JobQueueEntry: Record "Job Queue Entry"; begin - JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); - JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); - JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); // The dispatcher activates Waiting entries when their category is available. - JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); - if not JobQueueEntry.IsEmpty() then + if WaitingContinuationExists() then exit(false); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); JobQueueEntry.SetFilter(Status, '%1|%2', JobQueueEntry.Status::Ready, JobQueueEntry.Status::"On Hold"); JobQueueEntry.ReadIsolation(IsolationLevel::UpdLock); if JobQueueEntry.FindFirst() then begin @@ -83,9 +81,7 @@ codeunit 3997 "Retention Policy JQ" end; // A restartable entry may have become Waiting before the locked lookup. - JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); - JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); - if not JobQueueEntry.IsEmpty() then + if WaitingContinuationExists() then exit(false); // Enqueue inserts only when no primary key is retained from a previous lookup. @@ -94,6 +90,17 @@ codeunit 3997 "Retention Policy JQ" exit(true); end; + local procedure WaitingContinuationExists(): Boolean + var + JobQueueEntry: Record "Job Queue Entry"; + begin + JobQueueEntry.ReadIsolation(IsolationLevel::ReadCommitted); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"Retention Policy JQ"); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange(Status, JobQueueEntry.Status::Waiting); + exit(not JobQueueEntry.IsEmpty()); + end; + internal procedure SetSessionId(SessionId: Integer) begin CurrSessionId := SessionId