Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 54 additions & 25 deletions README.md

Large diffs are not rendered by default.

209 changes: 170 additions & 39 deletions app/API/PWBatchCoordinator.Codeunit.al

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions app/API/PWChunkContext.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ codeunit 99001 "PW Chunk Context"
KeyNotFoundErr: Label 'Key "%1" not found in chunk input.', Comment = '%1 = JSON key name';
NotRecordChunkErr: Label 'This chunk was not created via RunForRecords.';
NotInitializedErr: Label 'Chunk context has not been initialized.';
RecordSetChangedErr: Label 'The chunk''s record range no longer matches the data: the filtered record set is empty or has shrunk since the batch was created.';

procedure Init(var Chunk: Record "PW Batch Chunk")
var
Expand Down Expand Up @@ -202,9 +203,14 @@ codeunit 99001 "PW Chunk Context"
if FilterView <> '' then
RecRef.SetView(FilterView);

RecRef.FindSet();
// Chunk ranges are positional — if records were inserted or deleted
// since the batch was created, the ranges no longer match the data.
// Fail loudly instead of silently processing another chunk's records.
if not RecRef.FindSet() then
Error(RecordSetChangedErr);
if StartIndex > 1 then
RecRef.Next(StartIndex - 1);
if RecRef.Next(StartIndex - 1) <> StartIndex - 1 then
Error(RecordSetChangedErr);
end;

/// <summary>
Expand All @@ -222,6 +228,7 @@ codeunit 99001 "PW Chunk Context"
/// <param name="Result">The JSON object to store as the sole result.</param>
procedure SetResult(Result: JsonObject)
begin
CheckInitialized();
Clear(Results);
Results.Add(Result);
end;
Expand All @@ -232,6 +239,7 @@ codeunit 99001 "PW Chunk Context"
/// <param name="Result">The JSON object to append.</param>
procedure AppendResult(Result: JsonObject)
begin
CheckInitialized();
Results.Add(Result);
end;

Expand Down
25 changes: 20 additions & 5 deletions app/Engine/PWTaskDispatcher.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ codeunit 99002 "PW Task Dispatcher"
ErrorText: Text;
ErrorCallStack: Text;
begin
// UpdLock serializes the claim with the coordinator storing this chunk's
// session id in StartBatch and with dead-session recovery — a plain read
// could hit an optimistic-concurrency error on the Modify below.
Rec.ReadIsolation := IsolationLevel::UpdLock;
if not Rec.Get(Rec."Batch Id", Rec."Chunk Index") then
exit;
// Dead-session recovery may have already failed a chunk whose session
// was queued for too long — never resurrect a chunk that left Pending.
if Rec.Status <> "PW Chunk Status"::Pending then
exit;

Rec.Status := "PW Chunk Status"::Running;
Rec."Session Id" := SessionId();
Expand All @@ -29,21 +37,26 @@ codeunit 99002 "PW Task Dispatcher"
if Codeunit.Run(Codeunit::"PW Worker Runner", Rec) then begin
// Worker succeeded — results already saved by PW Worker Runner.
// Re-read to pick up changes made inside Codeunit.Run.
Rec.Get(Rec."Batch Id", Rec."Chunk Index");
// The chunk disappears if the caller ran Cleanup mid-batch —
// nothing left to persist.
if not Rec.Get(Rec."Batch Id", Rec."Chunk Index") then
exit;
Rec.Status := "PW Chunk Status"::Completed;
Rec."Completed At" := CurrentDateTime();
Rec.Modify();
// Persist chunk's Completed status before updating batch counters.
// If UpdateBatchCounters fails (e.g. lock timeout on PW Batch),
// the chunk is still correctly marked as Completed.
// The next finishing chunk will recompute counters correctly.
// WaitForCompletion reconciles the counters if no chunk finishes after this one.
Commit();
end else begin
// Worker failed — Codeunit.Run rolled back all DB changes.
// Re-read to ensure Rec is clean, consistent with the success branch.
Rec.Get(Rec."Batch Id", Rec."Chunk Index");
// Capture the error before any further record operations,
// then re-read to ensure Rec is clean, consistent with the success branch.
ErrorText := GetLastErrorText();
ErrorCallStack := GetLastErrorCallStack();
if not Rec.Get(Rec."Batch Id", Rec."Chunk Index") then
exit;
Rec."Error Message" := CopyStr(ErrorText, 1, MaxStrLen(Rec."Error Message"));
WriteFullErrorMessage(Rec, ErrorText);
WriteErrorCallStack(Rec, ErrorCallStack);
Expand Down Expand Up @@ -83,7 +96,9 @@ codeunit 99002 "PW Task Dispatcher"
// UpdLock on the Batch row serializes concurrent counter updates
// without disabling Tri-State Locking for the entire table.
Batch.ReadIsolation := IsolationLevel::UpdLock;
Batch.Get(BatchId);
// The batch disappears if the caller ran Cleanup mid-batch.
if not Batch.Get(BatchId) then
exit;

