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 @@ -6,6 +6,7 @@ Latest
* [#143](https://github.com/cleverage/ui-process-bundle/issues/143) Stop a running process execution from the UI: "Stop the process" action (POST, with confirmation) on the `started` executions, recording a stop request (`ProcessExecution::$stopRequestedAt`). The new `ProcessExecutionStopChecker` (a stop checker of cleverage/process-bundle#266, tag `cleverage_process.stop_checker`) reads it with DBAL before the task executions, at most every `process_execution.heartbeat_interval` seconds (new bundle configuration, default `5`): the process stops before its next task execution, with the new `stopped` status (`ProcessExecutionStatus::Stopped`, "Stop requested" displayed until then). New `stopped` notification status (`NotificationTrigger::Stopped`, not notified by default). Requires `cleverage/process-bundle` ^5.3. Add documentation and tests.
* [#144](https://github.com/cleverage/ui-process-bundle/issues/144) Detect the process executions whose process has been killed: the `ProcessExecutionStopChecker` updates the heartbeat of the running execution (`ProcessExecution::$heartbeatAt`); a `started` execution without activity for more than `process_execution.stale_after` seconds (new bundle configuration, default `900`) is displayed as "Not responding", and can be marked as failed ("Mark as failed" action, POST, with confirmation; its end date is its last activity; its process is not stopped). It can still be stopped: a process blocked in a long task call stops when the call returns. New `ProcessExecutionCrudController` optional `$staleAfter` constructor argument.
* [#143](https://github.com/cleverage/ui-process-bundle/issues/143) [#144](https://github.com/cleverage/ui-process-bundle/issues/144) `Version20261014120000` migration adding `process_execution.stop_requested_at` and `process_execution.heartbeat_at` (nullable).
* [#147](https://github.com/cleverage/ui-process-bundle/issues/147) Log retention: new `cleverage:ui-process:purge-logs` command (with `--dry-run`) deleting the log records (`log_record`, by batches) older than `logs.database_max_days` days and the log files (`<code>/<uuid>.log`) last modified more than `logs.file_max_days` days ago (new bundle configuration, default `null`: kept), both overridable by the new `logs` option of each process (`ProcessConfigurationsManager::getLogsOptions()`). The process executions are kept. Add documentation (including how to give the space back to the database after the first purge) and tests.

v3.1.1
------
Expand Down
10 changes: 10 additions & 0 deletions config/services/command.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ services:
- '@validator'
- '@security.user_password_hasher'
- '@doctrine.orm.entity_manager'

cleverage_ui_process.command.purge_logs:
class: CleverAge\UiProcessBundle\Command\PurgeLogsCommand
public: false
tags:
- { name: console.command }
arguments:
$entityManager: '@doctrine.orm.entity_manager'
$processConfigurationsManager: '@cleverage_ui_process.manager.process_configuration'
$logDirectory: '%kernel.logs_dir%'
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ clever_age_ui_process:
database_level: Info # Debug on dev environment
file_level: Info # Debug on dev environment
report_increment_level: Warning
database_max_days: ~ # Log retention, see the cleverage:ui-process:purge-logs command
file_max_days: ~
design:
logo_path: 'bundles/cleverageuiprocess/logo.jpg'
```
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/01-bundle_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ clever_age_ui_process:
database_level: Info # Debug when kernel.environment is "dev"
file_level: Info # Debug when kernel.environment is "dev"
report_increment_level: Warning
database_max_days: ~
file_max_days: ~
design:
logo_path: 'bundles/cleverageuiprocess/logo.jpg'
notification:
Expand Down Expand Up @@ -47,6 +49,10 @@ See [process executions & logs](04-process_executions_and_logs.md) for the whole
| `database_level` | `string` | `Debug` on `dev` environment, else `Info` | Minimum Monolog level of the log records stored in database. |
| `file_level` | `string` | `Debug` on `dev` environment, else `Info` | Minimum Monolog level of the log records written in the execution log file. |
| `report_increment_level` | `string` | `Warning` | Minimum Monolog level from which a log record increments the execution report (one counter per level, e.g. `Warning: 3`, `Error: 1`). Only records passing `file_level` are counted. |
| `database_max_days` | `int`, `null` | `null` | Days after which the log records are deleted from database by the [`cleverage:ui-process:purge-logs`](07-console_commands.md#cleverageui-processpurge-logs) command. `null`: kept. Can be overridden by process. |
| `file_max_days` | `int`, `null` | `null` | Days after which the log files are deleted by the [`cleverage:ui-process:purge-logs`](07-console_commands.md#cleverageui-processpurge-logs) command. `null`: kept. Can be overridden by process. |

See [log retention](04-process_executions_and_logs.md#log-retention).

Levels are Monolog level names (case-insensitive): `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`,
`Alert`, `Emergency`.
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/02-process_ui_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Every key is optional: a process without `options.ui` is displayed and launched
keys, or invalid values, raise an options resolver error when the process list is displayed.

The `notification` key, next to `ui`, configures the [notification](09-notifications.md) of the end of the process
executions.
executions. The `logs` key, next to `ui`, configures the [log retention](04-process_executions_and_logs.md#log-retention)
of the process.

Options
-------
Expand Down
65 changes: 65 additions & 0 deletions docs/reference/04-process_executions_and_logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,71 @@ clever_age_ui_process:
report_increment_level: Error
```

### Log retention

Neither the log records stored in database nor the log files are deleted by default. The
`cleverage:ui-process:purge-logs` command deletes those older than a retention, in days:

| Option | Deletes |
|---------------------|-----------------------------------------------------------------------------------------------------------|
| `database_max_days` | The log records (`log_record` table) created more than `database_max_days` days ago. |
| `file_max_days` | The log files (`<code>/<uuid>.log` in `%kernel.logs_dir%`) last modified more than `file_max_days` days ago. |

Both default to `null` (kept), in the [bundle configuration](01-bundle_configuration.md#logs), and can be overridden
by process under the `logs` key of its options (next to the [`ui` options](02-process_ui_options.md)). A key missing
(or `null`) in the process options is inherited from the bundle configuration; the executions of a process no longer
defined use the bundle configuration.

```yaml
# config/packages/clever_age_ui_process.yaml
clever_age_ui_process:
logs:
database_max_days: 30
file_max_days: 180

# A process whose logs are kept longer
clever_age_process:
configurations:
app.daily_import:
options:
logs:
database_max_days: 90
file_max_days: 365
tasks:
# ...
```

Schedule the command, e.g. every night with a cron:

```
0 3 * * * cd /path/to/project && bin/console cleverage:ui-process:purge-logs --no-debug
```

Run it with `--dry-run` first to count what would be deleted.

Keep in mind that:
- The process executions are never modified nor deleted: the executions list, their statuses and reports stay
complete. Once its logs are purged, the "Show logs stored in database" and "Download log file" actions of an
execution are no longer displayed.
- The log file of an execution contains its log records (the `message` is not truncated), as long as `file_level` is
not higher than `database_level`: keep the files longer than the database logs to keep the logs readable.
- Only the log files of the bundle are deleted: files named `<uuid>.log`, in the directory of a process code.
- The log records are deleted by batches of 1000.

#### Database space

Deleting rows does not shrink the `log_record` table on disk: the database reuses the freed space for the new log
records, so that with regular purges the table stops growing. To give the space back after the first purge, rebuild
the table during a maintenance window (no process running: the table is locked while it is rebuilt):

| Database | Command |
|---------------------|-------------------------------|
| PostgreSQL | `VACUUM FULL log_record;` |
| MySQL / MariaDB | `OPTIMIZE TABLE log_record;` |
| SQLite | `VACUUM;` (whole database) |

The command does not run them: they would block the running processes, which write their logs in this table.

### Logs list

The "Process > Logs" menu lists the database logs (250 per page), with their level, message, date and a "Has context
Expand Down
18 changes: 18 additions & 0 deletions docs/reference/07-console_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ bin/console cleverage:ui-process:user-create admin@example.com 'my-secret-passwo

Values passed as arguments are not validated. The email must be unique.

cleverage:ui-process:purge-logs
-------------------------------

Deletes the log records stored in database and the log files older than the configured retention
(`logs.database_max_days` and `logs.file_max_days`, overridable by process). The process executions are kept. See
[log retention](04-process_executions_and_logs.md#log-retention).

```bash
bin/console cleverage:ui-process:purge-logs [--dry-run]
```

| Option | Description |
|-------------|-------------------------------------------------------------------------------|
| `--dry-run` | Counts the log records and the log files to delete, without deleting them. |

It displays, for each process with a retention, the retention and the number of log records and log files deleted.
Without any retention configured, it does nothing.

Related commands
----------------

Expand Down
7 changes: 7 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ When the `store_in_database` option is enabled with a low `database_level`, the

See [process executions & logs](reference/04-process_executions_and_logs.md).

## The log_record table keeps growing

Configure a log retention (`logs.database_max_days`) and schedule the `cleverage:ui-process:purge-logs` command. After
the first purge, the space of the deleted rows is reused by the new ones, but the table does not shrink on disk.

See [log retention](reference/04-process_executions_and_logs.md#log-retention).

## {"message":"Missing auth token."} response when launching a process via HTTP request

If you use the Apache web server with PHP-FPM/FastCGI, the `Authorization` header may not be passed to PHP. Add (or
Expand Down
179 changes: 179 additions & 0 deletions src/Command/PurgeLogsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

/*
* This file is part of the CleverAge/UiProcessBundle package.
*
* Copyright (c) Clever-Age
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace CleverAge\UiProcessBundle\Command;

use CleverAge\ProcessBundle\Configuration\ProcessConfiguration;
use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;

/**
* Deletes the log records (log_record table) older than "logs.database_max_days" days and the log files older than
* "logs.file_max_days" days, both overridable by the "logs" option of each process. The process executions are kept.
*
* DBAL queries are used: the log records are deleted by batches, without loading them.
*/
#[AsCommand(
name: 'cleverage:ui-process:purge-logs',
description: 'Delete the log records and the log files of the process executions older than the configured retention.'
)]
class PurgeLogsCommand extends Command
{
private const BATCH_SIZE = 1000;

/**
* Name of the log files written by the ProcessHandler: <uuid>.log.
*/
private const LOG_FILE_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.log$/';

public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProcessConfigurationsManager $processConfigurationsManager,
private readonly string $logDirectory,
private readonly ?int $databaseMaxDays = null,
private readonly ?int $fileMaxDays = null,
) {
parent::__construct();
}

protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Count the log records and the log files to delete, without deleting them');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$style = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$connection = $this->entityManager->getConnection();

$rows = [];
$totalRecords = 0;
$totalFiles = 0;
foreach ($this->getProcessCodes($connection) as $code) {
$options = $this->processConfigurationsManager->getLogsOptions($code);
$databaseMaxDays = $options['database_max_days'] ?? $this->databaseMaxDays;
$fileMaxDays = $options['file_max_days'] ?? $this->fileMaxDays;
if (null === $databaseMaxDays && null === $fileMaxDays) {
continue;
}

$records = null === $databaseMaxDays ? null : $this->purgeLogRecords($connection, $code, $databaseMaxDays, $dryRun);
$files = null === $fileMaxDays ? null : $this->purgeLogFiles($code, $fileMaxDays, $dryRun);
$totalRecords += $records ?? 0;
$totalFiles += $files ?? 0;
$rows[] = [$code, $databaseMaxDays ?? '-', $records ?? '-', $fileMaxDays ?? '-', $files ?? '-'];
}

if ([] === $rows) {
$style->success('Nothing to purge: no retention configured ("logs.database_max_days", "logs.file_max_days").');

return Command::SUCCESS;
}

$style->table(
['Process', 'Database max days', $dryRun ? 'Log records to delete' : 'Log records deleted', 'File max days', $dryRun ? 'Log files to delete' : 'Log files deleted'],
$rows
);
$style->success(\sprintf(
$dryRun ? '%d log record(s) and %d log file(s) to delete (dry run).' : '%d log record(s) and %d log file(s) deleted.',
$totalRecords,
$totalFiles
));

return Command::SUCCESS;
}

/**
* Codes of the recorded process executions and of the defined processes, sorted.
*
* @return list<string>
*/
private function getProcessCodes(Connection $connection): array
{
/** @var list<string> $codes */
$codes = $connection->fetchFirstColumn('SELECT DISTINCT code FROM process_execution');
$configurations = [...$this->processConfigurationsManager->getPublicProcesses(), ...$this->processConfigurationsManager->getPrivateProcesses()];
$codes = array_unique([
...$codes,
...array_map(static fn (ProcessConfiguration $configuration): string => $configuration->getCode(), array_values($configurations)),
]);
sort($codes);

return $codes;
}

private function purgeLogRecords(Connection $connection, string $code, int $maxDays, bool $dryRun): int
{
$limit = new \DateTimeImmutable(\sprintf('-%d days', $maxDays));
$queryBuilder = $connection->createQueryBuilder()
->from('log_record', 'lr')
->innerJoin('lr', 'process_execution', 'pe', 'pe.id = lr.process_execution_id')
->where('pe.code = :code')
->andWhere('lr.created_at < :limit')
->setParameter('code', $code)
->setParameter('limit', $limit, Types::DATETIME_IMMUTABLE);

if ($dryRun) {
/** @var int|string|false $count */
$count = $queryBuilder->select('COUNT(lr.id)')->fetchOne();

return (int) $count;
}

$queryBuilder->select('lr.id')->setMaxResults(self::BATCH_SIZE);
$deleted = 0;
do {
$ids = $queryBuilder->fetchFirstColumn();
if ([] !== $ids) {
$deleted += (int) $connection->executeStatement(
'DELETE FROM log_record WHERE id IN (?)',
[$ids],
[ArrayParameterType::INTEGER]
);
}
} while (self::BATCH_SIZE === \count($ids));

return $deleted;
}

private function purgeLogFiles(string $code, int $maxDays, bool $dryRun): int
{
$directory = $this->logDirectory.\DIRECTORY_SEPARATOR.$code;
// The code is the name of the directory of the log files: never look outside the log directory
if (\in_array($code, ['', '.', '..'], true) || str_contains($code, '/') || str_contains($code, '\\') || !is_dir($directory)) {
return 0;
}

$limit = (new \DateTimeImmutable(\sprintf('-%d days', $maxDays)))->getTimestamp();
$finder = (new Finder())->files()->in($directory)->depth(0)->name(self::LOG_FILE_PATTERN)
->filter(static fn (\SplFileInfo $file): bool => $file->getMTime() < $limit);
$files = array_keys(iterator_to_array($finder));
if (!$dryRun) {
(new Filesystem())->remove($files);
}

return \count($files);
}
}
5 changes: 4 additions & 1 deletion src/DependencyInjection/CleverAgeUiProcessExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function load(array $configs, ContainerBuilder $container): void
$configuration = new Configuration($env);
/** @var array{
* security: array{roles: list<string>},
* logs: array{store_in_database: bool, database_level: string, file_level: string, report_increment_level: string},
* logs: array{store_in_database: bool, database_level: string, file_level: string, report_increment_level: string, database_max_days: ?int, file_max_days: ?int},
* design: array{logo_path: string},
* notification: array{enabled: bool, ...},
* process_execution: array{heartbeat_interval: int, stale_after: int},
Expand All @@ -59,6 +59,9 @@ public function load(array $configs, ContainerBuilder $container): void
$container->getDefinition('cleverage_ui_process.monolog_handler.doctrine_process')
->addMethodCall('disable');
}
$container->getDefinition('cleverage_ui_process.command.purge_logs')
->setArgument('$databaseMaxDays', $config['logs']['database_max_days'])
->setArgument('$fileMaxDays', $config['logs']['file_max_days']);

$container->getDefinition(ProcessDashboardController::class)
->setArgument('$logoPath', $config['design']['logo_path']);
Expand Down
Loading
Loading