From 33a0d79a88d56557c23747ba809957e1a9ad1c2b Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Mon, 27 Apr 2026 16:05:49 -0400 Subject: [PATCH 1/5] FOUR-30871 [50383] Slow response time in API PUT /api/1.0/tasks/xxxxx. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description: mergeLatestStoredData() now reads only the data column via value() instead of hydrating a full ProcessRequest row—this path runs often during BPMN transitions, so it cuts unnecessary I/O and model work on large JSON payloads. BpmnAction loads the request with with(['process','processVersion','collaboration']) to avoid N+1s when wiring the engine, and uses whereKey()->exists() instead of find() for the lock existence check so the DB does not return full rows when only presence matters. Related tickets: https://processmaker.atlassian.net/browse/FOUR-30871 --- ProcessMaker/Jobs/BpmnAction.php | 23 ++++- ProcessMaker/Models/ProcessRequest.php | 8 +- .../TaskControllerUpdateResponseTimeTest.php | 92 +++++++++++++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f78ddaf647..d962355794 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -194,13 +194,13 @@ private function lockInstance($instanceId) for ($tries = 0; $tries < $maxRetries; $tries++) { $currentLock = $this->currentLock($ids); if (!$currentLock) { - if (ProcessRequest::find($instanceId)) { + if (ProcessRequest::query()->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->findProcessRequestForBpmnAction($instanceId); $this->activateLock($lock); return $instance; @@ -231,7 +231,7 @@ private function findInstanceWithRetry($instanceId) for ($attempt = 0; $attempt < $totalAttempts; $attempt++) { try { - $instance = ProcessRequest::findOrFail($instanceId); + $instance = $this->findProcessRequestForBpmnAction($instanceId); return $instance; } catch (ModelNotFoundException $e) { @@ -344,6 +344,23 @@ private function mSleep($milliseconds) usleep($microseconds); } + /** + * Load ProcessRequest with relations used when wiring the BPMN engine (reduces N+1 during completeTask / other BPMN jobs). + * + * @param int|string $instanceId + */ + private function findProcessRequestForBpmnAction($instanceId): ProcessRequest + { + return ProcessRequest::query() + ->with([ + 'process', + 'processVersion', + 'collaboration', + ]) + ->whereKey($instanceId) + ->firstOrFail(); + } + public function __destruct() { $this->instance = null; diff --git a/ProcessMaker/Models/ProcessRequest.php b/ProcessMaker/Models/ProcessRequest.php index 57509fe823..3bbc15d934 100644 --- a/ProcessMaker/Models/ProcessRequest.php +++ b/ProcessMaker/Models/ProcessRequest.php @@ -848,8 +848,12 @@ public function updateCatchEvents() public function mergeLatestStoredData() { $store = $this->getDataStore(); - $latest = self::select('data')->find($this->getId()); - $this->data = $store->updateArray($latest->data); + // Read only the JSON column without hydrating a full ProcessRequest row (called often during BPMN transitions). + $latestData = static::query()->whereKey($this->getKey())->value('data'); + if (!is_array($latestData)) { + $latestData = []; + } + $this->data = $store->updateArray($latestData); return $this->data; } diff --git a/tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php b/tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php new file mode 100644 index 0000000000..923622f82e --- /dev/null +++ b/tests/Feature/Api/TaskControllerUpdateResponseTimeTest.php @@ -0,0 +1,92 @@ +assertLessThanOrEqual( + $maxMs, + $elapsedMs, + "{$label}: request took " . round($elapsedMs) . "ms, limit {$maxMs}ms (set TASK_UPDATE_MAX_RESPONSE_MS to adjust)" + ); + } + + public function testCompleteTaskUpdateResponseTime(): void + { + $maxMs = $this->maxResponseTimeMsForTaskUpdate(); + + $token = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + WorkflowManager::shouldReceive('completeTask') + ->once() + ->with(Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any()); + + $params = ['status' => 'COMPLETED', 'data' => ['foo' => 'bar']]; + + $started = microtime(true); + $response = $this->apiCall('PUT', '/tasks/' . $token->id, $params); + $elapsedMs = (microtime(true) - $started) * 1000; + + $response->assertStatus(200); + $this->assertResponseWithinMs($elapsedMs, $maxMs, 'PUT api/tasks/{id} (status=COMPLETED)'); + } + + public function testReassignTaskUpdateResponseTime(): void + { + $maxMs = $this->maxResponseTimeMsForTaskUpdate(); + + $assignee = User::factory()->create(); + $token = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + // Prevent notification errors by faking the notification system + // The factory generates random element_ids that don't exist in the BPMN document + Notification::fake(); + + $started = microtime(true); + $response = $this->apiCall('PUT', '/tasks/' . $token->id, [ + 'user_id' => $assignee->id, + 'comments' => 'response time test', + ]); + $elapsedMs = (microtime(true) - $started) * 1000; + + $response->assertStatus(200); + $this->assertResponseWithinMs($elapsedMs, $maxMs, 'PUT api/tasks/{id} (reassign)'); + } +} From 09dff019fc662e6a0e5c5e33c3691e1fcb2a3735 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Mon, 25 May 2026 23:55:24 -0400 Subject: [PATCH 2/5] Improvements with raw queries. --- .../Http/Controllers/Api/TaskController.php | 200 +++++++++++++++++- 1 file changed, 194 insertions(+), 6 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 0679b42853..552fe0c303 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -27,6 +27,7 @@ use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\ProcessVersion; use ProcessMaker\Models\Screen; use ProcessMaker\Models\Setting; use ProcessMaker\Models\TaskDraft; @@ -343,20 +344,37 @@ public function update(Request $request, ProcessRequestToken $task) } // Skip ConvertEmptyStringsToNull and TrimStrings middlewares $data = json_optimize_decode($request->getContent(), true); - $data = SanitizeHelper::sanitizeData($data['data'], null, $task->processRequest->do_not_sanitize ?? []); + $requestRow = $this->getProcessRequestRowForCompleteRaw($task->process_request_id); + $data = SanitizeHelper::sanitizeData($data['data'], null, $this->getDoNotSanitizeFromRowRaw($requestRow)); //Call the manager to trigger the start event - $process = $task->process; - $instance = $task->processRequest; - TaskDraft::moveDraftFiles($task); + $process = $this->getProcessForCompleteRaw($task->process_id); + $instance = $this->getProcessRequestFromRowRaw($requestRow); + $instance->setRelation('process', $process); + if ($processVersion = $this->getProcessVersionForCompleteRaw($requestRow->process_version_id ?? null)) { + $instance->setRelation('processVersion', $processVersion); + } + $task->setRelation('processRequest', $instance); + $task->setRelation('process', $process); + if ($this->taskHasDraftRaw($task->id)) { + TaskDraft::moveDraftFiles($task); + } WorkflowManager::completeTask($process, $instance, $task, $data); - return new Resource($task->refresh()); + $responseProcess = $this->getProcessForResponseRaw($task->process_id); + $responseInstance = $this->getProcessRequestForResponseRaw($task->process_request_id); + $responseInstance->setRelation('process', $responseProcess); + $taskRefreshed = $this->refreshTaskRaw($task, $responseProcess, $responseInstance); + + return new Resource($taskRefreshed); } elseif (!empty($request->input('user_id'))) { + $process = $this->getProcessForReassignRaw($task->process_id); + $task->setRelation('process', $process); + $userToAssign = $request->input('user_id'); $comments = $request->input('comments'); $task->reassign($userToAssign, $request->user(), $comments); - $taskRefreshed = $task->refresh(); + $taskRefreshed = $this->refreshTaskRaw($task, $process, $task->processRequest); CaseUpdate::dispatchSync($task->processRequest, $taskRefreshed); @@ -366,6 +384,176 @@ public function update(Request $request, ProcessRequestToken $task) } } + /** + * Analog to $task->processRequest->do_not_sanitize (Eloquent), from a raw request row. + */ + private function getDoNotSanitizeFromRowRaw(object $requestRow): array + { + $doNotSanitize = $requestRow->do_not_sanitize ?? null; + if ($doNotSanitize === null) { + return []; + } + if (is_array($doNotSanitize)) { + return $doNotSanitize; + } + + return json_decode($doNotSanitize, true) ?? []; + } + + /** + * Analog to ProcessRequest::find / $task->processRequest for completeTask (without loading data JSON). + */ + private function getProcessRequestRowForCompleteRaw(int $processRequestId): object + { + $requestRow = DB::selectOne( + 'SELECT do_not_sanitize, id, process_id, process_version_id, collaboration_uuid FROM process_requests WHERE id = ? LIMIT 1', + [$processRequestId] + ); + if (!$requestRow) { + abort(404); + } + + return $requestRow; + } + + /** + * Analog to $task->process for completeTask (BPMN only; required by getDefinition/validateData). + */ + private function getProcessForCompleteRaw(int $processId): Process + { + $processRow = DB::selectOne( + 'SELECT id, bpmn FROM processes WHERE id = ? LIMIT 1', + [$processId] + ); + if (!$processRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(Process::class, $processRow); + } + + /** + * Analog to $task->processRequest->processVersion when a version is pinned on the request. + */ + private function getProcessVersionForCompleteRaw(?int $processVersionId): ?ProcessVersion + { + if (!$processVersionId) { + return null; + } + + $versionRow = DB::selectOne( + 'SELECT id, process_id, bpmn FROM process_versions WHERE id = ? LIMIT 1', + [$processVersionId] + ); + if (!$versionRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(ProcessVersion::class, $versionRow); + } + + /** + * Analog to $task->processRequest after complete (without loading data JSON). + */ + private function getProcessRequestForResponseRaw(int $processRequestId): ProcessRequest + { + $requestRow = DB::selectOne( + 'SELECT id, process_id, process_version_id, collaboration_uuid, do_not_sanitize, name, status, case_number, case_title, parent_request_id, user_id, uuid, process_collaboration_id, callable_id, initiated_at, completed_at, created_at, updated_at FROM process_requests WHERE id = ? LIMIT 1', + [$processRequestId] + ); + if (!$requestRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(ProcessRequest::class, $requestRow); + } + + /** + * Analog to $task->process for the API response (without BPMN). + */ + private function getProcessForResponseRaw(int $processId): Process + { + $processRow = DB::selectOne( + 'SELECT id, name FROM processes WHERE id = ? LIMIT 1', + [$processId] + ); + if (!$processRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(Process::class, $processRow); + } + + /** + * Analog to $task->processRequest built from a raw row (without data JSON or BPMN). + */ + private function getProcessRequestFromRowRaw(object $requestRow): ProcessRequest + { + return $this->hydrateModelFromRowRaw(ProcessRequest::class, $requestRow); + } + + /** + * Analog to $task->process / Process::find for reassign (without loading bpmn). + */ + private function getProcessForReassignRaw(int $processId): Process + { + $processRow = DB::selectOne( + 'SELECT id, properties, stages, case_title FROM processes WHERE id = ? LIMIT 1', + [$processId] + ); + if (!$processRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(Process::class, $processRow); + } + + /** + * Analog to $task->draft()->exists() (Eloquent). + */ + private function taskHasDraftRaw(int $taskId): bool + { + return (bool) DB::selectOne( + 'SELECT 1 AS found FROM task_drafts WHERE task_id = ? LIMIT 1', + [$taskId] + ); + } + + /** + * Analog to $task->refresh() (Eloquent), keeping preloaded relations for the response. + */ + private function refreshTaskRaw( + ProcessRequestToken $task, + Process $process, + ProcessRequest $instance + ): ProcessRequestToken { + $row = DB::selectOne( + 'SELECT * FROM process_request_tokens WHERE id = ? LIMIT 1', + [$task->id] + ); + if ($row) { + $task->setRawAttributes((array) $row, true); + $task->syncOriginal(); + } + $task->setRelation('process', $process); + $task->setRelation('processRequest', $instance); + + return $task; + } + + /** + * Hydrate an Eloquent model from a raw DB row (used by *Raw helpers above). + */ + private function hydrateModelFromRowRaw(string $modelClass, object $row) + { + $model = new $modelClass(); + $model->setRawAttributes((array) $row, true); + $model->exists = true; + $model->syncOriginal(); + + return $model; + } + public function updateReassign(Request $request) { $userToAssign = $request->input('user_id'); From 1cfb4ff9d9573240b8208f26cdb9eb74291ed8f0 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 26 May 2026 10:53:55 -0400 Subject: [PATCH 3/5] Improvements time execution. --- .../Http/Controllers/Api/TaskController.php | 209 +---- ProcessMaker/Models/Process.php | 11 +- .../ProcessExecutionRawRepository.php | 715 ++++++++++++++++++ ProcessMaker/Repositories/TokenRepository.php | 11 +- ProcessMaker/Support/ExecutionTimer.php | 115 +++ 5 files changed, 875 insertions(+), 186 deletions(-) create mode 100644 ProcessMaker/Repositories/ProcessExecutionRawRepository.php create mode 100644 ProcessMaker/Support/ExecutionTimer.php diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 552fe0c303..4f9ea2ef72 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ActivityReassignment; @@ -27,7 +28,6 @@ use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; -use ProcessMaker\Models\ProcessVersion; use ProcessMaker\Models\Screen; use ProcessMaker\Models\Setting; use ProcessMaker\Models\TaskDraft; @@ -35,11 +35,17 @@ use ProcessMaker\Models\UserResourceView; use ProcessMaker\Notifications\TaskReassignmentNotification; use ProcessMaker\Query\SyntaxError; +use ProcessMaker\Repositories\ProcessExecutionRawRepository; use ProcessMaker\SanitizeHelper; use ProcessMaker\Traits\TaskControllerIndexMethods; class TaskController extends Controller { + public function __construct( + protected ProcessExecutionRawRepository $processExecutionRaw, + ) { + } + use TaskControllerIndexMethods; /** @@ -337,45 +343,50 @@ public function show(ProcessRequestToken $task) */ public function update(Request $request, ProcessRequestToken $task) { + if (!$task->relationLoaded('process')) { + $task->setRelation('process', $this->processExecutionRaw->getProcessForAuthorizeRaw($task->process_id)); + } $this->authorize('update', $task); + if ($request->input('status') === 'COMPLETED') { if ($task->status === 'CLOSED') { return abort(422, __('Task already closed')); } // Skip ConvertEmptyStringsToNull and TrimStrings middlewares $data = json_optimize_decode($request->getContent(), true); - $requestRow = $this->getProcessRequestRowForCompleteRaw($task->process_request_id); - $data = SanitizeHelper::sanitizeData($data['data'], null, $this->getDoNotSanitizeFromRowRaw($requestRow)); + $requestRow = $this->processExecutionRaw->getProcessRequestRowForCompleteRaw($task->process_request_id); + $data = SanitizeHelper::sanitizeData($data['data'], null, $this->processExecutionRaw->getDoNotSanitizeFromRowRaw($requestRow)); + //Call the manager to trigger the start event - $process = $this->getProcessForCompleteRaw($task->process_id); - $instance = $this->getProcessRequestFromRowRaw($requestRow); + $process = $this->processExecutionRaw->getProcessForCompleteRaw($task->process_id); + $instance = $this->processExecutionRaw->getProcessRequestFromRowRaw($requestRow); $instance->setRelation('process', $process); - if ($processVersion = $this->getProcessVersionForCompleteRaw($requestRow->process_version_id ?? null)) { + if ($processVersion = $this->processExecutionRaw->getProcessVersionForCompleteRaw($requestRow->process_version_id ?? null)) { $instance->setRelation('processVersion', $processVersion); } $task->setRelation('processRequest', $instance); $task->setRelation('process', $process); - if ($this->taskHasDraftRaw($task->id)) { + + if ($this->processExecutionRaw->taskHasDraftRaw($task->id)) { TaskDraft::moveDraftFiles($task); } + WorkflowManager::completeTask($process, $instance, $task, $data); - $responseProcess = $this->getProcessForResponseRaw($task->process_id); - $responseInstance = $this->getProcessRequestForResponseRaw($task->process_request_id); - $responseInstance->setRelation('process', $responseProcess); - $taskRefreshed = $this->refreshTaskRaw($task, $responseProcess, $responseInstance); + $responseInstance = $this->processExecutionRaw->getProcessRequestForResponseRaw($task->process_request_id); + $responseInstance->setRelation('process', $process); + $taskRefreshed = $this->processExecutionRaw->refreshTaskRaw($task, $process, $responseInstance); return new Resource($taskRefreshed); } elseif (!empty($request->input('user_id'))) { - $process = $this->getProcessForReassignRaw($task->process_id); + $process = $this->processExecutionRaw->getProcessForReassignRaw($task->process_id); $task->setRelation('process', $process); $userToAssign = $request->input('user_id'); $comments = $request->input('comments'); $task->reassign($userToAssign, $request->user(), $comments); - $taskRefreshed = $this->refreshTaskRaw($task, $process, $task->processRequest); - + $taskRefreshed = $this->processExecutionRaw->refreshTaskRaw($task, $process, $task->processRequest); CaseUpdate::dispatchSync($task->processRequest, $taskRefreshed); return new Resource($taskRefreshed); @@ -384,176 +395,6 @@ public function update(Request $request, ProcessRequestToken $task) } } - /** - * Analog to $task->processRequest->do_not_sanitize (Eloquent), from a raw request row. - */ - private function getDoNotSanitizeFromRowRaw(object $requestRow): array - { - $doNotSanitize = $requestRow->do_not_sanitize ?? null; - if ($doNotSanitize === null) { - return []; - } - if (is_array($doNotSanitize)) { - return $doNotSanitize; - } - - return json_decode($doNotSanitize, true) ?? []; - } - - /** - * Analog to ProcessRequest::find / $task->processRequest for completeTask (without loading data JSON). - */ - private function getProcessRequestRowForCompleteRaw(int $processRequestId): object - { - $requestRow = DB::selectOne( - 'SELECT do_not_sanitize, id, process_id, process_version_id, collaboration_uuid FROM process_requests WHERE id = ? LIMIT 1', - [$processRequestId] - ); - if (!$requestRow) { - abort(404); - } - - return $requestRow; - } - - /** - * Analog to $task->process for completeTask (BPMN only; required by getDefinition/validateData). - */ - private function getProcessForCompleteRaw(int $processId): Process - { - $processRow = DB::selectOne( - 'SELECT id, bpmn FROM processes WHERE id = ? LIMIT 1', - [$processId] - ); - if (!$processRow) { - abort(404); - } - - return $this->hydrateModelFromRowRaw(Process::class, $processRow); - } - - /** - * Analog to $task->processRequest->processVersion when a version is pinned on the request. - */ - private function getProcessVersionForCompleteRaw(?int $processVersionId): ?ProcessVersion - { - if (!$processVersionId) { - return null; - } - - $versionRow = DB::selectOne( - 'SELECT id, process_id, bpmn FROM process_versions WHERE id = ? LIMIT 1', - [$processVersionId] - ); - if (!$versionRow) { - abort(404); - } - - return $this->hydrateModelFromRowRaw(ProcessVersion::class, $versionRow); - } - - /** - * Analog to $task->processRequest after complete (without loading data JSON). - */ - private function getProcessRequestForResponseRaw(int $processRequestId): ProcessRequest - { - $requestRow = DB::selectOne( - 'SELECT id, process_id, process_version_id, collaboration_uuid, do_not_sanitize, name, status, case_number, case_title, parent_request_id, user_id, uuid, process_collaboration_id, callable_id, initiated_at, completed_at, created_at, updated_at FROM process_requests WHERE id = ? LIMIT 1', - [$processRequestId] - ); - if (!$requestRow) { - abort(404); - } - - return $this->hydrateModelFromRowRaw(ProcessRequest::class, $requestRow); - } - - /** - * Analog to $task->process for the API response (without BPMN). - */ - private function getProcessForResponseRaw(int $processId): Process - { - $processRow = DB::selectOne( - 'SELECT id, name FROM processes WHERE id = ? LIMIT 1', - [$processId] - ); - if (!$processRow) { - abort(404); - } - - return $this->hydrateModelFromRowRaw(Process::class, $processRow); - } - - /** - * Analog to $task->processRequest built from a raw row (without data JSON or BPMN). - */ - private function getProcessRequestFromRowRaw(object $requestRow): ProcessRequest - { - return $this->hydrateModelFromRowRaw(ProcessRequest::class, $requestRow); - } - - /** - * Analog to $task->process / Process::find for reassign (without loading bpmn). - */ - private function getProcessForReassignRaw(int $processId): Process - { - $processRow = DB::selectOne( - 'SELECT id, properties, stages, case_title FROM processes WHERE id = ? LIMIT 1', - [$processId] - ); - if (!$processRow) { - abort(404); - } - - return $this->hydrateModelFromRowRaw(Process::class, $processRow); - } - - /** - * Analog to $task->draft()->exists() (Eloquent). - */ - private function taskHasDraftRaw(int $taskId): bool - { - return (bool) DB::selectOne( - 'SELECT 1 AS found FROM task_drafts WHERE task_id = ? LIMIT 1', - [$taskId] - ); - } - - /** - * Analog to $task->refresh() (Eloquent), keeping preloaded relations for the response. - */ - private function refreshTaskRaw( - ProcessRequestToken $task, - Process $process, - ProcessRequest $instance - ): ProcessRequestToken { - $row = DB::selectOne( - 'SELECT * FROM process_request_tokens WHERE id = ? LIMIT 1', - [$task->id] - ); - if ($row) { - $task->setRawAttributes((array) $row, true); - $task->syncOriginal(); - } - $task->setRelation('process', $process); - $task->setRelation('processRequest', $instance); - - return $task; - } - - /** - * Hydrate an Eloquent model from a raw DB row (used by *Raw helpers above). - */ - private function hydrateModelFromRowRaw(string $modelClass, object $row) - { - $model = new $modelClass(); - $model->setRawAttributes((array) $row, true); - $model->exists = true; - $model->syncOriginal(); - - return $model; - } - public function updateReassign(Request $request) { $userToAssign = $request->input('user_id'); diff --git a/ProcessMaker/Models/Process.php b/ProcessMaker/Models/Process.php index fd27d18af9..f7390f1aaa 100644 --- a/ProcessMaker/Models/Process.php +++ b/ProcessMaker/Models/Process.php @@ -32,6 +32,7 @@ use ProcessMaker\Nayra\Managers\WorkflowManagerDefault; use ProcessMaker\Nayra\Storage\BpmnDocument; use ProcessMaker\Package\WebEntry\Models\WebentryRoute; +use ProcessMaker\Repositories\ProcessExecutionRawRepository; use ProcessMaker\Rules\BPMNValidation; use ProcessMaker\Traits\Exportable; use ProcessMaker\Traits\ExtendedPMQL; @@ -668,6 +669,14 @@ public function getNextUser(ActivityInterface $activity, ProcessRequestToken $to return $this->checkAssignment($token->getInstance(), $activity, $assignmentType, $escalateToManager, $user ? User::where('id', $user)->first() : null, $token); } + /** + * @deprecated Use ProcessExecutionRawRepository::getNextUserRaw() + */ + public function getNextUserRaw(ActivityInterface $activity, ProcessRequestToken $token) + { + return app(ProcessExecutionRawRepository::class)->getNextUserRaw($this, $activity, $token); + } + /** * If user assignment is not valid reassign to Process Manager * @@ -708,7 +717,7 @@ private function checkAssignment(ProcessRequest $request, ActivityInterface $act return $user; } - private function scalateToManagerIfEnabled($user, $activity, $token, $assignmentType) + public function scalateToManagerIfEnabled($user, $activity, $token, $assignmentType) { if ($user) { $assignmentProcess = self::where('name', self::ASSIGNMENT_PROCESS)->first(); diff --git a/ProcessMaker/Repositories/ProcessExecutionRawRepository.php b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php new file mode 100644 index 0000000000..4b07333ab0 --- /dev/null +++ b/ProcessMaker/Repositories/ProcessExecutionRawRepository.php @@ -0,0 +1,715 @@ +getProperty('assignment', $default); + $config = json_decode($activity->getProperty('config', '{}'), true) ?: []; + $escalateToManager = $config['escalateToManager'] ?? false; + + $assignmentLock = filter_var($activity->getProperty('assignmentLock', false), FILTER_VALIDATE_BOOLEAN); + $isSelfService = (bool) ($config['selfService'] ?? false); + + $request = $token->getInstance(); + $requestId = (int) $request->getKey(); + $processId = (int) $process->getKey(); + + if ($assignmentType === 'rule_expression') { + $userByRuleId = $isSelfService ? null : $this->getNextUserByRuleRaw($process, $processId, $activity, $token); + if ($userByRuleId !== null) { + $userId = $process->scalateToManagerIfEnabled($userByRuleId, $activity, $token, $assignmentType); + + return $this->checkAssignmentRaw( + $process, + $processId, + $request, + $activity, + $assignmentType, + $escalateToManager, + $this->getUserByIdRaw($userId), + $token + ); + } + } + + if ($assignmentLock) { + $userId = $this->getLastUserAssignedToTaskRaw($processId, $activity->getId(), $requestId); + if ($userId) { + return $this->checkAssignmentRaw( + $process, + $processId, + $request, + $activity, + $assignmentType, + $escalateToManager, + $this->getUserByIdRaw($userId), + $token + ); + } + } + + switch ($assignmentType) { + case 'user_group': + case 'group': + $userId = $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId()); + break; + case 'user': + $userId = $this->getNextUserAssignmentRaw($processId, $activity->getId()); + break; + case 'user_by_id': + $userId = $this->getNextUserFromVariableRaw($activity, $token); + break; + case 'process_variable': + $userId = $this->getNextUserFromProcessVariableRaw($process, $processId, $activity, $token); + break; + case 'requester': + $userId = $this->getRequesterUserIdRaw($activity, $token); + break; + case 'previous_task_assignee': + $userId = $this->getPreviousTaskAssigneeUserIdRaw($requestId); + break; + case 'process_manager': + $userId = $this->getNextProcessManagerUserIdRaw($processId, $activity, $request); + break; + case 'manual': + case 'self_service': + $userId = null; + break; + case 'script': + default: + $userId = null; + } + + if ($isSelfService && in_array($assignmentType, ['user_group', 'process_variable', 'rule_expression'], true)) { + $userId = null; + } + + $userId = $process->scalateToManagerIfEnabled($userId, $activity, $token, $assignmentType); + + return $this->checkAssignmentRaw( + $process, + $processId, + $request, + $activity, + $assignmentType, + $escalateToManager, + $this->getUserByIdRaw($userId), + $token + ); + } + + /** + * Analog to $task->processRequest->do_not_sanitize (Eloquent), from a raw request row. + */ + public function getDoNotSanitizeFromRowRaw(object $requestRow): array + { + $doNotSanitize = $requestRow->do_not_sanitize ?? null; + if ($doNotSanitize === null) { + return []; + } + if (is_array($doNotSanitize)) { + return $doNotSanitize; + } + + return json_decode($doNotSanitize, true) ?? []; + } + + /** + * Analog to $task->process for authorize('update') (properties only; manager_id is inside JSON). + */ + public function getProcessForAuthorizeRaw(int $processId): Process + { + $processRow = DB::selectOne( + 'SELECT id, properties FROM processes WHERE id = ? LIMIT 1', + [$processId] + ); + if (!$processRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(Process::class, $processRow); + } + + /** + * Analog to ProcessRequest::find / $task->processRequest for completeTask (without loading data JSON). + */ + public function getProcessRequestRowForCompleteRaw(int $processRequestId): object + { + $requestRow = DB::selectOne( + 'SELECT do_not_sanitize, id, process_id, process_version_id, collaboration_uuid FROM process_requests WHERE id = ? LIMIT 1', + [$processRequestId] + ); + if (!$requestRow) { + abort(404); + } + + return $requestRow; + } + + /** + * Analog to $task->process for completeTask (BPMN + name; required by getDefinition/validateData). + */ + public function getProcessForCompleteRaw(int $processId): Process + { + $processRow = DB::selectOne( + 'SELECT id, name, bpmn FROM processes WHERE id = ? LIMIT 1', + [$processId] + ); + if (!$processRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(Process::class, $processRow); + } + + /** + * Analog to $task->processRequest->processVersion when a version is pinned on the request. + */ + public function getProcessVersionForCompleteRaw(?int $processVersionId): ?ProcessVersion + { + if (!$processVersionId) { + return null; + } + + $versionRow = DB::selectOne( + 'SELECT id, process_id, bpmn FROM process_versions WHERE id = ? LIMIT 1', + [$processVersionId] + ); + if (!$versionRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(ProcessVersion::class, $versionRow); + } + + /** + * Analog to ProcessRequest::find columns needed for the task update response. + */ + public function getProcessRequestForResponseRaw(int $processRequestId): ProcessRequest + { + $requestRow = DB::selectOne( + 'SELECT id, process_id, process_version_id, collaboration_uuid, do_not_sanitize, name, status, case_number, case_title, parent_request_id, user_id, uuid, process_collaboration_id, callable_id, initiated_at, completed_at, created_at, updated_at FROM process_requests WHERE id = ? LIMIT 1', + [$processRequestId] + ); + if (!$requestRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(ProcessRequest::class, $requestRow); + } + + /** + * Analog to $task->processRequest built from a raw row (without data JSON or BPMN). + */ + public function getProcessRequestFromRowRaw(object $requestRow): ProcessRequest + { + return $this->hydrateModelFromRowRaw(ProcessRequest::class, $requestRow); + } + + /** + * Analog to $task->process / Process::find for reassign (without loading bpmn). + */ + public function getProcessForReassignRaw(int $processId): Process + { + $processRow = DB::selectOne( + 'SELECT id, properties, stages, case_title FROM processes WHERE id = ? LIMIT 1', + [$processId] + ); + if (!$processRow) { + abort(404); + } + + return $this->hydrateModelFromRowRaw(Process::class, $processRow); + } + + /** + * Analog to $task->draft()->exists() (Eloquent). + */ + public function taskHasDraftRaw(int $taskId): bool + { + return (bool) DB::selectOne( + 'SELECT 1 AS found FROM task_drafts WHERE task_id = ? LIMIT 1', + [$taskId] + ); + } + + /** + * Analog to $task->refresh() (Eloquent), keeping preloaded relations for the response. + */ + public function refreshTaskRaw( + ProcessRequestToken $task, + Process $process, + ProcessRequest $instance + ): ProcessRequestToken { + $row = DB::selectOne( + 'SELECT * FROM process_request_tokens WHERE id = ? LIMIT 1', + [$task->id] + ); + if ($row) { + $task->setRawAttributes((array) $row, true); + $task->syncOriginal(); + } + $task->setRelation('process', $process); + $task->setRelation('processRequest', $instance); + + return $task; + } + + /** + * Hydrate an Eloquent model from a raw DB row. + * + * @template T of Model + * + * @param class-string $modelClass + * @return T + */ + public function hydrateModelFromRowRaw(string $modelClass, object $row): Model + { + /** @var Model $model */ + $model = new $modelClass(); + $model->setRawAttributes((array) $row, true); + $model->exists = true; + $model->syncOriginal(); + + return $model; + } + + /** + * Analog to checkAssignment(), reusing a User already loaded via getUserByIdRaw(). + */ + private function checkAssignmentRaw( + Process $process, + int $processId, + ProcessRequest $request, + ActivityInterface $activity, + $assignmentType, + $escalateToManager, + ?User $user = null, + ?ProcessRequestToken $token = null + ): ?User { + $config = $activity->getProperty('config') ? json_decode($activity->getProperty('config'), true) : []; + $selfServiceToggle = array_key_exists('selfService', $config ?? []) ? $config['selfService'] : false; + $isSelfService = $selfServiceToggle || $assignmentType === 'self_service'; + + if ($activity instanceof ScriptTaskInterface + || $activity instanceof ServiceTaskInterface) { + return $user; + } + if ($user === null) { + if ($isSelfService && !$escalateToManager) { + return null; + } + if ($token === null) { + throw new ThereIsNoProcessManagerAssignedException($activity); + } + $userId = $this->getNextProcessManagerUserIdRaw($processId, $activity, $request); + if (!$userId) { + throw new ThereIsNoProcessManagerAssignedException($activity); + } + $user = $this->getUserByIdRaw($userId); + } + + return $user; + } + + /** + * Analog to User::find() — single flat query, no eager loads. + */ + public function getUserByIdRaw(?int $userId): ?User + { + if (!$userId) { + return null; + } + + $row = DB::selectOne('SELECT * FROM users WHERE id = ? LIMIT 1', [$userId]); + if (!$row) { + return null; + } + + return $this->hydrateModelFromRowRaw(User::class, $row); + } + + private function getRequesterUserIdRaw($activity, ProcessRequestToken $token): ?int + { + $processRequest = $token->getInstance(); + + if ($activity instanceof Activity && !$processRequest->user_id) { + throw new TaskDoesNotHaveRequesterException(); + } + + return $processRequest->user_id ? (int) $processRequest->user_id : null; + } + + private function getPreviousTaskAssigneeUserIdRaw(int $requestId): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_request_id = ? AND element_type = ? ORDER BY id DESC LIMIT 1', + [$requestId, 'task'] + ); + + return $row && $row->user_id ? (int) $row->user_id : null; + } + + private function getNextProcessManagerUserIdRaw(int $processId, ActivityInterface $task, ProcessRequest $request): ?int + { + $managers = $this->getManagerIdsFromRequestRaw($request); + if (empty($managers)) { + return null; + } + if (count($managers) === 1) { + return $managers[0]; + } + + $placeholders = implode(',', array_fill(0, count($managers), '?')); + $row = DB::selectOne( + "SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? AND user_id IN ($placeholders) ORDER BY created_at DESC LIMIT 1", + array_merge([$processId, $task->getId()], $managers) + ); + + $lastUserId = $row && $row->user_id ? (int) $row->user_id : null; + sort($managers); + $key = array_search($lastUserId, $managers, true); + if ($key === false) { + $key = 0; + } else { + $key = ($key + 1) % count($managers); + } + + return $managers[$key]; + } + + private function getManagerIdsFromRequestRaw(ProcessRequest $request): array + { + if ($request->process_version_id) { + $row = DB::selectOne( + 'SELECT properties FROM process_versions WHERE id = ? LIMIT 1', + [$request->process_version_id] + ); + } else { + $row = DB::selectOne( + 'SELECT properties FROM processes WHERE id = ? LIMIT 1', + [$request->process_id] + ); + } + + if (!$row || empty($row->properties)) { + return []; + } + + $properties = is_array($row->properties) + ? $row->properties + : (json_decode($row->properties, true) ?: []); + + $managerId = $properties['manager_id'] ?? []; + + return array_values(array_map('intval', is_array($managerId) ? $managerId : [$managerId])); + } + + private function getLastUserAssignedToTaskRaw(int $processId, string $processTaskUuid, int $processRequestId): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? AND process_request_id = ? ORDER BY created_at DESC LIMIT 1', + [$processId, $processTaskUuid, $processRequestId] + ); + + return $row && $row->user_id ? (int) $row->user_id : null; + } + + private function getNextUserFromGroupAssignmentRaw(int $processId, string $processTaskUuid, ?array $users = null): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? ORDER BY created_at DESC, id DESC LIMIT 1', + [$processId, $processTaskUuid] + ); + if ($users === null) { + $users = $this->getAssignableUserIdsRaw($processId, $processTaskUuid); + } + if (empty($users)) { + return null; + } + sort($users); + $lastUserId = $row && $row->user_id ? (int) $row->user_id : null; + if ($lastUserId) { + foreach ($users as $user) { + if ($user > $lastUserId) { + return (int) $user; + } + } + } + + return (int) $users[0]; + } + + private function getNextUserAssignmentRaw(int $processId, string $processTaskUuid, ?array $users = null): ?int + { + $row = DB::selectOne( + 'SELECT user_id FROM process_request_tokens WHERE process_id = ? AND element_id = ? ORDER BY created_at DESC LIMIT 1', + [$processId, $processTaskUuid] + ); + if ($users === null) { + $users = $this->getAssignableUserIdsRaw($processId, $processTaskUuid); + } + if (empty($users)) { + return null; + } + sort($users); + $lastUserId = $row && $row->user_id ? (int) $row->user_id : null; + if ($lastUserId) { + foreach ($users as $user) { + if ($user > $lastUserId) { + return (int) $user; + } + } + } + + return (int) $users[0]; + } + + private function getAssignableUserIdsRaw(int $processId, string $processTaskUuid): array + { + $assignments = DB::select( + 'SELECT assignment_id, assignment_type FROM process_task_assignments WHERE process_id = ? AND process_task_id = ?', + [$processId, $processTaskUuid] + ); + + $users = []; + $groupIds = []; + foreach ($assignments as $assignment) { + if ($assignment->assignment_type === User::class) { + $users[(int) $assignment->assignment_id] = (int) $assignment->assignment_id; + } else { + $groupIds[] = (int) $assignment->assignment_id; + } + } + + if ($groupIds) { + $this->mergeGroupMemberUserIdsRaw($groupIds, $users); + } + + return array_values($users); + } + + private function mergeGroupMemberUserIdsRaw(array $groupIds, array &$users): void + { + $pending = array_values(array_unique(array_map('intval', $groupIds))); + $visitedGroups = []; + + while ($pending) { + $batch = array_values(array_diff($pending, $visitedGroups)); + if (empty($batch)) { + break; + } + $visitedGroups = array_merge($visitedGroups, $batch); + $pending = []; + $placeholders = implode(',', array_fill(0, count($batch), '?')); + + $members = DB::select( + "SELECT member_id, member_type FROM group_members WHERE group_id IN ($placeholders)", + $batch + ); + + $subGroupIds = []; + foreach ($members as $member) { + if ($member->member_type === User::class) { + $users[(int) $member->member_id] = (int) $member->member_id; + } elseif ($member->member_type === Group::class) { + $subGroupIds[] = (int) $member->member_id; + } + } + + if ($subGroupIds) { + $subGroupIds = array_values(array_unique($subGroupIds)); + $groupPlaceholders = implode(',', array_fill(0, count($subGroupIds), '?')); + $activeGroups = DB::select( + "SELECT id FROM groups WHERE id IN ($groupPlaceholders) AND status = ?", + array_merge($subGroupIds, ['ACTIVE']) + ); + foreach ($activeGroups as $group) { + $pending[] = (int) $group->id; + } + } + } + + if (empty($users)) { + return; + } + + $userIds = array_keys($users); + $userPlaceholders = implode(',', array_fill(0, count($userIds), '?')); + $statusPlaceholders = implode(',', array_fill(0, count(Process::NOT_ASSIGNABLE_USER_STATUS), '?')); + $activeRows = DB::select( + "SELECT id FROM users WHERE id IN ($userPlaceholders) AND status NOT IN ($statusPlaceholders)", + array_merge($userIds, Process::NOT_ASSIGNABLE_USER_STATUS) + ); + $activeIds = array_flip(array_map(fn ($row) => (int) $row->id, $activeRows)); + $users = array_intersect_key($users, $activeIds); + } + + private function getNextUserFromVariableRaw($activity, ProcessRequestToken $token): ?int + { + try { + $userExpression = $activity->getProperty('assignedUsers'); + $dataManager = new DataManager(); + $instanceData = $dataManager->getData($token); + $mustache = new Mustache_Engine(); + $userId = (int) $mustache->render($userExpression, $instanceData); + if (!$this->getUserByIdRaw($userId)) { + throw new InvalidUserAssignmentException($userExpression, $userId); + } + + return $userId; + } catch (Exception $exception) { + return null; + } + } + + private function getNextUserFromProcessVariableRaw( + Process $process, + int $processId, + $activity, + ProcessRequestToken $token + ): ?int { + if ($token->getSelfServiceAttribute()) { + return null; + } + + $usersVariable = $activity->getProperty('assignedUsers'); + $groupsVariable = $activity->getProperty('assignedGroups'); + $dataManager = new DataManager(); + $instanceData = $dataManager->getData($token); + + $assignedUsers = $usersVariable ? feelExpression($usersVariable, $instanceData) : []; + $assignedGroups = $groupsVariable ? feelExpression($groupsVariable, $instanceData) : []; + + if (!is_array($assignedUsers)) { + $assignedUsers = [$assignedUsers]; + } + if (!is_array($assignedGroups)) { + $assignedGroups = [$assignedGroups]; + } + + $users = []; + if ($assignedUsers) { + $uniqueUsers = array_values(array_unique(array_map('intval', $assignedUsers))); + $placeholders = implode(',', array_fill(0, count($uniqueUsers), '?')); + $statusPlaceholders = implode(',', array_fill(0, count(Process::NOT_ASSIGNABLE_USER_STATUS), '?')); + $activeRows = DB::select( + "SELECT id FROM users WHERE id IN ($placeholders) AND status NOT IN ($statusPlaceholders)", + array_merge($uniqueUsers, Process::NOT_ASSIGNABLE_USER_STATUS) + ); + foreach ($activeRows as $row) { + $users[(int) $row->id] = (int) $row->id; + } + + $oooPlaceholders = implode(',', array_fill(0, count($uniqueUsers), '?')); + $oooRows = DB::select( + "SELECT delegation_user_id FROM users WHERE id IN ($oooPlaceholders) AND status = ? AND delegation_user_id IS NOT NULL", + array_merge($uniqueUsers, ['OUT_OF_OFFICE']) + ); + foreach ($oooRows as $row) { + $users[(int) $row->delegation_user_id] = (int) $row->delegation_user_id; + } + } + + foreach ($assignedGroups as $groupId) { + $this->mergeGroupMemberUserIdsRaw([(int) $groupId], $users); + } + + return $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId(), array_values($users)); + } + + private function getNextUserByRuleRaw( + Process $process, + int $processId, + $activity, + ProcessRequestToken $token + ): ?int { + $assignmentRules = $activity->getProperty('assignmentRules', null); + $instanceData = $token->getInstance()->getDataStore()->getData(); + + if (!$assignmentRules || !$instanceData) { + return null; + } + + $list = json_decode($assignmentRules); + $list = ($list === null) ? [] : $list; + foreach ($list as $item) { + $formalExp = new FormalExpression(); + $formalExp->setLanguage('FEEL'); + $formalExp->setBody($item->expression); + if (!$formalExp($instanceData)) { + continue; + } + + switch ($item->type) { + case 'user_group': + $users = []; + foreach ($item->assignee->users as $user) { + $users[$user] = $user; + } + foreach ($item->assignee->groups as $group) { + $this->mergeGroupMemberUserIdsRaw([(int) $group], $users); + } + $userId = $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId(), array_values($users)); + break; + case 'group': + $users = []; + $this->mergeGroupMemberUserIdsRaw([(int) $item->assignee], $users); + $userId = $this->getNextUserFromGroupAssignmentRaw($processId, $activity->getId(), array_values($users)); + break; + case 'user': + $userId = (int) $item->assignee; + break; + case 'requester': + $userId = $this->getRequesterUserIdRaw($activity, $token); + break; + case 'manual': + case 'self_service': + $userId = null; + break; + case 'user_by_id': + $mustache = new Mustache_Engine(); + $userId = (int) $mustache->render($item->assignee, $instanceData); + break; + case 'script': + default: + $userId = null; + } + + return $userId ?: null; + } + + return null; + } +} diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 77ad20fba9..8a02c2c722 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -44,6 +44,8 @@ class TokenRepository implements TokenRepositoryInterface */ private $instanceRepository; + private ?ProcessExecutionRawRepository $processExecutionRaw = null; + /** * Initialize the Token Repository. * @@ -54,6 +56,11 @@ public function __construct(ExecutionInstanceRepository $instanceRepository) $this->instanceRepository = $instanceRepository; } + private function processExecutionRaw(): ProcessExecutionRawRepository + { + return $this->processExecutionRaw ??= app(ProcessExecutionRawRepository::class); + } + /** * Creates an instance of Token. * @@ -100,7 +107,8 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter if ($isScriptOrServiceTask) { $user = null; } else { - $user = $token->getInstance()->getProcess()->getOwnerDocument()->getModel()->getNextUser($activity, $token); + $processModel = $token->getInstance()->getProcess()->getOwnerDocument()->getModel(); + $user = $this->processExecutionRaw()->getNextUserRaw($processModel, $activity, $token); } $this->addUserToData($token->getInstance(), $user); $this->addRequestToData($token->getInstance()); @@ -168,6 +176,7 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $token->getInstance()->updateCatchEvents(); $token->saveOrFail(); $token->setId($token->getKey()); + $request = $token->getInstance(); $request->last_stage_id = $token->stage_id; $request->last_stage_name = $token->stage_name; diff --git a/ProcessMaker/Support/ExecutionTimer.php b/ProcessMaker/Support/ExecutionTimer.php new file mode 100644 index 0000000000..2a3cef2f97 --- /dev/null +++ b/ProcessMaker/Support/ExecutionTimer.php @@ -0,0 +1,115 @@ + $task->id]); + * + * $this->authorize('update', $task); + * $timer->step('authorize'); + * + * WorkflowManager::completeTask($process, $instance, $task, $data); + * $timer->step('completeTask'); + * + * return new Resource($task->refresh()); + * + * Example 2 — loop iterations with explicit phase start (mark + stepPhase): + * + * $timer = ExecutionTimer::start('BpmnEngine::runToNextState', ['engine_uid' => $this->uid]); + * + * while ($step) { + * $iterStart = $timer->mark(); + * $step = $this->step(); + * $timer->stepPhase('step', $iterStart, ['iteration' => $iteration, 'step_result' => $step]); + * $iteration++; + * } + * + * Logs are written to the default log channel as `[channel] timing` with step_ms / phase_ms and total_ms. + * When APP_DEBUG is false, all methods are no-ops (zero overhead). + */ +class ExecutionTimer +{ + private float $start; + + private float $lastStep; + + private function __construct( + private readonly string $channel, + private readonly array $context, + private readonly bool $enabled + ) { + $this->start = microtime(true); + $this->lastStep = $this->start; + } + + public static function start(string $channel, array $context = []): self + { + return new self($channel, $context, (bool) config('app.debug')); + } + + public static function disabled(string $channel = '', array $context = []): self + { + return new self($channel, $context, false); + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Mark the start of a sub-phase (returns microtime for stepPhase). + */ + public function mark(): float + { + return microtime(true); + } + + /** + * Log elapsed time since the previous step. + */ + public function step(string $step, array $extra = []): self + { + if (!$this->enabled) { + return $this; + } + + $now = microtime(true); + Log::debug("[{$this->channel}] timing", array_merge($this->context, $extra, [ + 'step' => $step, + 'step_ms' => round(($now - $this->lastStep) * 1000, 2), + 'total_ms' => round(($now - $this->start) * 1000, 2), + ])); + $this->lastStep = $now; + + return $this; + } + + /** + * Log elapsed time since an explicit phase start (useful for loop iterations). + */ + public function stepPhase(string $step, float $phaseStart, array $extra = []): self + { + if (!$this->enabled) { + return $this; + } + + $now = microtime(true); + Log::debug("[{$this->channel}] timing", array_merge($this->context, $extra, [ + 'step' => $step, + 'phase_ms' => round(($now - $phaseStart) * 1000, 2), + 'total_ms' => round(($now - $this->start) * 1000, 2), + ])); + $this->lastStep = $now; + + return $this; + } +} From 02badb1dff13bee51ecf1a402feb840b0ff21722 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 26 May 2026 11:10:09 -0400 Subject: [PATCH 4/5] Remove unnecessary changes. --- ProcessMaker/Http/Controllers/Api/TaskController.php | 2 -- ProcessMaker/Repositories/TokenRepository.php | 1 - 2 files changed, 3 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 4f9ea2ef72..775aa83418 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ActivityReassignment; @@ -347,7 +346,6 @@ public function update(Request $request, ProcessRequestToken $task) $task->setRelation('process', $this->processExecutionRaw->getProcessForAuthorizeRaw($task->process_id)); } $this->authorize('update', $task); - if ($request->input('status') === 'COMPLETED') { if ($task->status === 'CLOSED') { return abort(422, __('Task already closed')); diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 8a02c2c722..cafba1a1ee 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -176,7 +176,6 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $token->getInstance()->updateCatchEvents(); $token->saveOrFail(); $token->setId($token->getKey()); - $request = $token->getInstance(); $request->last_stage_id = $token->stage_id; $request->last_stage_name = $token->stage_name; From b2c01866b5805196c80c2951bc8697e2d25fe078 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 27 May 2026 09:27:59 -0400 Subject: [PATCH 5/5] fix phpunit issues. --- .../Http/Controllers/Api/TaskController.php | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/TaskController.php b/ProcessMaker/Http/Controllers/Api/TaskController.php index 775aa83418..bf498295d4 100644 --- a/ProcessMaker/Http/Controllers/Api/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/TaskController.php @@ -40,13 +40,13 @@ class TaskController extends Controller { - public function __construct( - protected ProcessExecutionRawRepository $processExecutionRaw, - ) { - } - use TaskControllerIndexMethods; + private function processExecutionRaw(): ProcessExecutionRawRepository + { + return app(ProcessExecutionRawRepository::class); + } + /** * A whitelist of attributes that should not be * sanitized by our SanitizeInput middleware. @@ -343,7 +343,7 @@ public function show(ProcessRequestToken $task) public function update(Request $request, ProcessRequestToken $task) { if (!$task->relationLoaded('process')) { - $task->setRelation('process', $this->processExecutionRaw->getProcessForAuthorizeRaw($task->process_id)); + $task->setRelation('process', $this->processExecutionRaw()->getProcessForAuthorizeRaw($task->process_id)); } $this->authorize('update', $task); if ($request->input('status') === 'COMPLETED') { @@ -352,39 +352,39 @@ public function update(Request $request, ProcessRequestToken $task) } // Skip ConvertEmptyStringsToNull and TrimStrings middlewares $data = json_optimize_decode($request->getContent(), true); - $requestRow = $this->processExecutionRaw->getProcessRequestRowForCompleteRaw($task->process_request_id); - $data = SanitizeHelper::sanitizeData($data['data'], null, $this->processExecutionRaw->getDoNotSanitizeFromRowRaw($requestRow)); + $requestRow = $this->processExecutionRaw()->getProcessRequestRowForCompleteRaw($task->process_request_id); + $data = SanitizeHelper::sanitizeData($data['data'], null, $this->processExecutionRaw()->getDoNotSanitizeFromRowRaw($requestRow)); //Call the manager to trigger the start event - $process = $this->processExecutionRaw->getProcessForCompleteRaw($task->process_id); - $instance = $this->processExecutionRaw->getProcessRequestFromRowRaw($requestRow); + $process = $this->processExecutionRaw()->getProcessForCompleteRaw($task->process_id); + $instance = $this->processExecutionRaw()->getProcessRequestFromRowRaw($requestRow); $instance->setRelation('process', $process); - if ($processVersion = $this->processExecutionRaw->getProcessVersionForCompleteRaw($requestRow->process_version_id ?? null)) { + if ($processVersion = $this->processExecutionRaw()->getProcessVersionForCompleteRaw($requestRow->process_version_id ?? null)) { $instance->setRelation('processVersion', $processVersion); } $task->setRelation('processRequest', $instance); $task->setRelation('process', $process); - if ($this->processExecutionRaw->taskHasDraftRaw($task->id)) { + if ($this->processExecutionRaw()->taskHasDraftRaw($task->id)) { TaskDraft::moveDraftFiles($task); } WorkflowManager::completeTask($process, $instance, $task, $data); - $responseInstance = $this->processExecutionRaw->getProcessRequestForResponseRaw($task->process_request_id); + $responseInstance = $this->processExecutionRaw()->getProcessRequestForResponseRaw($task->process_request_id); $responseInstance->setRelation('process', $process); - $taskRefreshed = $this->processExecutionRaw->refreshTaskRaw($task, $process, $responseInstance); + $taskRefreshed = $this->processExecutionRaw()->refreshTaskRaw($task, $process, $responseInstance); return new Resource($taskRefreshed); } elseif (!empty($request->input('user_id'))) { - $process = $this->processExecutionRaw->getProcessForReassignRaw($task->process_id); + $process = $this->processExecutionRaw()->getProcessForReassignRaw($task->process_id); $task->setRelation('process', $process); $userToAssign = $request->input('user_id'); $comments = $request->input('comments'); $task->reassign($userToAssign, $request->user(), $comments); - $taskRefreshed = $this->processExecutionRaw->refreshTaskRaw($task, $process, $task->processRequest); + $taskRefreshed = $this->processExecutionRaw()->refreshTaskRaw($task, $process, $task->processRequest); CaseUpdate::dispatchSync($task->processRequest, $taskRefreshed); return new Resource($taskRefreshed);