// ReadCommitted ensures we see committed chunk statuses only.
// Each dispatcher commits its chunk status before calling this procedure.
Expand Down
19 changes: 8 additions & 11 deletions app/Monitoring/PWBatchCleanup.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,18 @@ codeunit 99003 "PW Batch Cleanup"
procedure CleanupOlderThan(Hours: Integer)
var
Batch: Record "PW Batch";
Chunk: Record "PW Batch Chunk";
CutoffOffset: Duration;
begin
// Duration arithmetic — Hours * 3600000 as Integer overflows at ~596 hours.
CutoffOffset := Hours;
CutoffOffset := CutoffOffset * 3600000;

Batch.SetFilter(Status, '%1|%2|%3',
"PW Batch Status"::Completed,
"PW Batch Status"::PartialFailure,
"PW Batch Status"::Failed);
Batch.SetFilter("Completed At", '<>%1', 0DT);

if Batch.FindSet() then
repeat
if (CurrentDateTime() - Batch."Completed At") > Hours * 3600000 then begin
Chunk.SetRange("Batch Id", Batch.Id);
Chunk.DeleteAll();
Batch.Delete();
end;
until Batch.Next() = 0;
Batch.SetFilter("Completed At", '<>%1&<%2', 0DT, CurrentDateTime() - CutoffOffset);
// OnDelete on PW Batch removes the chunks.
Batch.DeleteAll(true);
end;
}
20 changes: 20 additions & 0 deletions app/Translations/Parallel Worker.g.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,26 @@
<note from="Developer" annotates="general" priority="2"></note>
<note from="Xliff Generator" annotates="general" priority="3">Table PW Batch Chunk - Field Status - Property Caption</note>
</trans-unit>
<trans-unit id="Codeunit 797980843 - NamedType 2314357299" size-unit="char" translate="yes" xml:space="preserve">
<source>Thread count must be at least 1.</source>
<note from="Developer" annotates="general" priority="2"></note>
<note from="Xliff Generator" annotates="general" priority="3">Codeunit PW Batch Coordinator - NamedType InvalidThreadCountErr</note>
</trans-unit>
<trans-unit id="Codeunit 797980843 - NamedType 3558797986" size-unit="char" translate="yes" xml:space="preserve">
<source>The payload key %1 is not allowed. Keys prefixed with $ are reserved for the framework.</source>
<note from="Developer" annotates="general" priority="2">%1 = JSON key name</note>
<note from="Xliff Generator" annotates="general" priority="3">Codeunit PW Batch Coordinator - NamedType ReservedKeyErr</note>
</trans-unit>
<trans-unit id="Codeunit 797980843 - NamedType 1294502618" size-unit="char" translate="yes" xml:space="preserve">
<source>Session terminated (timeout or server stop).</source>
<note from="Developer" annotates="general" priority="2"></note>
<note from="Xliff Generator" annotates="general" priority="3">Codeunit PW Batch Coordinator - NamedType SessionTerminatedErr</note>
</trans-unit>
<trans-unit id="Codeunit 797980843 - NamedType 2503317769" size-unit="char" translate="yes" xml:space="preserve">
<source>Cannot wait for a parallel batch while a write transaction is active. WaitForCompletion may commit internally during recovery, which would silently commit your changes.</source>
<note from="Developer" annotates="general" priority="2"></note>
<note from="Xliff Generator" annotates="general" priority="3">Codeunit PW Batch Coordinator - NamedType WaitWriteTransactionErr</note>
</trans-unit>
<trans-unit id="Codeunit 797980843 - NamedType 3772275472" size-unit="char" translate="yes" xml:space="preserve">
<source>Cannot start a parallel batch while a write transaction is active. Commit or rollback your changes before calling RunFor* methods, otherwise they would be silently committed.</source>
<note from="Developer" annotates="general" priority="2"></note>
Expand All @@ -143,6 +158,11 @@
<note from="Developer" annotates="general" priority="2"></note>
<note from="Xliff Generator" annotates="general" priority="3">Codeunit PW Chunk Context - NamedType NotRecordChunkErr</note>
</trans-unit>
<trans-unit id="Codeunit 914310053 - NamedType 2954270052" size-unit="char" translate="yes" xml:space="preserve">
<source>The chunk's record range no longer matches the data: the filtered record set is empty or has shrunk since the batch was created.</source>
<note from="Developer" annotates="general" priority="2"></note>
<note from="Xliff Generator" annotates="general" priority="3">Codeunit PW Chunk Context - NamedType RecordSetChangedErr</note>
</trans-unit>
<trans-unit id="Codeunit 3345984611 - NamedType 3198830347" size-unit="char" translate="yes" xml:space="preserve">
<source>Chunk %1: </source>
<note from="Developer" annotates="general" priority="2">%1 = chunk index</note>
Expand Down
Binary file modified app/Volodymyr Dvernytskyi_Parallel Worker_1.0.0.1.app
Binary file not shown.
Binary file modified test/Volodymyr Dvernytskyi_Parallel Worker Test_1.0.0.0.app
Binary file not shown.
12 changes: 12 additions & 0 deletions test/disabledtests.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,5 +202,17 @@
"codeunitId": 99209,
"codeunitName": "PW Integration Test",
"method": "BatchTimeoutReturnsFalseButSessionContinues"
},
{
"bug": "Need to set isolation disabled test runner. Currently not supported by AL-Go.",
"codeunitId": 99209,
"codeunitName": "PW Integration Test",
"method": "SessionIdStoredOnChunksAtStart"
},
{
"bug": "Need to set isolation disabled test runner. Currently not supported by AL-Go.",
"codeunitId": 99209,
"codeunitName": "PW Integration Test",
"method": "CleanupWhileSessionsRunningIsSafe"
}
]
27 changes: 27 additions & 0 deletions test/src/Helpers/PWTestHelper.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,31 @@ codeunit 99204 "PW Test Helper"
Chunk.Status := Status;
Chunk.Insert();
end;

