From c4140ae4f3987603786b0e5015fe9796655221db Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Mon, 17 Aug 2026 12:58:31 -0400 Subject: [PATCH 1/2] feat: implement inline task execution and context reuse in bpmn engine for sequential script and service tasks. --- ProcessMaker/BpmnEngine.php | 35 +++ ProcessMaker/Jobs/BpmnAction.php | 131 +++++++++- ProcessMaker/Jobs/BpmnContextReuseGuard.php | 64 +++++ ProcessMaker/Models/ProcessRequest.php | 1 + .../Nayra/Managers/WorkflowManagerDefault.php | 49 ++++ .../ExecutionInstanceRepository.php | 31 +++ ProcessMaker/Repositories/TokenRepository.php | 2 + ...ion_revision_to_process_requests_table.php | 30 +++ tests/Feature/BpmnContextReuseGuardTest.php | 224 ++++++++++++++++++ tests/Feature/BpmnInlineLockTransferTest.php | 31 +++ 10 files changed, 585 insertions(+), 13 deletions(-) create mode 100644 ProcessMaker/Jobs/BpmnContextReuseGuard.php create mode 100644 database/migrations/2026_08_14_000000_add_execution_revision_to_process_requests_table.php create mode 100644 tests/Feature/BpmnContextReuseGuardTest.php create mode 100644 tests/Feature/BpmnInlineLockTransferTest.php diff --git a/ProcessMaker/BpmnEngine.php b/ProcessMaker/BpmnEngine.php index cc53db496f..1a1d1133bc 100644 --- a/ProcessMaker/BpmnEngine.php +++ b/ProcessMaker/BpmnEngine.php @@ -20,6 +20,18 @@ class BpmnEngine implements EngineInterface { use EngineTrait; + public const INACTIVE_TOKEN_STATUSES = ['CLOSED', 'TRIGGERED', 'COMPLETED']; + + /** + * @var array|null + */ + private $nextInlineJob = null; + + /** + * @var bool + */ + private $inlineTaskExecutionEnabled = false; + /** * @var RepositoryFactoryInterface */ @@ -164,4 +176,27 @@ public function registerStartTimerEvents(ProcessInterface $process) } $this->getJobManager()->disableRegisterStartEvents(); } + + public function isInlineTaskExecutionEnabled(): bool + { + return $this->inlineTaskExecutionEnabled; + } + + public function setInlineTaskExecutionEnabled(bool $enabled): void + { + $this->inlineTaskExecutionEnabled = $enabled; + } + + public function scheduleInlineJob(array $job): void + { + $this->nextInlineJob = $job; + } + + public function pullInlineJob(): ?array + { + $job = $this->nextInlineJob; + $this->nextInlineJob = null; + + return $job; + } } diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f78ddaf647..7e3ad5df28 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -18,6 +18,7 @@ use ProcessMaker\Models\Process as Definitions; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestLock; +use ProcessMaker\Models\ProcessRequestToken; use Throwable; abstract class BpmnAction implements ShouldQueue @@ -47,6 +48,17 @@ abstract class BpmnAction implements ShouldQueue protected $processId; + /** + * Context loaded at the beginning of the job. It can be reused after an + * external action when the persisted execution state has not changed. + * + * @var array|null + */ + private $loadedContext; + + /** @var int|null */ + private $loadedExecutionRevision; + /** * @var ProcessRequestLock */ @@ -60,8 +72,11 @@ abstract class BpmnAction implements ShouldQueue public function handle() { $response = null; + $currentAction = $this; try { - extract($this->loadContext()); + $this->loadedContext = $this->loadContext(); + $this->loadedExecutionRevision = $this->loadedContext['instance']?->execution_revision; + extract($this->loadedContext); $this->engine = $engine; $this->instance = $instance; @@ -70,6 +85,21 @@ public function handle() // Run engine to the next state $this->engine->runToNextState(); + + while ($inlineJob = $currentAction->engine->pullInlineJob()) { + $context = $currentAction->loadedContext; + $context['token'] = $inlineJob['context']['token']; + $context['instance'] = $inlineJob['context']['instance']; + $context['element'] = $inlineJob['context']['element']; + $currentAction->loadedContext = $context; + $currentAction->loadedExecutionRevision = $context['instance']->execution_revision; + $currentAction->transferInternalContext($inlineJob['job']); + $currentAction = $inlineJob['job']; + + $response = App::call([$currentAction, 'action'], $context); + $currentAction->engine->runToNextState(); + } + // call to redirect after all events are completed // (e.g. completed, assigned, process completed, etc) // excluding system process (non_persistent_process) @@ -82,27 +112,38 @@ public function handle() } catch (Throwable $exception) { Log::error($exception->getMessage()); // Change the Request to error status - $request = !$this->instance && $this instanceof StartEvent ? $response : $this->instance; + $request = !$currentAction->instance && $currentAction instanceof StartEvent ? $response : $currentAction->instance; if ($request) { - $request->logError($exception, $element); + $request->logError($exception, $context['element'] ?? $element ?? null); } } finally { - $this->unlock(); + $currentAction->unlock(); } return $response; } + public function transferInternalContext(BpmnAction $action): void + { + $action->engine = $this->engine; + $action->instance = $this->instance; + $action->loadedContext = $this->loadedContext; + $action->loadedExecutionRevision = $this->loadedExecutionRevision; + $action->lock = $this->lock; + $action->disableGlobalEvents = $this->disableGlobalEvents; + $this->lock = null; + } + /** * Load the context for the action * * @return array */ - private function loadContext() + private function loadContext(?ProcessRequest $lockedInstance = null) { // Load the process definition if (isset($this->instanceId)) { - $instance = $this->lockInstance($this->instanceId); + $instance = $lockedInstance ?: $this->lockInstance($this->instanceId); $processModel = $instance->process; $definitions = ($instance->processVersion ?? $instance->process)->getDefinitions(true); $engine = app(BpmnEngine::class, ['definitions' => $definitions, 'globalEvents' => !$this->disableGlobalEvents]); @@ -114,6 +155,8 @@ private function loadContext() $instance = null; } + $engine->setInlineTaskExecutionEnabled($this->allowsInlineTaskExecution()); + // Load the instances of the process and its collaborators if ($instance && $instance->collaboration) { $activeRequests = $instance->collaboration->requests()->where('status', 'ACTIVE')->get(); @@ -152,6 +195,11 @@ private function loadContext() return compact('definitions', 'instance', 'token', 'process', 'element', 'data', 'processModel', 'engine'); } + protected function allowsInlineTaskExecution(): bool + { + return $this instanceof RunScriptTask || $this instanceof RunServiceTask; + } + /** * This method execute a callback with the context updated * @@ -159,11 +207,49 @@ private function loadContext() */ public function withUpdatedContext(callable $callable) { - $context = $this->loadContext(); + $lockedInstance = $this->lockInstance($this->instanceId, true); + $contextReused = $this->canReuseLoadedContext($lockedInstance); + if ($contextReused) { + $context = $this->loadedContext; + } else { + $context = $this->loadContext(ProcessRequest::findOrFail($this->instanceId)); + } + + $this->loadedContext = $context; + $this->loadedExecutionRevision = $context['instance']?->execution_revision; return App::call($callable, $context); } + /** + * Determine whether the in-memory engine still represents the persisted + * request. This optimization is intentionally limited to linear states. + * true: can reuse the loaded context + * false: cannot reuse the loaded context + * null: cannot determine if the context can be reused + */ + private function canReuseLoadedContext(ProcessRequest $lockedInstance): bool + { + $activeTokenIds = []; + if ((int) $lockedInstance->execution_revision === (int) $this->loadedExecutionRevision) { + $activeTokenIds = ProcessRequestToken::query() + ->where('process_request_id', $this->instanceId) + ->whereNotIn('status', BpmnEngine::INACTIVE_TOKEN_STATUSES) + ->limit(2) + ->pluck('id') + ->all(); + } + + $fallbackReason = app(BpmnContextReuseGuard::class)->fallbackReason( + $this->loadedContext['instance'] ?? null, + $lockedInstance, + $this->loadedExecutionRevision, + $activeTokenIds + ); + + return $fallbackReason === null; + } + /** * Lock the instance and its collaborators * @@ -171,11 +257,11 @@ public function withUpdatedContext(callable $callable) * * @return ProcessRequest */ - private function lockInstance($instanceId) + private function lockInstance($instanceId, bool $lightweight = false) { try { // First attempt to find the instance with retry logic for race conditions - $instance = $this->findInstanceWithRetry($instanceId); + $instance = $this->findInstanceWithRetry($instanceId, $lightweight); if (config('queue.default') === 'sync') { return $instance; @@ -194,13 +280,13 @@ private function lockInstance($instanceId) for ($tries = 0; $tries < $maxRetries; $tries++) { $currentLock = $this->currentLock($ids); if (!$currentLock) { - if (ProcessRequest::find($instanceId)) { + if (ProcessRequest::whereKey($instanceId)->exists()) { $lock = $this->requestLock($ids); } else { throw new Exception('Unable to lock instance #' . $this->instanceId . ': Request does not exists'); } } elseif ($lock->id == $currentLock->id) { - $instance = ProcessRequest::findOrFail($instanceId); + $instance = $this->findInstance($instanceId, $lightweight); $this->activateLock($lock); return $instance; @@ -221,7 +307,7 @@ private function lockInstance($instanceId) * @return ProcessRequest * @throws Exception */ - private function findInstanceWithRetry($instanceId) + private function findInstanceWithRetry($instanceId, bool $lightweight = false) { $maxRetries = config('app.bpmn_actions_find_retries', 5); $retryDelay = config('app.bpmn_actions_find_retry_delay', 50); // milliseconds @@ -231,7 +317,7 @@ private function findInstanceWithRetry($instanceId) for ($attempt = 0; $attempt < $totalAttempts; $attempt++) { try { - $instance = ProcessRequest::findOrFail($instanceId); + $instance = $this->findInstance($instanceId, $lightweight); return $instance; } catch (ModelNotFoundException $e) { @@ -251,6 +337,23 @@ private function findInstanceWithRetry($instanceId) throw new ModelNotFoundException("ProcessRequest #{$instanceId} not found after {$totalAttempts} attempts"); } + /** + * Load only lock and revision metadata when validating the fast path. + */ + private function findInstance($instanceId, bool $lightweight): ProcessRequest + { + $query = ProcessRequest::query(); + if ($lightweight) { + $query->select([ + 'id', + 'process_collaboration_id', + 'execution_revision', + ]); + } + + return $query->findOrFail($instanceId); + } + /** * Request a lock for the instance * @param array $ids @@ -349,6 +452,8 @@ public function __destruct() $this->instance = null; $this->engine = null; $this->lock = null; + $this->loadedContext = null; + $this->loadedExecutionRevision = null; gc_collect_cycles(); } } diff --git a/ProcessMaker/Jobs/BpmnContextReuseGuard.php b/ProcessMaker/Jobs/BpmnContextReuseGuard.php new file mode 100644 index 0000000000..88631ad370 --- /dev/null +++ b/ProcessMaker/Jobs/BpmnContextReuseGuard.php @@ -0,0 +1,64 @@ +isLinearExecution($loadedInstance)) { + return 'execution_not_linear'; + } + + if ((int) $persistedInstance->execution_revision !== (int) $loadedRevision) { + return 'execution_revision_changed'; + } + + $loadedTokenIds = collect($loadedInstance->getTokens()) + ->filter(fn ($token) => !in_array($token->getStatus(), BpmnEngine::INACTIVE_TOKEN_STATUSES, true)) + ->map(fn ($token) => (string) $token->getId()) + ->values() + ->all(); + + if (count($persistedActiveTokenIds) !== 1) { + return 'persisted_execution_not_linear'; + } + + if ((string) $persistedActiveTokenIds[0] !== $loadedTokenIds[0]) { + return 'active_token_changed'; + } + + return null; + } + + /** + * A collaboration, multi-instance token, or multiple active tokens must + * use a freshly hydrated engine. + */ + private function isLinearExecution(ProcessRequest $instance): bool + { + if ($instance->getRawOriginal('process_collaboration_id')) { + return false; + } + + $tokens = collect($instance->getTokens()) + ->filter(fn ($token) => !in_array($token->getStatus(), BpmnEngine::INACTIVE_TOKEN_STATUSES, true)) + ->values(); + + return $tokens->count() === 1 && !$tokens->first()->isMultiInstance(); + } +} diff --git a/ProcessMaker/Models/ProcessRequest.php b/ProcessMaker/Models/ProcessRequest.php index 4ac0976872..8e19f4efb1 100644 --- a/ProcessMaker/Models/ProcessRequest.php +++ b/ProcessMaker/Models/ProcessRequest.php @@ -155,6 +155,7 @@ class ProcessRequest extends ProcessMakerModel implements ExecutionInstanceInter 'do_not_sanitize' => 'array', 'signal_events' => 'array', 'locked_at' => 'datetime:c', + 'execution_revision' => 'integer', ]; /** diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php index 0f757aa7e2..11a8d3007f 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; +use ProcessMaker\BpmnEngine; use ProcessMaker\Contracts\ServiceTaskImplementationInterface; use ProcessMaker\Contracts\WorkflowManagerInterface; use ProcessMaker\Jobs\BoundaryEvent; @@ -177,6 +178,42 @@ public function callProcess(Definitions $definitions, ProcessInterface $process, return (new CallProcess($definitions, $process, $data))->handle(); } + private function runInlineTask(Token $token, $jobClass) + { + $instance = $token->getInstance(); + $process = $instance->process; + $engine = $instance->getEngine(); + $inlineJob = new $jobClass($process, $instance, $token, []); + $engine->scheduleInlineJob([ + 'job' => $inlineJob, + 'context' => [ + 'token' => $token, + 'instance' => $instance, + 'element' => $token->getOwnerElement(), + ], + ]); + } + + private function canRunInlineTask(Token $token): bool + { + $instance = $token->getInstance(); + $engine = $instance->getEngine(); + if (!$engine->isInlineTaskExecutionEnabled()) { + return false; + } + + $activeTokens = collect($instance->getTokens()) + ->filter(fn ($currentToken) => !in_array( + $currentToken->getStatus(), + BpmnEngine::INACTIVE_TOKEN_STATUSES, + true + )) + ->values(); + + return $activeTokens->count() === 1 + && (string) $activeTokens->first()->getId() === (string) $token->getId(); + } + /** * Run a script task. * @@ -186,6 +223,12 @@ public function callProcess(Definitions $definitions, ProcessInterface $process, public function runScripTask(ScriptTaskInterface $scriptTask, Token $token) { Log::info('Dispatch a script task: ' . $scriptTask->getId() . ' #' . $token->getId()); + + if ($this->canRunInlineTask($token)) { + $this->runInlineTask($token, RunScriptTask::class); + return; + } + $instance = $token->processRequest; $process = $instance->process; RunScriptTask::dispatch($process, $instance, $token, [])->onQueue('bpmn'); @@ -200,6 +243,12 @@ public function runScripTask(ScriptTaskInterface $scriptTask, Token $token) public function runServiceTask(ServiceTaskInterface $serviceTask, Token $token) { Log::info('Dispatch a service task: ' . $serviceTask->getId()); + + if ($this->canRunInlineTask($token)) { + $this->runInlineTask($token, RunServiceTask::class); + return; + } + $instance = $token->processRequest; $process = $instance->process; RunServiceTask::dispatch($process, $instance, $token, []); diff --git a/ProcessMaker/Repositories/ExecutionInstanceRepository.php b/ProcessMaker/Repositories/ExecutionInstanceRepository.php index 90212e373a..96b83e4236 100644 --- a/ProcessMaker/Repositories/ExecutionInstanceRepository.php +++ b/ProcessMaker/Repositories/ExecutionInstanceRepository.php @@ -183,6 +183,7 @@ public function persistInstanceCreated(ExecutionInstanceInterface $instance) $instance->initiated_at = Carbon::now(); $instance->do_not_sanitize = SanitizeHelper::getDoNotSanitizeFields($definition); $instance->data = $data; + $this->advanceExecutionRevision($instance); $instance->saveOrFail(); // Set id @@ -213,6 +214,7 @@ public function persistInstanceError(ExecutionInstanceInterface $instance) // Save instance with error $instance->status = 'ERROR'; $instance->mergeLatestStoredData(); + $this->advanceExecutionRevision($instance); $instance->saveOrFail(); CaseUpdateStatus::dispatchSync($instance); @@ -239,6 +241,7 @@ public function persistInstanceUpdated(ExecutionInstanceInterface $instance) $instance->status = 'ACTIVE'; } $instance->mergeLatestStoredData(); + $this->advanceExecutionRevision($instance); $instance->saveOrFail(); CaseUpdateStatus::dispatchSync($instance); @@ -264,6 +267,7 @@ public function persistInstanceCompleted(ExecutionInstanceInterface $instance) $instance->status = 'COMPLETED'; $instance->completed_at = Carbon::now(); $instance->mergeLatestStoredData(); + $this->advanceExecutionRevision($instance); $instance->saveOrFail(); CaseUpdateStatus::dispatchSync($instance); @@ -293,15 +297,42 @@ public function persistInstanceCollaboration(ExecutionInstanceInterface $instanc $collaboration->process_id = $instance->process->getKey(); $collaboration->saveOrFail(); $source->process_collaboration_id = $collaboration->getKey(); + $this->advanceExecutionRevision($source); $source->saveOrFail(); } // Save collaboration $instance->process_collaboration_id = $source->process_collaboration_id; $instance->participant_id = $participant ? $participant->getId() : null; + $this->advanceExecutionRevision($instance); $instance->saveOrFail(); } + /** + * Include the revision in the instance's next insert or update. + */ + private function advanceExecutionRevision(ExecutionInstanceInterface $instance): void + { + $instance->execution_revision = (int) $instance->execution_revision + 1; + } + + /** + * Mark a persisted execution-state change without inspecting request JSON. + */ + public function incrementExecutionRevision(ExecutionInstanceInterface $instance): void + { + if (!$instance->getKey()) { + return; + } + + ProcessRequest::query() + ->whereKey($instance->getKey()) + ->increment('execution_revision'); + + $instance->execution_revision = (int) $instance->execution_revision + 1; + $instance->syncOriginalAttribute('execution_revision'); + } + /** * Persist current collaboration. * diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 77ad20fba9..f7a07913c0 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -558,6 +558,7 @@ public function store(TokenInterface $token, $saveChildElements = false) } $token->saveOrFail(); + $this->instanceRepository->incrementExecutionRevision($token->getInstance()); return $this; } @@ -672,6 +673,7 @@ public function persistCallActivityActivated(TokenInterface $token, ExecutionIns $token->subprocess_start_event_id = $startId; $token->updateTokenProperties(); $token->saveOrFail(); + $this->instanceRepository->incrementExecutionRevision($source); } /** diff --git a/database/migrations/2026_08_14_000000_add_execution_revision_to_process_requests_table.php b/database/migrations/2026_08_14_000000_add_execution_revision_to_process_requests_table.php new file mode 100644 index 0000000000..0817a2b8d5 --- /dev/null +++ b/database/migrations/2026_08_14_000000_add_execution_revision_to_process_requests_table.php @@ -0,0 +1,30 @@ +getConnectionName(); + Schema::connection($connection)->table('process_requests', function (Blueprint $table) { + $table->unsignedBigInteger('execution_revision')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $connection = (new ProcessRequest())->getConnectionName(); + Schema::connection($connection)->table('process_requests', function (Blueprint $table) { + $table->dropColumn('execution_revision'); + }); + } +}; diff --git a/tests/Feature/BpmnContextReuseGuardTest.php b/tests/Feature/BpmnContextReuseGuardTest.php new file mode 100644 index 0000000000..0696aa0179 --- /dev/null +++ b/tests/Feature/BpmnContextReuseGuardTest.php @@ -0,0 +1,224 @@ +assertTrue(Schema::hasColumn('process_requests', 'execution_revision')); + } + + public function testOneActiveTokenAndMatchingRevisionUsesFastPath(): void + { + [$loaded, $persisted] = $this->linearInstances(7); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 7, [100]); + + $this->assertNull($reason); + } + + public function testChangedRevisionUsesFallbackWithoutReadingJson(): void + { + [$loaded, $persisted] = $this->linearInstances(8); + $loaded->shouldNotReceive('getAttribute')->with('data'); + $persisted->shouldNotReceive('getAttribute')->with('data'); + $loaded->shouldNotReceive('getRawOriginal')->with('data'); + $persisted->shouldNotReceive('getRawOriginal')->with('data'); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 7, []); + + $this->assertSame('execution_revision_changed', $reason); + } + + public function testMissingLoadedContextUsesFallback(): void + { + $reason = app(BpmnContextReuseGuard::class)->fallbackReason( + null, + $this->persistedInstance(1), + null, + [] + ); + + $this->assertSame('context_not_loaded', $reason); + } + + public function testMultipleActiveTokensDisableFastPath(): void + { + $loaded = $this->instanceWithTokens([ + $this->token('ACTIVE', false, 100), + $this->token('ACTIVE', false, 101), + ]); + $persisted = $this->persistedInstance(1); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 1, []); + + $this->assertSame('execution_not_linear', $reason); + } + + public function testClosedTokensDoNotDisableLinearFastPath(): void + { + $loaded = $this->instanceWithTokens([ + $this->token('ACTIVE', false, 100), + $this->token('CLOSED', false, 101), + ]); + $persisted = $this->persistedInstance(3); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 3, [100]); + + $this->assertNull($reason); + } + + public function testCompletedAndTriggeredTokensDoNotDisableLinearFastPath(): void + { + $loaded = $this->instanceWithTokens([ + $this->token('ACTIVE', false, 100), + $this->token('COMPLETED', false, 101), + $this->token('TRIGGERED', false, 102), + ]); + $persisted = $this->persistedInstance(3); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 3, [100]); + + $this->assertNull($reason); + } + + public function testIncomingTokenDisablesLinearFastPath(): void + { + $loaded = $this->instanceWithTokens([ + $this->token('ACTIVE', false, 100), + $this->token('INCOMING', false, 101), + ]); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason( + $loaded, + $this->persistedInstance(3), + 3, + [100, 101] + ); + + $this->assertSame('execution_not_linear', $reason); + } + + public function testMultiInstanceTokenDisablesFastPath(): void + { + $loaded = $this->instanceWithTokens([$this->token('ACTIVE', true)]); + $persisted = $this->persistedInstance(1); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 1, []); + + $this->assertSame('execution_not_linear', $reason); + } + + public function testCollaborationDisablesFastPath(): void + { + $loaded = Mockery::mock(ProcessRequest::class)->makePartial(); + $loaded->shouldReceive('getRawOriginal') + ->with('process_collaboration_id') + ->once() + ->andReturn(10); + $loaded->shouldNotReceive('getTokens'); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason( + $loaded, + $this->persistedInstance(1), + 1, + [] + ); + + $this->assertSame('execution_not_linear', $reason); + } + + public function testMultiplePersistedTokensDisableFastPath(): void + { + [$loaded, $persisted] = $this->linearInstances(2); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason( + $loaded, + $persisted, + 2, + [100, 101] + ); + + $this->assertSame('persisted_execution_not_linear', $reason); + } + + public function testPersistedTokenReplacementDisablesFastPath(): void + { + [$loaded, $persisted] = $this->linearInstances(2); + + $reason = app(BpmnContextReuseGuard::class)->fallbackReason($loaded, $persisted, 2, [200]); + + $this->assertSame('active_token_changed', $reason); + } + + public function testExecutionRepositoryIncrementsRevisionAtomically(): void + { + $request = ProcessRequest::factory()->create(['execution_revision' => 4]); + + app(ExecutionInstanceRepository::class)->incrementExecutionRevision($request); + + $this->assertSame(5, $request->refresh()->execution_revision); + } + + public function testTokenRepositoryStoreIncrementsRequestRevision(): void + { + $request = ProcessRequest::factory()->create(['execution_revision' => 9]); + $token = ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + ]); + $token->setInstance($request); + + app(TokenRepository::class)->store($token); + + $this->assertSame(10, $request->refresh()->execution_revision); + } + + private function linearInstances(int $persistedRevision): array + { + return [ + $this->instanceWithTokens([$this->token('ACTIVE', false, 100)]), + $this->persistedInstance($persistedRevision), + ]; + } + + private function instanceWithTokens(array $tokens): ProcessRequest + { + $instance = Mockery::mock(ProcessRequest::class)->makePartial(); + $instance->shouldReceive('getRawOriginal') + ->with('process_collaboration_id') + ->andReturn(null); + $instance->shouldReceive('getTokens')->andReturn($tokens); + + return $instance; + } + + private function persistedInstance(int $revision): ProcessRequest + { + $instance = Mockery::mock(ProcessRequest::class)->makePartial(); + $instance->execution_revision = $revision; + + return $instance; + } + + private function token(string $status, bool $multiInstance = false, int $id = 100): ProcessRequestToken + { + $token = Mockery::mock(ProcessRequestToken::class)->makePartial(); + $token->shouldReceive('getStatus')->andReturn($status); + $token->shouldReceive('isMultiInstance')->andReturn($multiInstance); + $token->shouldReceive('getId')->andReturn($id); + + return $token; + } +} diff --git a/tests/Feature/BpmnInlineLockTransferTest.php b/tests/Feature/BpmnInlineLockTransferTest.php new file mode 100644 index 0000000000..1065c07466 --- /dev/null +++ b/tests/Feature/BpmnInlineLockTransferTest.php @@ -0,0 +1,31 @@ +makePartial(); + $target = Mockery::mock(BpmnAction::class)->makePartial(); + $lock = Mockery::mock(ProcessRequestLock::class); + $source->setInternalContext(['lock' => $lock]); + + $source->transferInternalContext($target); + + $lockProperty = new ReflectionProperty(BpmnAction::class, 'lock'); + $this->assertNull($lockProperty->getValue($source)); + $this->assertSame($lock, $lockProperty->getValue($target)); + } +} From ba1c53c2b8f98b483143ea510fbba0030e32364f Mon Sep 17 00:00:00 2001 From: David Callizaya Date: Tue, 18 Aug 2026 09:07:41 -0400 Subject: [PATCH 2/2] test: update test --- tests/Feature/BpmnInlineLockTransferTest.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/Feature/BpmnInlineLockTransferTest.php b/tests/Feature/BpmnInlineLockTransferTest.php index 1065c07466..75e118b2c8 100644 --- a/tests/Feature/BpmnInlineLockTransferTest.php +++ b/tests/Feature/BpmnInlineLockTransferTest.php @@ -7,7 +7,11 @@ use PHPUnit\Framework\Attributes\Group as TestGroup; use PHPUnit\Framework\TestCase; use ProcessMaker\Jobs\BpmnAction; +use ProcessMaker\Jobs\CompleteActivity; +use ProcessMaker\Jobs\RunScriptTask; +use ProcessMaker\Jobs\RunServiceTask; use ProcessMaker\Models\ProcessRequestLock; +use ReflectionMethod; use ReflectionProperty; #[TestGroup('process_tests')] @@ -20,12 +24,21 @@ public function testInlineActionBecomesTheOnlyLockOwner(): void $source = Mockery::mock(BpmnAction::class)->makePartial(); $target = Mockery::mock(BpmnAction::class)->makePartial(); $lock = Mockery::mock(ProcessRequestLock::class); - $source->setInternalContext(['lock' => $lock]); + $lockProperty = new ReflectionProperty(BpmnAction::class, 'lock'); + $lockProperty->setValue($source, $lock); $source->transferInternalContext($target); - $lockProperty = new ReflectionProperty(BpmnAction::class, 'lock'); $this->assertNull($lockProperty->getValue($source)); $this->assertSame($lock, $lockProperty->getValue($target)); } + + public function testOnlyScriptAndServiceJobsEnableInlineTaskExecution(): void + { + $method = new ReflectionMethod(BpmnAction::class, 'allowsInlineTaskExecution'); + + $this->assertTrue($method->invoke(Mockery::mock(RunScriptTask::class)->makePartial())); + $this->assertTrue($method->invoke(Mockery::mock(RunServiceTask::class)->makePartial())); + $this->assertFalse($method->invoke(Mockery::mock(CompleteActivity::class)->makePartial())); + } }