diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php index 334c109077..6b89129496 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php @@ -15,6 +15,7 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\ProcessTranslations\TranslationManager; +use ProcessMaker\Repositories\TaskV11ShowFastRepository; class TaskController extends Controller { @@ -92,11 +93,67 @@ private function addSubprocessTasks(Request $request, Builder &$q) public function show(ProcessRequestToken $task) { + if (config('app.optimized_tasks_start_enabled')) { + return $this->showFast($task); + } + $resource = TaskResource::preprocessInclude(request(), ProcessRequestToken::where('id', $task->id)); return $resource->toArray(request()); } + public function showFast(ProcessRequestToken $task) + { + $httpRequest = request(); + $this->removeDataFromIncludeQuery($httpRequest); + + $repository = app(TaskV11ShowFastRepository::class); + $token = $repository->findForShow($task->id); + + if ($this->queryIncludesRequestData($httpRequest)) { + $repository->hydrateProcessRequestData($token); + } + + $resource = TaskResource::fromTokenWithIncludes($token, $httpRequest); + + return $resource->toArray($httpRequest); + } + + private function removeDataFromIncludeQuery(Request $request): void + { + $include = $request->query('include'); + if (!is_string($include) || $include === '') { + return; + } + + $parts = array_values(array_filter( + array_map('trim', explode(',', $include)), + static fn (string $part) => $part !== '' && $part !== 'data' + )); + + if ($parts === []) { + $request->query->remove('include'); + } else { + $request->query->set('include', implode(',', $parts)); + } + } + + private function queryIncludesRequestData(Request $request): bool + { + $include = $request->query('include', ''); + if (!is_string($include) || $include === '') { + return false; + } + + foreach (array_map('trim', explode(',', $include)) as $part) { + if ($part === 'requestData') { + return true; + } + } + + return false; + } + public function showScreen($taskId) { $task = ProcessRequestToken::select( diff --git a/ProcessMaker/Http/Resources/V1_1/TaskResource.php b/ProcessMaker/Http/Resources/V1_1/TaskResource.php index 26755cd5f5..c4b934177f 100644 --- a/ProcessMaker/Http/Resources/V1_1/TaskResource.php +++ b/ProcessMaker/Http/Resources/V1_1/TaskResource.php @@ -204,6 +204,45 @@ public static function preprocessInclude(Request $request, Builder $query): self return new static($model); } + /** + * Build a resource for an already-loaded token (fast show path). + */ + public static function fromTokenWithIncludes(ProcessRequestToken $token, Request $request): self + { + $include = $request->query('include', []); + if ($include) { + $include = explode(',', $include); + } + $include = array_merge($include, self::$defaultIncludes); + + foreach (self::$includeMethods as $key) { + if (!in_array($key, $include)) { + continue; + } + + if ($key === 'user' && $token->relationLoaded('user')) { + continue; + } + if ($key === 'processRequest' && $token->relationLoaded('processRequest')) { + continue; + } + if ($key === 'process' && $token->relationLoaded('process')) { + continue; + } + if ($key === 'draft' && $token->relationLoaded('draft')) { + continue; + } + if ($key === 'requestor' && $token->relationLoaded('processRequest') + && $token->processRequest->relationLoaded('user')) { + continue; + } + + self::addRelationship($token, $key); + } + + return new static($token); + } + private function processInclude(Request $request, array $array) { $include = $request->query('include', []); diff --git a/ProcessMaker/Repositories/TaskV11ShowFastRepository.php b/ProcessMaker/Repositories/TaskV11ShowFastRepository.php new file mode 100644 index 0000000000..42b4e70da2 --- /dev/null +++ b/ProcessMaker/Repositories/TaskV11ShowFastRepository.php @@ -0,0 +1,91 @@ +where('id', $taskId)->firstOrFail(); + + $row = DB::selectOne( + 'SELECT id, uuid, process_id, user_id, parent_request_id, status, do_not_sanitize, errors, + completed_at, initiated_at, created_at, updated_at, case_title, case_number, callable_id, + process_version_id, name + FROM process_requests + WHERE id = ? + LIMIT 1', + [$token->process_request_id] + ); + + $processRequest = new ProcessRequest(); + $processRequest->setRawAttributes((array) $row, true); + $processRequest->exists = true; + $token->setRelation('processRequest', $processRequest); + + if ($token->user_id) { + $user = User::query()->select(self::USER_COLUMNS)->find($token->user_id); + $token->setRelation('user', $user); + } + + if ($processRequest->user_id) { + $requestor = User::query()->select(self::USER_COLUMNS)->find($processRequest->user_id); + $processRequest->setRelation('user', $requestor); + } + + if ($token->process_id) { + $process = Process::query()->select(['id', 'name'])->find($token->process_id); + $token->setRelation('process', $process); + } + + $token->load(['draft' => function ($query) { + $query->select(['id', 'task_id', 'data']); + }]); + + return $token; + } + + /** + * Load process_requests.data onto the lean processRequest relation (fast show only). + */ + public function hydrateProcessRequestData(ProcessRequestToken $token): void + { + $processRequest = $token->processRequest; + if (!$processRequest || !$processRequest->getKey()) { + return; + } + + if (array_key_exists('data', $processRequest->getAttributes())) { + return; + } + + $raw = DB::table('process_requests') + ->where('id', $processRequest->getKey()) + ->value('data'); + + $data = []; + if (is_array($raw)) { + $data = $raw; + } elseif (is_string($raw)) { + $data = json_decode($raw, true) ?? []; + } + + $processRequest->setAttribute('data', $data); + } +} diff --git a/ProcessMaker/Traits/TaskResourceIncludes.php b/ProcessMaker/Traits/TaskResourceIncludes.php index 2408f49b2e..92b4bcd3d8 100644 --- a/ProcessMaker/Traits/TaskResourceIncludes.php +++ b/ProcessMaker/Traits/TaskResourceIncludes.php @@ -138,7 +138,9 @@ private function includeDefinition() private function includeBpmnTagName() { - return ['bpmn_tag_name' => $this->getBpmnDefinition()->localName]; + $definition = $this->getBpmnDefinition(); + + return ['bpmn_tag_name' => $definition ? $definition->localName : null]; } private function includeProcess() diff --git a/config/app.php b/config/app.php index b3c5891f98..75171f2f01 100644 --- a/config/app.php +++ b/config/app.php @@ -47,6 +47,9 @@ // The timeout length for API calls, in milliseconds (0 for no timeout) 'api_timeout' => env('API_TIMEOUT', 5000), + // Optimized V1.1 task show (case start): lean load + skips duplicate request data include + 'optimized_tasks_start_enabled' => env('OPTIMIZED_TASKS_START_ENABLED', false), + // Hide server headers for security (prevents information disclosure) 'hide_server_headers' => env('HIDE_SERVER_HEADERS', true), diff --git a/tests/Feature/Api/V1_1/TaskShowFastResponseTimeTest.php b/tests/Feature/Api/V1_1/TaskShowFastResponseTimeTest.php new file mode 100644 index 0000000000..d828e0708c --- /dev/null +++ b/tests/Feature/Api/V1_1/TaskShowFastResponseTimeTest.php @@ -0,0 +1,179 @@ +create([ + 'data' => $payload, + 'status' => 'ACTIVE', + ]); + + $task = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'status' => 'ACTIVE', + 'element_type' => 'task', + ]); + + return [$task, self::LEGACY_TASK_INCLUDES]; + } + + private function measureTaskShowMs(ProcessRequestToken $task, string $includes): float + { + $url = route('api.1.1.tasks.show', $task->id) + . '?include=' . urlencode($includes); + + $started = microtime(true); + $response = $this->apiCall('GET', $url); + $elapsedMs = (microtime(true) - $started) * 1000; + + $response->assertStatus(200); + + return $elapsedMs; + } + + private function medianMs(array $samples): float + { + sort($samples); + $count = count($samples); + $middle = (int) floor($count / 2); + + if ($count % 2 === 1) { + return $samples[$middle]; + } + + return ($samples[$middle - 1] + $samples[$middle]) / 2; + } + + public function testShowFastWithIncludeDataOnlyReturns200(): void + { + [$task] = $this->createTaskWithLargeRequestData(10, 10); + + config(['app.optimized_tasks_start_enabled' => true]); + $response = $this->apiCall( + 'GET', + route('api.1.1.tasks.show', $task->id) . '?include=data' + ); + + $response->assertStatus(200); + $this->assertArrayHasKey('id', $response->json()); + $this->assertArrayNotHasKey('data', $response->json()); + } + + public function testShowFastRequestDataMatchesLegacyShow(): void + { + [$task, $includes] = $this->createTaskWithLargeRequestData(500, 40); + + config(['app.optimized_tasks_start_enabled' => false]); + $legacy = $this->apiCall( + 'GET', + route('api.1.1.tasks.show', $task->id) . '?include=' . urlencode($includes) + ); + $legacy->assertStatus(200); + + config(['app.optimized_tasks_start_enabled' => true]); + $fast = $this->apiCall( + 'GET', + route('api.1.1.tasks.show', $task->id) . '?include=' . urlencode($includes) + ); + $fast->assertStatus(200); + + $this->assertSame( + $legacy->json('request_data.marker'), + $fast->json('request_data.marker') + ); + $this->assertSame( + count($legacy->json('request_data')), + count($fast->json('request_data')) + ); + $this->assertArrayNotHasKey('data', $fast->json()); + $this->assertSame($legacy->json('requestor.username'), $fast->json('requestor.username')); + $this->assertSame($legacy->json('requestor.status'), $fast->json('requestor.status')); + } + + public function testShowFastIsFasterThanLegacyShowWithLargeRequestData(): void + { + [$task, $includes] = $this->createTaskWithLargeRequestData(12000, 100); + $ratio = $this->maxFastToLegacyRatio(); + $iterations = 3; + + config(['app.optimized_tasks_start_enabled' => false]); + $legacySamples = []; + for ($i = 0; $i < $iterations; $i++) { + $legacySamples[] = $this->measureTaskShowMs($task, $includes); + } + $legacyMs = $this->medianMs($legacySamples); + + config(['app.optimized_tasks_start_enabled' => true]); + $fastSamples = []; + for ($i = 0; $i < $iterations; $i++) { + $fastSamples[] = $this->measureTaskShowMs($task, $includes); + } + $fastMs = $this->medianMs($fastSamples); + + $this->assertLessThan( + $legacyMs, + $fastMs, + sprintf( + 'showFast median %.2fms should be less than legacy median %.2fms', + $fastMs, + $legacyMs + ) + ); + + $this->assertLessThanOrEqual( + $legacyMs * $ratio, + $fastMs, + sprintf( + 'showFast median %.2fms exceeded legacy median %.2fms * ratio %.2f (set TASK_SHOW_FAST_MAX_RATIO to relax)', + $fastMs, + $legacyMs, + $ratio + ) + ); + } +}