procedure CreateChunkWithSession(BatchId: Guid; ChunkIndex: Integer; Status: Enum "PW Chunk Status"; SessionId: Integer)
var
Chunk: Record "PW Batch Chunk";
begin
Chunk."Batch Id" := BatchId;
Chunk."Chunk Index" := ChunkIndex;
Chunk.Status := Status;
Chunk."Session Id" := SessionId;
Chunk.Insert();
end;

procedure CreateChunkWithFullError(BatchId: Guid; ChunkIndex: Integer; ErrorText: Text)
var
Chunk: Record "PW Batch Chunk";
OutStream: OutStream;
begin
Chunk."Batch Id" := BatchId;
Chunk."Chunk Index" := ChunkIndex;
Chunk.Status := "PW Chunk Status"::Failed;
Chunk."Error Message" := CopyStr(ErrorText, 1, MaxStrLen(Chunk."Error Message"));
Chunk.Insert();

Chunk."Full Error Message".CreateOutStream(OutStream, TextEncoding::UTF8);
OutStream.WriteText(ErrorText);
Chunk.Modify();
end;
}
18 changes: 18 additions & 0 deletions test/src/Tests/PWBatchCleanupTest.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ codeunit 99203 "PW Batch Cleanup Test"
LibraryAssert.IsFalse(Batch.Get(BatchId), 'OnRun should delete old completed batch');
end;

[Test]
procedure CleanupOlderThanLargeHoursDoesNotOverflow()
// [SCENARIO 5.9] Hours large enough to overflow 32-bit millisecond math
// (Hours * 3600000 > MaxInt at ~596 hours) is handled without a runtime error
var
Cleanup: Codeunit "PW Batch Cleanup";
Batch: Record "PW Batch";
BatchId: Guid;
begin
BatchId := TestHelper.CreateBatchWithTimestamp(
"PW Batch Status"::Completed,
CurrentDateTime() - (48 * 3600000));

Cleanup.CleanupOlderThan(100000);

LibraryAssert.IsTrue(Batch.Get(BatchId), '48h-old batch should survive a 100000h threshold');
end;

