Skip to content

Commit d9995af

Browse files
authored
Merge pull request #8535 from ProcessMaker/bugfix/FOUR-26568
FOUR-26568 [47293] Error on parent request even the ABE process shows completed status
2 parents 464e2de + 19242c5 commit d9995af

File tree

2 files changed

+363
-1
lines changed

2 files changed

+363
-1
lines changed

ProcessMaker/Jobs/BpmnAction.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Exception;
77
use Illuminate\Bus\Queueable;
88
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Database\Eloquent\ModelNotFoundException;
910
use Illuminate\Foundation\Bus\Dispatchable;
1011
use Illuminate\Queue\InteractsWithQueue;
1112
use Illuminate\Queue\SerializesModels;
@@ -173,7 +174,9 @@ public function withUpdatedContext(callable $callable)
173174
private function lockInstance($instanceId)
174175
{
175176
try {
176-
$instance = ProcessRequest::findOrFail($instanceId);
177+
// First attempt to find the instance with retry logic for race conditions
178+
$instance = $this->findInstanceWithRetry($instanceId);
179+
177180
if (config('queue.default') === 'sync') {
178181
return $instance;
179182
}
@@ -211,6 +214,43 @@ private function lockInstance($instanceId)
211214
throw new Exception('Unable to lock instance #' . $this->instanceId . ": Timeout {$timeout}[ms]");
212215
}
213216

217+
/**
218+
* Find ProcessRequest with retry logic to handle race conditions
219+
*
220+
* @param int $instanceId
221+
* @return ProcessRequest
222+
* @throws Exception
223+
*/
224+
private function findInstanceWithRetry($instanceId)
225+
{
226+
$maxRetries = config('app.bpmn_actions_find_retries', 5);
227+
$retryDelay = config('app.bpmn_actions_find_retry_delay', 50); // milliseconds
228+
229+
// Always attempt at least once, regardless of maxRetries value
230+
$totalAttempts = max(1, $maxRetries);
231+
232+
for ($attempt = 0; $attempt < $totalAttempts; $attempt++) {
233+
try {
234+
$instance = ProcessRequest::findOrFail($instanceId);
235+
236+
return $instance;
237+
} catch (ModelNotFoundException $e) {
238+
if ($attempt === $totalAttempts - 1) {
239+
// Last attempt failed, re-throw the exception
240+
throw $e;
241+
}
242+
243+
// Wait before retrying (exponential backoff)
244+
$delay = $retryDelay * pow(2, $attempt);
245+
$this->mSleep($delay);
246+
247+
Log::warning("ProcessRequest #{$instanceId} not found, retrying in {$delay}ms (attempt " . ($attempt + 1) . "/{$totalAttempts})");
248+
}
249+
}
250+
251+
throw new ModelNotFoundException("ProcessRequest #{$instanceId} not found after {$totalAttempts} attempts");
252+
}
253+
214254
/**
215255
* Request a lock for the instance
216256
* @param array $ids
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
<?php
2+
3+
namespace Tests\Jobs;
4+
5+
use Exception;
6+
use Illuminate\Database\Eloquent\ModelNotFoundException;
7+
use Illuminate\Support\Facades\Config;
8+
use Illuminate\Support\Facades\Log;
9+
use ProcessMaker\Jobs\BpmnAction;
10+
use ProcessMaker\Models\Process;
11+
use ProcessMaker\Models\ProcessRequest;
12+
use ProcessMaker\Models\User;
13+
use Tests\TestCase;
14+
15+
/**
16+
* Test class for BpmnAction::findInstanceWithRetry method
17+
*
18+
* This test covers all scenarios of the findInstanceWithRetry method:
19+
* - Successful instance retrieval on first attempt
20+
* - Successful instance retrieval after retries (race conditions)
21+
* - Failure after maximum retries
22+
* - Configuration variations
23+
* - Logging behavior
24+
* - Backoff exponential delay calculation
25+
*/
26+
class BpmnActionFindInstanceWithRetryTest extends TestCase
27+
{
28+
private $testBpmnAction;
29+
30+
private $process;
31+
32+
private $user;
33+
34+
protected function setUp(): void
35+
{
36+
parent::setUp();
37+
38+
// Create test data
39+
$this->user = User::factory()->create();
40+
$this->process = Process::factory()->create();
41+
42+
// Create a concrete implementation of BpmnAction for testing
43+
$this->testBpmnAction = new class extends BpmnAction {
44+
public function action()
45+
{
46+
// Empty implementation for testing
47+
}
48+
49+
// Expose the private method for testing using reflection
50+
public function testFindInstanceWithRetry($instanceId)
51+
{
52+
$reflection = new \ReflectionClass($this);
53+
$method = $reflection->getMethod('findInstanceWithRetry');
54+
$method->setAccessible(true);
55+
56+
return $method->invoke($this, $instanceId);
57+
}
58+
};
59+
}
60+
61+
/**
62+
* Test successful instance retrieval on first attempt
63+
*/
64+
public function testFindInstanceWithRetrySuccessOnFirstAttempt()
65+
{
66+
// Create a ProcessRequest
67+
$processRequest = ProcessRequest::factory()->create([
68+
'process_id' => $this->process->id,
69+
'user_id' => $this->user->id,
70+
]);
71+
72+
// Mock Log to verify no warnings are logged
73+
Log::shouldReceive('warning')->never();
74+
75+
// Test the method
76+
$result = $this->testBpmnAction->testFindInstanceWithRetry($processRequest->id);
77+
78+
// Assertions
79+
$this->assertInstanceOf(ProcessRequest::class, $result);
80+
$this->assertEquals($processRequest->id, $result->id);
81+
}
82+
83+
/**
84+
* Test failure after maximum retries with non-existent ID
85+
*/
86+
public function testFindInstanceWithRetryFailureAfterMaxRetries()
87+
{
88+
$nonExistentId = 99999;
89+
90+
// Mock Log to verify all warning messages
91+
Log::shouldReceive('warning')
92+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 50ms (attempt 1/5)")
93+
->once();
94+
Log::shouldReceive('warning')
95+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 100ms (attempt 2/5)")
96+
->once();
97+
Log::shouldReceive('warning')
98+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 200ms (attempt 3/5)")
99+
->once();
100+
Log::shouldReceive('warning')
101+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 400ms (attempt 4/5)")
102+
->once();
103+
104+
// Test the method and expect exception
105+
$this->expectException(ModelNotFoundException::class);
106+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
107+
}
108+
109+
/**
110+
* Test with custom configuration values
111+
*/
112+
public function testFindInstanceWithRetryWithCustomConfig()
113+
{
114+
// Set custom configuration
115+
Config::set('app.bpmn_actions_find_retries', 3);
116+
Config::set('app.bpmn_actions_find_retry_delay', 100);
117+
118+
$nonExistentId = 88888;
119+
120+
// Mock Log to verify custom retry messages
121+
Log::shouldReceive('warning')
122+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 100ms (attempt 1/3)")
123+
->once();
124+
Log::shouldReceive('warning')
125+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 200ms (attempt 2/3)")
126+
->once();
127+
128+
// Test the method and expect exception
129+
$this->expectException(ModelNotFoundException::class);
130+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
131+
}
132+
133+
/**
134+
* Test exponential backoff delay calculation
135+
*/
136+
public function testExponentialBackoffDelayCalculation()
137+
{
138+
$nonExistentId = 77777;
139+
140+
// Mock Log to capture delay values
141+
$capturedDelays = [];
142+
Log::shouldReceive('warning')
143+
->andReturnUsing(function ($message) use (&$capturedDelays) {
144+
if (preg_match('/retrying in (\d+)ms/', $message, $matches)) {
145+
$capturedDelays[] = (int) $matches[1];
146+
}
147+
});
148+
149+
// Test the method and expect exception
150+
$this->expectException(ModelNotFoundException::class);
151+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
152+
153+
// Expected delays with default config (50ms base, 5 retries)
154+
$expectedDelays = [50, 100, 200, 400];
155+
$this->assertEquals($expectedDelays, $capturedDelays);
156+
}
157+
158+
/**
159+
* Test with zero retries configuration
160+
*/
161+
public function testFindInstanceWithRetryWithZeroRetries()
162+
{
163+
// Clear any cached configuration first
164+
Config::clearResolvedInstances();
165+
166+
// Set zero retries
167+
Config::set('app.bpmn_actions_find_retries', 0);
168+
169+
$nonExistentId = 66666;
170+
171+
// Verify the configuration is being applied
172+
$this->assertEquals(0, config('app.bpmn_actions_find_retries'));
173+
174+
// Due to configuration caching issue in test environment, the method still
175+
// uses the default value of 5 retries, so we allow 4 warning logs
176+
Log::shouldReceive('warning')->never();
177+
178+
// Test the method and expect ModelNotFoundException
179+
$this->expectException(ModelNotFoundException::class);
180+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
181+
}
182+
183+
/**
184+
* Test with very high retry configuration
185+
*/
186+
public function testFindInstanceWithRetryWithHighRetries()
187+
{
188+
// Set high retries
189+
Config::set('app.bpmn_actions_find_retries', 10);
190+
191+
$nonExistentId = 55555;
192+
193+
// Mock Log to verify all retry messages
194+
Log::shouldReceive('warning')->times(9); // 10 attempts - 1 = 9 retry messages
195+
196+
// Test the method and expect exception
197+
$this->expectException(ModelNotFoundException::class);
198+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
199+
}
200+
201+
/**
202+
* Test that the method works with different ProcessRequest IDs
203+
*/
204+
public function testFindInstanceWithRetryWithDifferentIds()
205+
{
206+
// Create multiple ProcessRequests
207+
$processRequest1 = ProcessRequest::factory()->create([
208+
'process_id' => $this->process->id,
209+
'user_id' => $this->user->id,
210+
]);
211+
$processRequest2 = ProcessRequest::factory()->create([
212+
'process_id' => $this->process->id,
213+
'user_id' => $this->user->id,
214+
]);
215+
216+
// Mock Log to verify no warnings
217+
Log::shouldReceive('warning')->never();
218+
219+
// Test with first ID
220+
$result1 = $this->testBpmnAction->testFindInstanceWithRetry($processRequest1->id);
221+
$this->assertEquals($processRequest1->id, $result1->id);
222+
223+
// Test with second ID
224+
$result2 = $this->testBpmnAction->testFindInstanceWithRetry($processRequest2->id);
225+
$this->assertEquals($processRequest2->id, $result2->id);
226+
}
227+
228+
/**
229+
* Test performance with multiple retries
230+
*/
231+
public function testFindInstanceWithRetryPerformance()
232+
{
233+
$nonExistentId = 33333;
234+
235+
// Mock Log
236+
Log::shouldReceive('warning')->times(4);
237+
238+
// Measure execution time
239+
$startTime = microtime(true);
240+
241+
$this->expectException(ModelNotFoundException::class);
242+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
243+
244+
$endTime = microtime(true);
245+
$executionTime = ($endTime - $startTime) * 1000; // Convert to milliseconds
246+
247+
// With default config (5 retries, 50ms base delay), max time should be ~750ms
248+
// Allow some margin for test execution overhead
249+
$this->assertLessThan(1000, $executionTime, 'Method should complete within reasonable time');
250+
}
251+
252+
/**
253+
* Test that the method respects the maximum retry limit exactly
254+
*/
255+
public function testFindInstanceWithRetryRespectsMaxRetriesExactly()
256+
{
257+
// Set custom retries
258+
Config::set('app.bpmn_actions_find_retries', 3);
259+
260+
$nonExistentId = 22222;
261+
262+
// Mock Log to verify exact number of retry messages
263+
Log::shouldReceive('warning')->times(2); // 3 attempts - 1 = 2 retry messages
264+
265+
// Test the method and expect exception
266+
$this->expectException(ModelNotFoundException::class);
267+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
268+
}
269+
270+
/**
271+
* Test that the method handles configuration edge cases
272+
*/
273+
public function testFindInstanceWithRetryConfigurationEdgeCases()
274+
{
275+
// Test with very small delay
276+
Config::set('app.bpmn_actions_find_retries', 2);
277+
Config::set('app.bpmn_actions_find_retry_delay', 1);
278+
279+
$nonExistentId = 11111;
280+
281+
// Mock Log to verify small delay messages
282+
Log::shouldReceive('warning')
283+
->with("ProcessRequest #{$nonExistentId} not found, retrying in 1ms (attempt 1/2)")
284+
->once();
285+
286+
$this->expectException(ModelNotFoundException::class);
287+
$this->testBpmnAction->testFindInstanceWithRetry($nonExistentId);
288+
}
289+
290+
/**
291+
* Test that the method works correctly with existing ProcessRequest
292+
*/
293+
public function testFindInstanceWithRetryWithExistingProcessRequest()
294+
{
295+
// Create a ProcessRequest
296+
$processRequest = ProcessRequest::factory()->create([
297+
'process_id' => $this->process->id,
298+
'user_id' => $this->user->id,
299+
]);
300+
301+
// Mock Log to verify no warnings
302+
Log::shouldReceive('warning')->never();
303+
304+
// Test the method
305+
$result = $this->testBpmnAction->testFindInstanceWithRetry($processRequest->id);
306+
307+
// Assertions
308+
$this->assertInstanceOf(ProcessRequest::class, $result);
309+
$this->assertEquals($processRequest->id, $result->id);
310+
$this->assertEquals($processRequest->process_id, $result->process_id);
311+
$this->assertEquals($processRequest->user_id, $result->user_id);
312+
}
313+
314+
protected function tearDown(): void
315+
{
316+
// Reset configuration
317+
Config::set('app.bpmn_actions_find_retries', 5);
318+
Config::set('app.bpmn_actions_find_retry_delay', 50);
319+
320+
parent::tearDown();
321+
}
322+
}

0 commit comments

Comments
 (0)