Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Latest
------

## Changes
* [#337](https://github.com/cleverage/process-bundle/issues/337) `StopTask` (`stop`): a process stopped on purpose is identified as stopped, not as failed. Its history is flagged as stopped (new `ProcessHistory::STATE_STOPPED`, `setStopped()`, `isStopped()`) instead of failed, the `Process <code> stopped` log is written at the `success_level` (instead of `Process <code> failed` at the `failed_level`), and the `cleverage_process.end` event tells it with the new `ProcessEvent::isProcessStopped()`. The process still ends normally (tasks finalized, exit code 0, parent process going on). Update documentation, add tests.
* [#339](https://github.com/cleverage/process-bundle/issues/339) A shared service referenced by several tasks of the same process is rejected when the container is compiled (`InvalidArgumentException` naming the service, the tasks and the process): all these tasks ran with the same instance, initialized with the options of each task in turn, without error. Declare the task services with `shared: false` (applied by the autoconfiguration of `TaskInterface` since 5.4). Update documentation, add tests.
* [#351](https://github.com/cleverage/process-bundle/issues/351) `ProcessManager` and `ProcessHelpCommand` are typed on `TaskRegistryInterface` instead of the final `TaskRegistry` class: the task registry can be decorated or replaced. Add tests.

Expand Down
7 changes: 4 additions & 3 deletions docs/04-advanced_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ When a process is executed (with the `cleverage:process:execute` command or with
6. **finalizes** every task, in the order they are configured (see
[finalizable tasks](02-task_types.md#finalizable-tasks))
7. returns the last output of the `end_point` task (or `null` if there is none) and dispatches the
`cleverage_process.end` event
`cleverage_process.end` event (`ProcessEvent::isProcessStopped()` tells whether the process was stopped on purpose by
a [StopTask](reference/tasks/stop_task.md))

If an exception is thrown at any point, the `cleverage_process.fail` event is dispatched and the exception is rethrown.
Note that a task error handled by the `stop` strategy does not surface as the original exception: the process manager
Expand Down Expand Up @@ -153,7 +154,7 @@ A task may also control the flow without any error:
tasks are executed, blocking tasks that already received inputs are proceeded, flushable tasks are flushed and every
task is finalized. It is not a failure for the process manager: the `cleverage_process.end` event is dispatched (not
`cleverage_process.fail`) and the command exits with code 0. The [StopTask](reference/tasks/stop_task.md) also flags
the process history as failed, but this does not change the event nor the exit code
the process history as stopped: the process ends as stopped on purpose, not as a failure

The legacy `errors` key is a deprecated alias of `error_outputs` (defining both throws an exception).

Expand Down Expand Up @@ -209,7 +210,7 @@ to the process code, input, context and, depending on the event, output or error
| Event name | Constant | Dispatched |
| ---------- | -------- | ---------- |
| `cleverage_process.start` | `ProcessEvent::EVENT_PROCESS_STARTED` | on process start |
| `cleverage_process.end` | `ProcessEvent::EVENT_PROCESS_ENDED` | on successful process end (with the process output) |
| `cleverage_process.end` | `ProcessEvent::EVENT_PROCESS_ENDED` | on successful process end (with the process output), including a process stopped on purpose (`isProcessStopped()`) |
| `cleverage_process.fail` | `ProcessEvent::EVENT_PROCESS_FAILED` | on failed process end (with the associated error) |

Those events are also dispatched for sub-processes executed with the `ProcessExecutorTask`.
Expand Down
8 changes: 4 additions & 4 deletions docs/reference/01-process_definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ not define its own `error_strategy` (see [task definition](02-task_definition.md

**logs**: optional [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424) severities of the logs written at the end
of each process (channel `cleverage_process`, with the `duration` of the process in seconds in the record context):
* `success_level` (default *info*): level of the `Process <process_code> succeed` log
* `failed_level` (default *debug*): level of the `Process <process_code> failed` log, written when the process fails,
either with an exception (in addition to the `Critical process failure` log) or when it is stopped without exception
(e.g. by a [StopTask](tasks/stop_task.md))
* `success_level` (default *info*): level of the `Process <process_code> succeed` log, and of the
`Process <process_code> stopped` log of a process stopped on purpose by a [StopTask](tasks/stop_task.md)
* `failed_level` (default *debug*): level of the `Process <process_code> failed` log, written when the process fails
(in addition to the `Critical process failure` log)

Without UI, the success log is the only trace of a succeeded process: lower its level (e.g. *debug*) only if processes
are followed by other means.
Expand Down
49 changes: 45 additions & 4 deletions docs/reference/tasks/stop_task.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
StopTask
========

Immediately stops the process and marks its history as failed. Unlike [DieTask](die_task.md), the stop is handled by
the process manager (no following task is executed, including remaining iterations). Useful to halt execution in an
error branch.
Stops the process on purpose: this is not a failure, but a decision of the process, e.g. not going on when the source
file does not exist. Unlike [DieTask](die_task.md), the stop is handled by the process manager:
* the stop signal goes back up to the root task: no following task is executed, including remaining iterations (other
root tasks are still executed, see [advanced workflow](../../04-advanced_workflow.md));
* every task is finalized (e.g. a writer closes its file);
* the process ends as **stopped**: its history is flagged as stopped (`ProcessHistory::isStopped()`), the
`Process <process_code> stopped` log is written (at the `success_level`, see
[process definition](../01-process_definition.md)), and the `cleverage_process.end` event is dispatched with
`ProcessEvent::isProcessStopped()` returning `true` (the UI shows the execution as *Stopped*);
* `cleverage:process:execute` exits with code 0, and the task running a sub-process
([ProcessExecutorTask](process_executor_task.md)) goes on.

To stop a process because of an error, let the error fail the process instead (error strategy `stop`).

Task reference
--------------
Expand All @@ -29,7 +39,38 @@ This task has no option.
Examples
--------

* Stop the process on the first validation error
* Do not go on when the source file does not exist (`-c source:"'path/to/file.csv'"`)

```yaml
# Task configuration level
source_path:
task: constant_output
options:
output: '{{ source }}'
outputs: [source_exists]
source_exists:
task: transformer
options:
transformers:
callback:
callback: file_exists
outputs: [check_source]
check_source:
task: filter
options:
match:
'': true
outputs: [read]
error_outputs: [stop]
stop:
task: stop
read:
task: csv_reader
options:
file_path: '{{ source }}'
```

* Stop the process on the first invalid item

```yaml
# Task configuration level
Expand Down
9 changes: 9 additions & 0 deletions src/Event/ProcessEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public function __construct(
protected array $processContext = [],
protected mixed $processOutput = null,
protected ?\Throwable $processError = null,
protected bool $processStopped = false,
) {
}

Expand Down Expand Up @@ -67,4 +68,12 @@ public function getProcessError(): ?\Throwable
{
return $this->processError;
}

/**
* The process has ended because it was stopped on purpose (e.g. by the StopTask), see ProcessHistory::isStopped().
*/
public function isProcessStopped(): bool
{
return $this->processStopped;
}
}
22 changes: 15 additions & 7 deletions src/Manager/ProcessManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,13 @@ public function execute(string $processCode, mixed $input = null, array $context
$this->processLogger->debug('Process start');

$result = $this->doExecute($processCode, $input, $context);
// doExecute() leaves the history of the executed process, read before restoring the one of the parent
$processStopped = $this->processHistory?->isStopped() ?? false;
$this->processHistory = $parentProcessHistory;

$this->processLogger->debug('Process end');
$this->eventDispatcher->dispatch(
new ProcessEvent($processCode, $input, $context, $result),
new ProcessEvent($processCode, $input, $context, $result, null, $processStopped),
ProcessEvent::EVENT_PROCESS_ENDED
);
} catch (\Throwable $error) {
Expand All @@ -159,8 +162,8 @@ public function execute(string $processCode, mixed $input = null, array $context

throw $error;
} finally {
// When the process has failed, doExecute() did not restore the history of the parent process (if any): the
// logs above are still attributed to the failed process, the next ones to the parent process
// When the process has failed, the history of the parent process (if any) is not restored yet: the logs
// above are still attributed to the failed process, the next ones to the parent process
$this->processHistory = $parentProcessHistory;
// The logs of the parent process are attributed to the task that has executed this process (if any)
$this->taskConfiguration = $parentTaskConfiguration;
Expand All @@ -176,7 +179,6 @@ public function execute(string $processCode, mixed $input = null, array $context
*/
protected function doExecute(string $processCode, mixed $input = null, array $context = []): mixed
{
$parentProcessHistory = $this->processHistory;
$processConfiguration = $this->processConfigurationRegistry->getProcessConfiguration($processCode);
$processHistory = $this->initializeStates($processConfiguration, $context);
$this->processHistory = $processHistory;
Expand Down Expand Up @@ -222,7 +224,7 @@ protected function doExecute(string $processCode, mixed $input = null, array $co
->getOutput();
}
} catch (\Throwable $error) {
if ($processHistory->isStarted()) {
if ($processHistory->isStarted() || $processHistory->isStopped()) {
$processHistory->setFailed();
}
$this->endProcess($processHistory);
Expand All @@ -236,8 +238,6 @@ protected function doExecute(string $processCode, mixed $input = null, array $co
}
}

$this->processHistory = $parentProcessHistory;

return $returnValue;
}

Expand Down Expand Up @@ -617,6 +617,14 @@ protected function endProcess(ProcessHistory $history): void
'duration' => $history->getDuration(),
]
);
} elseif ($history->isStopped()) {
$this->processLogger->log(
$processConfiguration->getSuccessLogLevel(),
"Process {$history->getProcessCode()} stopped",
[
'duration' => $history->getDuration(),
]
);
} elseif ($history->isFailed()) {
$this->processLogger->log(
$processConfiguration->getFailedLogLevel(),
Expand Down
16 changes: 16 additions & 0 deletions src/Model/ProcessHistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class ProcessHistory implements \Stringable

final public const STATE_FAILED = 'failed';

final public const STATE_STOPPED = 'stopped';

protected float $id;

protected string $processCode;
Expand Down Expand Up @@ -101,6 +103,15 @@ public function setFailed(): void
$this->state = self::STATE_FAILED;
}

/**
* Set the process as stopped on purpose (e.g. by the StopTask): it is not a failure.
*/
public function setStopped(): void
{
$this->endDate = new \DateTime();
$this->state = self::STATE_STOPPED;
}

/**
* Set the process as succeded.
*/
Expand All @@ -123,6 +134,11 @@ public function isFailed(): bool
return self::STATE_FAILED === $this->state;
}

public function isStopped(): bool
{
return self::STATE_STOPPED === $this->state;
}

/**
* Get process duration in seconds.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/Task/StopTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
use CleverAge\ProcessBundle\Model\TaskInterface;

/**
* Allows to directly stop a process, marking it as failed.
* Allows to directly stop a process on purpose, marking it as stopped: it is not a failure.
*
* @final since 5.4
*/
Expand All @@ -27,6 +27,6 @@ public function execute(ProcessState $state): void
{
$state->setStopped(true);
$state->getProcessHistory()
->setFailed();
->setStopped();
}
}
34 changes: 26 additions & 8 deletions tests/Manager/ProcessManagerEndLogTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,18 @@ class ProcessManagerEndLogTest extends TestCase
*/
private array $records = [];

/**
* @var list<ProcessEvent>
*/
private array $endedEvents = [];

public function testSucceededProcessIsLoggedAsInfoByDefault(): void
{
$this->createProcessManager('@test.constant')->execute('test.process');

self::assertSame([[LogLevel::INFO, 'Process test.process succeed']], $this->getEndRecords());
self::assertCount(1, $this->endedEvents);
self::assertFalse($this->endedEvents[0]->isProcessStopped());
}

public function testSucceededProcessUsesTheDefaultSuccessLevel(): void
Expand Down Expand Up @@ -91,19 +98,25 @@ public function testSkippedErrorDoesNotFailTheProcess(): void
self::assertSame([[LogLevel::INFO, 'Process test.process succeed']], $this->getEndRecords());
}

public function testStoppedProcessIsLoggedAsFailedAsDebugByDefault(): void
public function testStoppedProcessEndsAsStoppedAndIsLoggedAsInfoByDefault(): void
{
$this->createProcessManager('@test.stop')->execute('test.process');

self::assertSame([[LogLevel::DEBUG, 'Process test.process failed']], $this->getEndRecords());
self::assertSame([[LogLevel::INFO, 'Process test.process stopped']], $this->getEndRecords());
self::assertCount(1, $this->endedEvents);
self::assertSame('test.process', $this->endedEvents[0]->getProcessCode());
self::assertTrue($this->endedEvents[0]->isProcessStopped());
self::assertNull($this->endedEvents[0]->getProcessError());
}

public function testStoppedProcessUsesTheDefaultFailedLevel(): void
public function testStoppedProcessUsesTheDefaultSuccessLevel(): void
{
$this->createProcessManager('@test.stop', defaultLogLevels: ['failed_level' => LogLevel::ERROR])
->execute('test.process');
$this->createProcessManager(
'@test.stop',
defaultLogLevels: ['success_level' => LogLevel::NOTICE, 'failed_level' => LogLevel::ERROR]
)->execute('test.process');

self::assertSame([[LogLevel::ERROR, 'Process test.process failed']], $this->getEndRecords());
self::assertSame([[LogLevel::NOTICE, 'Process test.process stopped']], $this->getEndRecords());
}

public function testFailingProcessIsLoggedAsFailedWithTheLevelOfTheProcess(): void
Expand Down Expand Up @@ -178,13 +191,18 @@ public function log($level, string|\Stringable $message, array $context = []): v
}
};

$eventDispatcher = new EventDispatcher();
$eventDispatcher->addListener(ProcessEvent::EVENT_PROCESS_ENDED, function (ProcessEvent $event): void {
$this->endedEvents[] = $event;
});

return new ProcessManager(
$container,
new ProcessLogger($logger),
new TaskLogger(new NullLogger()),
new ProcessConfigurationRegistry(['test.process' => $rawConfiguration], 'stop', $defaultLogLevels),
new ContextualOptionResolver(),
new EventDispatcher(),
$eventDispatcher,
taskRegistry: new TaskRegistry($container),
);
}
Expand All @@ -198,7 +216,7 @@ private function getEndRecords(): array
{
$endRecords = [];
foreach ($this->records as [$level, $message, $context]) {
if (str_ends_with($message, ' succeed') || str_ends_with($message, ' failed')) {
if (str_ends_with($message, ' succeed') || str_ends_with($message, ' failed') || str_ends_with($message, ' stopped')) {
self::assertIsInt($context['duration'] ?? null);
$endRecords[] = [$level, $message];
}
Expand Down
Loading
Loading