[Test]
procedure MixedScenarioDeletesOnlyOldFinished()
// [SCENARIO 5.7] Mixed — only old finished batches are deleted
Expand Down
102 changes: 102 additions & 0 deletions test/src/Tests/PWChunkContextTest.Codeunit.al
Original file line number Diff line number Diff line change
Expand Up @@ -507,4 +507,106 @@ codeunit 99201 "PW Chunk Context Test"
Chunk.CalcFields("Result Payload");
LibraryAssert.IsFalse(Chunk."Result Payload".HasValue(), 'Result blob should be empty');
end;

[Test]
procedure SetResultErrorsBeforeInit()
// [SCENARIO 1.23] SetResult called before Init raises error
var
Ctx: Codeunit "PW Chunk Context";
Result: JsonObject;
begin
asserterror Ctx.SetResult(Result);

LibraryAssert.ExpectedError('Chunk context has not been initialized.');
end;

[Test]
procedure AppendResultErrorsBeforeInit()
// [SCENARIO 1.24] AppendResult called before Init raises error
var
Ctx: Codeunit "PW Chunk Context";
Result: JsonObject;
begin
asserterror Ctx.AppendResult(Result);

LibraryAssert.ExpectedError('Chunk context has not been initialized.');
end;

[Test]
procedure GetRecordRefErrorsWhenRecordSetIsEmpty()
// [SCENARIO 1.25] GetRecordRef fails loudly when the chunk's filtered set is now empty
var
Ctx: Codeunit "PW Chunk Context";
Chunk: Record "PW Batch Chunk";
SourceBatch: Record "PW Batch";
SourceRef: RecordRef;
TargetRef: RecordRef;
BatchId: Guid;
Payload: JsonObject;
begin
SourceBatch.SetRange("Company Name", 'PW_CTX_EMPTY');
SourceRef.GetTable(SourceBatch);

Payload.Add('$IsRecordChunk', true);
Payload.Add('$TableNo', Database::"PW Batch");
Payload.Add('$FilterView', SourceRef.GetView());
Payload.Add('$StartIndex', 1);
Payload.Add('$EndIndex', 5);
BatchId := TestHelper.CreateBatch("PW Batch Status"::Running);
TestHelper.CreateChunkWithPayload(BatchId, 1, Payload);
Chunk.Get(BatchId, 1);
Ctx.Init(Chunk);

asserterror Ctx.GetRecordRef(TargetRef);

LibraryAssert.ExpectedError('no longer matches the data');
end;

[Test]
procedure GetRecordRefErrorsWhenRecordSetShrank()
// [SCENARIO 1.26] GetRecordRef fails loudly when the set has fewer records
// than the chunk's start index — positioning would silently overlap another chunk
var
Ctx: Codeunit "PW Chunk Context";
Chunk: Record "PW Batch Chunk";
SourceBatch: Record "PW Batch";
SourceRef: RecordRef;
TargetRef: RecordRef;
BatchId: Guid;
Payload: JsonObject;
i: Integer;
begin
SourceBatch.SetRange("Company Name", 'PW_CTX_SHRUNK');
SourceBatch.DeleteAll();
for i := 1 to 2 do begin
Clear(SourceBatch);
SourceBatch.Id := CreateGuid();
SourceBatch.Status := "PW Batch Status"::Pending;
SourceBatch."Company Name" := 'PW_CTX_SHRUNK';
SourceBatch.Insert();
end;

SourceBatch.Reset();
SourceBatch.SetRange("Company Name", 'PW_CTX_SHRUNK');
SourceRef.GetTable(SourceBatch);

// Chunk expects to start at record 5 but only 2 records exist
Payload.Add('$IsRecordChunk', true);
Payload.Add('$TableNo', Database::"PW Batch");
Payload.Add('$FilterView', SourceRef.GetView());
Payload.Add('$StartIndex', 5);
Payload.Add('$EndIndex', 6);
BatchId := TestHelper.CreateBatch("PW Batch Status"::Running);
TestHelper.CreateChunkWithPayload(BatchId, 1, Payload);
Chunk.Get(BatchId, 1);
Ctx.Init(Chunk);

asserterror Ctx.GetRecordRef(TargetRef);

LibraryAssert.ExpectedError('no longer matches the data');

SourceBatch.Reset();
SourceBatch.SetRange("Company Name", 'PW_CTX_SHRUNK');
SourceBatch.DeleteAll();
end;
}
Loading
Loading