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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions ProcessMaker/Http/Controllers/Api/TaskController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@
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
{
use TaskControllerIndexMethods;

private ?ProcessExecutionRawRepository $processExecutionRaw = null;

private function processExecutionRaw(): ProcessExecutionRawRepository
{
return $this->processExecutionRaw ??= app(ProcessExecutionRawRepository::class);
}

/**
* A whitelist of attributes that should not be
* sanitized by our SanitizeInput middleware.
Expand Down Expand Up @@ -336,28 +344,48 @@ 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);
$data = SanitizeHelper::sanitizeData($data['data'], null, $task->processRequest->do_not_sanitize ?? []);
$instance = $this->processExecutionRaw()->getProcessRequestForCompleteRaw($task->process_request_id);
$data = SanitizeHelper::sanitizeData($data['data'], null, $instance->do_not_sanitize ?? []);

//Call the manager to trigger the start event
$process = $task->process;
$instance = $task->processRequest;
TaskDraft::moveDraftFiles($task);
$process = $this->processExecutionRaw()->getProcessForCompleteRaw($task->process_id);
$instance->setRelation('process', $process);
if ($processVersion = $this->processExecutionRaw()->getProcessVersionForCompleteRaw($instance->process_version_id)) {
$instance->setRelation('processVersion', $processVersion);
}
$task->setRelation('processRequest', $instance);
$task->setRelation('process', $process);

if ($this->processExecutionRaw()->taskHasDraftRaw($task->id)) {
TaskDraft::moveDraftFiles($task);
}

WorkflowManager::completeTask($process, $instance, $task, $data);

return new Resource($task->refresh());
$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->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 = $task->refresh();

$taskRefreshed = $this->processExecutionRaw()->refreshTaskRaw($task, $process, $task->processRequest);
CaseUpdate::dispatchSync($task->processRequest, $taskRefreshed);

return new Resource($taskRefreshed);
Expand Down
138 changes: 125 additions & 13 deletions ProcessMaker/Jobs/BpmnAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -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;

Expand All @@ -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)
Expand All @@ -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(self $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]);
Expand All @@ -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();
Expand Down Expand Up @@ -152,30 +195,73 @@ 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
*
* @return array
*/
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
*
* @param int $instanceId
*
* @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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -251,6 +337,30 @@ private function findInstanceWithRetry($instanceId)
throw new ModelNotFoundException("ProcessRequest #{$instanceId} not found after {$totalAttempts} attempts");
}

/**
* Load ProcessRequest for BPMN actions. In lightweight mode, only lock and
* revision metadata are loaded 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',
]);
} else {
$query->with([
'process',
'processVersion',
'collaboration',
]);
}

return $query->findOrFail($instanceId);
}

/**
* Request a lock for the instance
* @param array $ids
Expand Down Expand Up @@ -349,6 +459,8 @@ public function __destruct()
$this->instance = null;
$this->engine = null;
$this->lock = null;
$this->loadedContext = null;
$this->loadedExecutionRevision = null;
gc_collect_cycles();
}
}
11 changes: 10 additions & 1 deletion ProcessMaker/Models/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 7 additions & 2 deletions ProcessMaker/Models/ProcessRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -848,8 +848,13 @@ public function updateCatchEvents()
public function mergeLatestStoredData()
{
$store = $this->getDataStore();
$latest = self::select('data')->find($this->getId());
$this->data = $store->updateArray($latest->data);
// Load only the data column (Eloquent cast applies) without hydrating the full row.
$latest = static::query()->whereKey($this->getKey())->first(['data']);
$latestData = $latest?->data ?? [];
if (!is_array($latestData)) {
$latestData = [];
}
$this->data = $store->updateArray($latestData);

return $this->data;
}
Expand Down
Loading