diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md
index dd4454bc..0e93b227 100644
--- a/UPGRADE-1.0.md
+++ b/UPGRADE-1.0.md
@@ -104,7 +104,7 @@ a small, pluggable interface hierarchy.
| Class / Interface | Description |
|-------------------|-------------|
| `Yokai\Batch\Logger\JobExecutionLoggerInterface` | PSR-3 logger + `getReference()`, `getLogs()`, `getLogsContent()` |
-| `Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface` | Creates and restores loggers (`create()` / `restore(string $ref)`) |
+| `Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface` | Creates and restores loggers (`create(string $id)` / `restore(string $ref)`) |
| `Yokai\Batch\Logger\InMemoryJobExecutionLogger` | Default implementation — accumulates logs in-memory |
| `Yokai\Batch\Logger\NullJobExecutionLogger` | Discards all logs — useful in tests |
| `Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory` | Factory for the in-memory logger (registered by default by the Symfony bundle) |
@@ -169,6 +169,79 @@ forward to the current job execution logger during job execution. Replace any re
---
+### New bridge: `yokai/batch-monolog`
+
+A new optional bridge package adds file-based log storage for job executions via
+[Monolog](https://seldaek.github.io/monolog)'s `StreamHandler`.
+
+```bash
+composer require yokai/batch-monolog
+```
+
+Each job execution gets its own log file named after its id:
+
+```php
+use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory;
+
+new StreamJobExecutionLoggerFactory(
+ directory: '/var/log/batch',
+);
+```
+
+For large workloads, files can be spread across nested subdirectories (git-object style):
+
+```php
+new StreamJobExecutionLoggerFactory(
+ directory: '/var/log/batch',
+ subDirectories: 2,
+ charsPerDirectory: 2,
+ // a job "60996f72" is stored at /var/log/batch/60/99/60996f72.log
+);
+```
+
+Monolog processors and a custom formatter can also be injected to control how records are written.
+
+In a Symfony application, configure the bridge through the bundle (see below).
+
+---
+
+### Symfony bundle configuration — job execution log storage
+
+A new `logging` key controls how job execution logs are stored.
+The default behaviour is unchanged (`memory` — in-memory, lost at process end):
+
+```yaml
+# config/packages/yokai_batch.yaml
+yokai_batch:
+ logging:
+ type: memory # memory (default) | null | stream | service
+```
+
+To persist logs to files (requires `yokai/batch-monolog`):
+
+```yaml
+yokai_batch:
+ logging:
+ type: stream
+ stream:
+ directory: '%kernel.logs_dir%/batch'
+ sub_directories: 0 # subdirectory depth (0 = flat)
+ chars_per_directory: 0 # characters per subdirectory level
+ processors: [] # Monolog processor service ids
+ formatter: ~ # Monolog formatter service id (optional)
+```
+
+To use a custom service implementing `JobExecutionLoggerFactoryInterface`:
+
+```yaml
+yokai_batch:
+ logging:
+ type: service
+ service: App\Batch\MyJobExecutionLoggerFactory
+```
+
+---
+
### Symfony bundle configuration — storage DSN
The `storage` key of the `yokai_batch` bundle now accepts a **DSN string** as a shorthand:
diff --git a/composer.json b/composer.json
index 1f42fcbf..47791ad9 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,7 @@
"doctrine/orm": "^3.0",
"doctrine/persistence": "^4.0",
"league/flysystem": "^3.0",
+ "monolog/monolog": "^3.0",
"openspout/openspout": "^4.0",
"psr/container": "^1.0",
"psr/event-dispatcher": "^1.0",
@@ -63,7 +64,8 @@
"yokai/batch-symfony-messenger": "self.version",
"yokai/batch-symfony-serializer": "self.version",
"yokai/batch-symfony-uid": "self.version",
- "yokai/batch-symfony-validator": "self.version"
+ "yokai/batch-symfony-validator": "self.version",
+ "yokai/batch-monolog": "self.version"
},
"autoload": {
"psr-4": {
@@ -78,7 +80,8 @@
"Yokai\\Batch\\Bridge\\Symfony\\Messenger\\": "src/batch-symfony-messenger/src/",
"Yokai\\Batch\\Bridge\\Symfony\\Serializer\\": "src/batch-symfony-serializer/src/",
"Yokai\\Batch\\Bridge\\Symfony\\Uid\\": "src/batch-symfony-uid/src/",
- "Yokai\\Batch\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/src/"
+ "Yokai\\Batch\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/src/",
+ "Yokai\\Batch\\Bridge\\Monolog\\": "src/batch-monolog/src/"
}
},
"autoload-dev": {
@@ -98,7 +101,8 @@
"Yokai\\Batch\\Tests\\Bridge\\Symfony\\Messenger\\": "src/batch-symfony-messenger/tests/",
"Yokai\\Batch\\Tests\\Bridge\\Symfony\\Serializer\\": "src/batch-symfony-serializer/tests/",
"Yokai\\Batch\\Tests\\Bridge\\Symfony\\Uid\\": "src/batch-symfony-uid/tests/",
- "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/tests/"
+ "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/tests/",
+ "Yokai\\Batch\\Tests\\Bridge\\Monolog\\": "src/batch-monolog/tests/"
}
}
}
diff --git a/docs/docs/bridges.rst b/docs/docs/bridges.rst
index 7c737214..862241c1 100644
--- a/docs/docs/bridges.rst
+++ b/docs/docs/bridges.rst
@@ -12,6 +12,7 @@ Here is the complete list of what to expect:
Doctrine ORM
Doctrine Persistence
Flysystem
+ Monolog
OpenSpout
Symfony Console
Symfony Messenger
diff --git a/docs/docs/bridges/monolog.rst b/docs/docs/bridges/monolog.rst
new file mode 100644
index 00000000..8238c8af
--- /dev/null
+++ b/docs/docs/bridges/monolog.rst
@@ -0,0 +1,45 @@
+Bridge with ``monolog/monolog``
+============================================================
+
+Refer to the `official documentation `__ on Monolog's website.
+
+This bridge provides file-based log storage for job executions using Monolog's ``StreamHandler``.
+
+
+Store job execution logs in files
+------------------------------------------------------------
+
+| The
+ `StreamJobExecutionLoggerFactory `__
+ creates one log file per job execution, named after the job execution id.
+| Logs written during execution are readable afterwards via the same reference.
+
+.. literalinclude:: monolog/stream-logger-factory.php
+ :language: php
+
+.. seealso::
+ | :doc:`What is a job execution logger? `
+ | :doc:`Bridge with Symfony Framework `
+
+
+Organize log files in subdirectories
+------------------------------------------------------------
+
+| By default, all log files land in the same directory.
+| For large workloads this can become a filesystem performance issue.
+| You can configure the factory to spread files across nested subdirectories,
+ similarly to how Git stores objects:
+
+.. literalinclude:: monolog/stream-logger-factory-with-subdirectories.php
+ :language: php
+
+
+Customize the Monolog stack
+------------------------------------------------------------
+
+| You can provide Monolog processors and a custom formatter to control how records are written.
+| Processors are applied to every log record before it is written.
+| The formatter controls the final string representation written to the file.
+
+.. literalinclude:: monolog/stream-logger-factory-with-processors.php
+ :language: php
diff --git a/docs/docs/bridges/monolog/stream-logger-factory-with-processors.php b/docs/docs/bridges/monolog/stream-logger-factory-with-processors.php
new file mode 100644
index 00000000..42d52ddc
--- /dev/null
+++ b/docs/docs/bridges/monolog/stream-logger-factory-with-processors.php
@@ -0,0 +1,13 @@
+`
+
+Custom logger factory service
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If none of the built-in types fit your needs, you can point to your own service:
+
+.. code-block:: yaml
+
+ # config/packages/yokai_batch.yaml
+ yokai_batch:
+ logging:
+ type: service
+ service: App\Batch\MyJobExecutionLoggerFactory
+
+
User interface to visualize ``JobExecution``
------------------------------------------------------------
diff --git a/docs/docs/core-concepts/job-execution-logger.rst b/docs/docs/core-concepts/job-execution-logger.rst
new file mode 100644
index 00000000..5c41dd91
--- /dev/null
+++ b/docs/docs/core-concepts/job-execution-logger.rst
@@ -0,0 +1,56 @@
+Job execution logger
+============================================================
+
+What is a job execution logger?
+------------------------------------------------------------
+
+Every ``JobExecution`` carries a
+`JobExecutionLoggerInterface `__
+instance that records log messages produced during the execution.
+
+It extends PSR-3's ``LoggerInterface``, so anything you can do with a standard logger
+(``info``, ``warning``, ``error``, etc.) works here.
+On top of that, it exposes two extra methods to read the logs back:
+
+* ``getLogs(): iterable`` — streams log lines lazily, suitable for large files or HTTP streaming
+* ``getLogsContent(): string`` — returns the full log content as a string (loads everything in memory)
+
+It also exposes ``getReference(): string``, a string serialized alongside the ``JobExecution``
+that allows the log storage to be restored after the execution is loaded back from storage.
+
+How are loggers created and restored?
+------------------------------------------------------------
+
+The
+`JobExecutionLoggerFactoryInterface `__
+is responsible for the full lifecycle:
+
+* ``create(string $jobExecutionId)`` — called when a new ``JobExecution`` is created.
+ Returns a fresh logger ready to receive messages.
+* ``restore(string $logsReference)`` — called when a ``JobExecution`` is loaded from storage.
+ Reconstructs the logger from the reference that was serialized with the execution.
+
+You should never have to call these methods yourself; the framework handles it internally.
+
+What implementations exist?
+------------------------------------------------------------
+
+**Built-in implementations:**
+
+* `InMemoryJobExecutionLoggerFactory `__
+ (default): keeps logs in memory. Logs are lost when the process ends.
+* `NullJobExecutionLoggerFactory `__:
+ discards all log messages.
+
+**From bridges:**
+
+* From ``monolog/monolog`` bridge:
+
+ * `StreamJobExecutionLoggerFactory `__:
+ writes one log file per job execution using Monolog's ``StreamHandler``.
+ Logs survive the process and can be read back from the file.
+
+.. seealso::
+ | :doc:`What is a job execution? `
+ | :doc:`Bridge with Monolog `
+ | :doc:`Bridge with Symfony Framework `
diff --git a/docs/docs/core-concepts/job-execution.rst b/docs/docs/core-concepts/job-execution.rst
index 8864368c..d538e96e 100644
--- a/docs/docs/core-concepts/job-execution.rst
+++ b/docs/docs/core-concepts/job-execution.rst
@@ -20,9 +20,10 @@ What kind of information does it hold?
* ``JobExecution::$failures``: A list of failures (usually exceptions)
* ``JobExecution::$warnings``: A list of warnings (usually skipped items)
* ``JobExecution::$summary``: A summary (can contain any data you wish to store)
-* ``JobExecution::$logs``: Some logs
+* ``JobExecution::$logger``: The associated logger
* ``JobExecution::$childExecutions``: Some child execution
.. seealso::
| :doc:`How is a job execution created? `
| :doc:`How can I retrieve a job execution afterwards? `
+ | :doc:`How are job execution logs stored? `
diff --git a/docs/docs/index.rst b/docs/docs/index.rst
index 4939881f..7f12914d 100644
--- a/docs/docs/index.rst
+++ b/docs/docs/index.rst
@@ -67,6 +67,7 @@ Explore some of the things that could be built with **Yokai Batch**:
core-concepts/item-job
core-concepts/job-execution
core-concepts/job-execution-storage
+ core-concepts/job-execution-logger
core-concepts/job-with-children
core-concepts/job-parameter-accessor
core-concepts/aware-interfaces
@@ -98,6 +99,7 @@ Explore some of the things that could be built with **Yokai Batch**:
Doctrine ORM
Doctrine Persistence
Flysystem
+ Monolog
OpenSpout
Symfony Console
Symfony Framework
diff --git a/phpstan.neon b/phpstan.neon
index 1c6f6faf..575f0b05 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -15,3 +15,4 @@ parameters:
- src/batch-symfony-messenger/src/
- src/batch-symfony-serializer/src/
- src/batch-symfony-validator/src/
+ - src/batch-monolog/src/
diff --git a/src/batch-monolog/.gitattributes b/src/batch-monolog/.gitattributes
new file mode 100644
index 00000000..c75a9010
--- /dev/null
+++ b/src/batch-monolog/.gitattributes
@@ -0,0 +1,8 @@
+.github export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+docs/ export-ignore
+tests/ export-ignore
+LICENSE export-ignore
+phpunit.xml export-ignore
+*.md export-ignore
diff --git a/src/batch-monolog/.github/workflows/lockdown.yml b/src/batch-monolog/.github/workflows/lockdown.yml
new file mode 100644
index 00000000..c97070ec
--- /dev/null
+++ b/src/batch-monolog/.github/workflows/lockdown.yml
@@ -0,0 +1,27 @@
+name: 'Lock down Pull Requests'
+
+on:
+ pull_request:
+ types: opened
+
+jobs:
+ lockdown:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: dessant/repo-lockdown@v4
+ with:
+ github-token: ${{ github.token }}
+ close-pr: true
+ lock-pr: true
+ pr-comment: >
+ Thanks for your pull request!
+
+ However, this repository does not accept pull requests,
+ see the README for details.
+
+ If you want to contribute,
+ you should instead open a pull request on the main repository:
+
+ https://github.com/yokai-php/batch-src
+
+ Thank you
diff --git a/src/batch-monolog/.gitignore b/src/batch-monolog/.gitignore
new file mode 100644
index 00000000..88259615
--- /dev/null
+++ b/src/batch-monolog/.gitignore
@@ -0,0 +1,4 @@
+/.phpunit.result.cache
+/tests/.artifacts/
+/vendor/
+/composer.lock
diff --git a/src/batch-monolog/LICENSE b/src/batch-monolog/LICENSE
new file mode 100644
index 00000000..2a58154b
--- /dev/null
+++ b/src/batch-monolog/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Yann Eugoné
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/src/batch-monolog/README.md b/src/batch-monolog/README.md
new file mode 100644
index 00000000..cf761edb
--- /dev/null
+++ b/src/batch-monolog/README.md
@@ -0,0 +1,41 @@
+# monolog/monolog bridge for Batch processing library
+
+[](https://packagist.org/packages/yokai/batch-monolog)
+[](https://packagist.org/packages/yokai/batch-monolog)
+
+[`monolog/monolog`](https://github.com/Seldaek/monolog) bridge for [Batch](https://github.com/yokai-php/batch) processing library.
+
+
+# Installation
+
+```
+composer require yokai/batch-monolog
+```
+
+
+## Documentation
+
+Please read the [dedicated documentation page](https://yokai-batch.readthedocs.io/en/1.x/bridges/monolog.html).
+
+This package provides:
+
+- an [job execution logger factory](https://github.com/yokai-php/batch-monolog/blob/1.x/src/StreamJobExecutionLoggerFactory.php) that create a local file logger
+- an [job execution logger](https://github.com/yokai-php/batch-monolog/blob/1.x/src/StreamJobExecutionLogger.php) that write job logs to a local file
+
+
+## Contribution
+
+This package is a readonly split of a [larger repository](https://github.com/yokai-php/batch-src),
+containing all tests and sources for all librairies of the batch universe.
+
+Please feel free to open an [issue](https://github.com/yokai-php/batch-src/issues)
+or a [pull request](https://github.com/yokai-php/batch-src/pulls)
+in the [main repository](https://github.com/yokai-php/batch-src).
+
+The library was originally created by [Yann Eugoné](https://github.com/yann-eugone).
+See the list of [contributors](https://github.com/yokai-php/batch-src/contributors).
+
+
+## License
+
+This library is under MIT [LICENSE](LICENSE).
diff --git a/src/batch-monolog/composer.json b/src/batch-monolog/composer.json
new file mode 100644
index 00000000..7b53dd6c
--- /dev/null
+++ b/src/batch-monolog/composer.json
@@ -0,0 +1,32 @@
+{
+ "name": "yokai/batch-monolog",
+ "description": "monolog/monolog bridge for yokai/batch",
+ "keywords": ["batch", "job", "logging", "monolog"],
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Yann Eugoné",
+ "email": "eugone.yann@gmail.com"
+ }
+ ],
+ "require": {
+ "php": "^8.2",
+ "monolog/monolog": "^3.0",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "yokai/batch": "^1.0.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Yokai\\Batch\\Bridge\\Monolog\\": "src/"
+ }
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Yokai\\Batch\\Tests\\Bridge\\Monolog\\": "tests/"
+ }
+ }
+}
diff --git a/src/batch-monolog/phpunit.xml b/src/batch-monolog/phpunit.xml
new file mode 100644
index 00000000..f2bbace6
--- /dev/null
+++ b/src/batch-monolog/phpunit.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+ ./tests
+
+
+
+
+
+ ./src
+
+
+
diff --git a/src/batch-monolog/src/StreamJobExecutionLogger.php b/src/batch-monolog/src/StreamJobExecutionLogger.php
new file mode 100644
index 00000000..bdd7d50c
--- /dev/null
+++ b/src/batch-monolog/src/StreamJobExecutionLogger.php
@@ -0,0 +1,84 @@
+ $processors Monolog processors applied to every log record.
+ * @param FormatterInterface|null $formatter Optional Monolog formatter for the stream handler.
+ */
+ public function __construct(
+ private readonly string $absolutePath,
+ private readonly string $reference,
+ array $processors = [],
+ FormatterInterface|null $formatter = null,
+ ) {
+ $handler = new StreamHandler($absolutePath);
+ if ($formatter !== null) {
+ $handler->setFormatter($formatter);
+ }
+ $this->logger = new Logger('yokai/batch', [$handler], $processors);
+ }
+
+ /**
+ * @param Level|LogLevel::* $level
+ */
+ public function log($level, string|\Stringable $message, array $context = []): void
+ {
+ $this->logger->log($level, $message, $context);
+ }
+
+ public function getReference(): string
+ {
+ return $this->reference;
+ }
+
+ public function getLogs(): iterable
+ {
+ $handle = @\fopen($this->absolutePath, 'r');
+ if ($handle === false) {
+ return;
+ }
+
+ try {
+ while (($line = \fgets($handle)) !== false) {
+ yield \rtrim($line, "\n\r");
+ }
+ } finally {
+ \fclose($handle);
+ }
+ }
+
+ public function getLogsContent(): string
+ {
+ $contents = @\file_get_contents($this->absolutePath);
+ if ($contents === false) {
+ return '';
+ }
+
+ return $contents;
+ }
+}
diff --git a/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php b/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php
new file mode 100644
index 00000000..c20096a6
--- /dev/null
+++ b/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php
@@ -0,0 +1,77 @@
+ $processors Monolog processors added to every logger instance.
+ * @param FormatterInterface|null $formatter Optional Monolog formatter applied to the stream handler.
+ * @param int $subDirectories Number of subdirectory levels to create from the job execution id.
+ * @param int $charsPerDirectory Number of characters from the job execution id used per subdirectory level.
+ */
+ public function __construct(
+ private string $directory,
+ private array $processors = [],
+ private FormatterInterface|null $formatter = null,
+ private int $subDirectories = 0,
+ private int $charsPerDirectory = 0,
+ ) {
+ }
+
+ public function create(string $jobExecutionId): JobExecutionLoggerInterface
+ {
+ $subdir = $this->subdir($jobExecutionId);
+ $reference = ($subdir !== '' ? $subdir . \DIRECTORY_SEPARATOR : '') . $jobExecutionId . '.log';
+
+ return $this->buildLogger($reference);
+ }
+
+ public function restore(string $logsReference): JobExecutionLoggerInterface
+ {
+ return $this->buildLogger($logsReference);
+ }
+
+ private function buildLogger(string $reference): StreamJobExecutionLogger
+ {
+ return new StreamJobExecutionLogger(
+ absolutePath: $this->directory . \DIRECTORY_SEPARATOR . $reference,
+ reference: $reference,
+ processors: $this->processors,
+ formatter: $this->formatter,
+ );
+ }
+
+ /**
+ * Splits the job execution id into subdirectory segments based on configured depth and segment length.
+ * For example, with subDirectories=2 and charsPerDirectory=2, the id
+ * 60996f72-4f54-4184-9268-35ffdecf0de6
+ * is stored at:
+ * └─ 60/
+ * └─ 99/
+ * └─ 60996f72-4f54-4184-9268-35ffdecf0de6.log
+ */
+ private function subdir(string $id): string
+ {
+ $parts = [];
+ for ($i = 0, $start = 0; $i < $this->subDirectories; $i++, $start += $this->charsPerDirectory) {
+ $parts[] = \substr($id, $start, $this->charsPerDirectory);
+ }
+
+ return \implode(\DIRECTORY_SEPARATOR, $parts);
+ }
+}
diff --git a/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php b/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php
new file mode 100644
index 00000000..7299cb3e
--- /dev/null
+++ b/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php
@@ -0,0 +1,160 @@
+tmpDir = ARTIFACT_DIR . '/stream-job-execution-logger-factory-' . \uniqid();
+ \mkdir($this->tmpDir, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ (new Filesystem())->remove($this->tmpDir);
+ }
+
+ public function testCreateReturnsStreamJobExecutionLogger(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory($this->tmpDir);
+
+ self::assertInstanceOf(StreamJobExecutionLogger::class, $factory->create('exec-1'));
+ }
+
+ public function testCreateUsesJobExecutionIdAsRelativeReference(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory($this->tmpDir);
+
+ $logger = $factory->create('exec-abc');
+
+ self::assertSame('exec-abc.log', $logger->getReference());
+ }
+
+ public function testCreateGeneratesDistinctReferencePerExecutionId(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory($this->tmpDir);
+
+ $first = $factory->create('exec-1');
+ $second = $factory->create('exec-2');
+
+ self::assertNotSame($first->getReference(), $second->getReference());
+ }
+
+ public function testCreatedLoggerIsInitiallyEmpty(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory($this->tmpDir);
+
+ $logger = $factory->create('exec-empty');
+
+ self::assertSame([], \iterator_to_array($logger->getLogs()));
+ self::assertSame('', $logger->getLogsContent());
+ }
+
+ public function testRestoreReturnsLoggerWithGivenReference(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory($this->tmpDir);
+
+ $logger = $factory->restore('existing.log');
+
+ self::assertSame('existing.log', $logger->getReference());
+ }
+
+ public function testRestoreReadsExistingFileContent(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory($this->tmpDir);
+
+ \file_put_contents($this->tmpDir . '/existing.log', "line one\nline two\n");
+
+ $logger = $factory->restore('existing.log');
+
+ self::assertStringContainsString('line one', $logger->getLogsContent());
+ self::assertStringContainsString('line two', $logger->getLogsContent());
+ }
+
+ public function testProcessorIsPassedToLogger(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory(
+ $this->tmpDir,
+ [new PsrLogMessageProcessor()],
+ );
+
+ $logger = $factory->create('exec-proc');
+ $logger->info('value is {val}', ['val' => 'hello']);
+
+ self::assertStringContainsString('value is hello', $logger->getLogsContent());
+ }
+
+ public function testFormatterIsPassedToLogger(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory(
+ $this->tmpDir,
+ [],
+ new JsonFormatter(),
+ );
+
+ $logger = $factory->create('exec-fmt');
+ $logger->info('json log');
+
+ $decoded = \json_decode($logger->getLogsContent(), true);
+
+ self::assertIsArray($decoded);
+ self::assertSame('json log', $decoded['message']);
+ }
+
+ public function testCreateWithSubDirectoriesBuildsNestedReference(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory(
+ $this->tmpDir,
+ subDirectories: 2,
+ charsPerDirectory: 2,
+ );
+
+ $logger = $factory->create('60996f72-4f54-4184-9268-35ffdecf0de6');
+
+ self::assertSame('60/99/60996f72-4f54-4184-9268-35ffdecf0de6.log', $logger->getReference());
+ }
+
+ public function testCreateWithSubDirectoriesWritesFileInSubdirectory(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory(
+ $this->tmpDir,
+ subDirectories: 2,
+ charsPerDirectory: 2,
+ );
+
+ $logger = $factory->create('60996f72-4f54-4184-9268-35ffdecf0de6');
+ $logger->info('nested log');
+
+ self::assertFileExists($this->tmpDir . '/60/99/60996f72-4f54-4184-9268-35ffdecf0de6.log');
+ self::assertStringContainsString('nested log', $logger->getLogsContent());
+ }
+
+ public function testRestoreWithSubDirectoryReferenceReadsFileContent(): void
+ {
+ $factory = new StreamJobExecutionLoggerFactory(
+ $this->tmpDir,
+ subDirectories: 2,
+ charsPerDirectory: 2,
+ );
+
+ \mkdir($this->tmpDir . '/60/99', 0755, true);
+ \file_put_contents($this->tmpDir . '/60/99/exec.log', "restored line\n");
+
+ $logger = $factory->restore('60/99/exec.log');
+
+ self::assertSame('60/99/exec.log', $logger->getReference());
+ self::assertStringContainsString('restored line', $logger->getLogsContent());
+ }
+}
diff --git a/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php b/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php
new file mode 100644
index 00000000..1c62935a
--- /dev/null
+++ b/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php
@@ -0,0 +1,187 @@
+tmpDir = ARTIFACT_DIR . '/stream-job-execution-logger-' . \uniqid();
+ \mkdir($this->tmpDir, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ (new Filesystem())->remove($this->tmpDir);
+ }
+
+ public function testGetReferenceReturnsTheGivenReference(): void
+ {
+ $logger = $this->createLogger('test.log');
+
+ self::assertSame('test.log', $logger->getReference());
+ }
+
+ public function testGetReferenceDoesNotReturnAbsolutePath(): void
+ {
+ $logger = $this->createLogger('exec-abc.log');
+
+ self::assertStringNotContainsString($this->tmpDir, $logger->getReference());
+ }
+
+ public function testGetLogsIsEmptyWhenFileDoesNotExist(): void
+ {
+ $path = $this->tmpDir . '/nonexistent.log';
+ $logger = new StreamJobExecutionLogger($path, 'nonexistent.log');
+
+ self::assertSame([], \iterator_to_array($logger->getLogs()));
+ }
+
+ public function testGetLogsContentIsEmptyWhenFileDoesNotExist(): void
+ {
+ $path = $this->tmpDir . '/nonexistent.log';
+ $logger = new StreamJobExecutionLogger($path, 'nonexistent.log');
+
+ self::assertSame('', $logger->getLogsContent());
+ }
+
+ public function testLogWritesToFile(): void
+ {
+ $logger = $this->createLogger('test.log');
+
+ $logger->info('hello world');
+
+ self::assertFileExists($this->tmpDir . '/test.log');
+ self::assertStringContainsString('hello world', $logger->getLogsContent());
+ }
+
+ public function testGetLogsYieldsLines(): void
+ {
+ $logger = $this->createLogger('test.log');
+
+ $logger->info('first message');
+ $logger->warning('second message');
+
+ $lines = \iterator_to_array($logger->getLogs());
+
+ self::assertCount(2, $lines);
+ self::assertStringContainsString('first message', $lines[0]);
+ self::assertStringContainsString('second message', $lines[1]);
+ }
+
+ public function testGetLogsContentReturnsFullContent(): void
+ {
+ $logger = $this->createLogger('test.log');
+
+ $logger->error('something failed');
+
+ self::assertStringContainsString('something failed', $logger->getLogsContent());
+ }
+
+ public function testGetLogsIsLazy(): void
+ {
+ $logger = $this->createLogger('test.log');
+
+ $logger->debug('line one');
+ $logger->debug('line two');
+ $logger->debug('line three');
+
+ $first = null;
+ foreach ($logger->getLogs() as $line) {
+ $first = $line;
+ break;
+ }
+
+ self::assertNotNull($first);
+ self::assertStringContainsString('line one', $first);
+ }
+
+ public function testProcessorIsApplied(): void
+ {
+ $logger = $this->createLogger('test.log', [new PsrLogMessageProcessor()]);
+
+ $logger->info('value is {val}', ['val' => 'hello']);
+
+ self::assertStringContainsString('value is hello', $logger->getLogsContent());
+ }
+
+ public function testFormatterIsApplied(): void
+ {
+ $logger = $this->createLogger('test.log', [], new JsonFormatter());
+
+ $logger->info('json log');
+
+ $decoded = \json_decode($logger->getLogsContent(), true);
+
+ self::assertIsArray($decoded);
+ self::assertSame('json log', $decoded['message']);
+ }
+
+ public function testGetReferenceReturnsNestedReferenceAsIs(): void
+ {
+ $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99');
+
+ self::assertSame('60/99/exec-1.log', $logger->getReference());
+ }
+
+ public function testLogWritesToFileInSubdirectory(): void
+ {
+ $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99');
+
+ $logger->info('nested message');
+
+ self::assertFileExists($this->tmpDir . '/60/99/exec-1.log');
+ self::assertStringContainsString('nested message', $logger->getLogsContent());
+ }
+
+ public function testGetLogsReadsFromNestedPath(): void
+ {
+ $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99');
+
+ $logger->info('first line');
+ $logger->warning('second line');
+
+ $lines = \iterator_to_array($logger->getLogs());
+
+ self::assertCount(2, $lines);
+ self::assertStringContainsString('first line', $lines[0]);
+ self::assertStringContainsString('second line', $lines[1]);
+ }
+
+ public function testGetLogsContentReadsFromNestedPath(): void
+ {
+ $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99');
+
+ $logger->error('nested error');
+
+ self::assertStringContainsString('nested error', $logger->getLogsContent());
+ }
+
+ private function createLogger(
+ string $filename,
+ array $processors = [],
+ mixed $formatter = null,
+ string $subdir = '',
+ ): StreamJobExecutionLogger {
+ if ($subdir !== '') {
+ \mkdir($this->tmpDir . '/' . $subdir, 0755, true);
+ }
+
+ return new StreamJobExecutionLogger(
+ $this->tmpDir . '/' . $filename,
+ $filename,
+ $processors,
+ $formatter,
+ );
+ }
+}
diff --git a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php
index ec2b0288..49fda934 100644
--- a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php
+++ b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php
@@ -20,6 +20,18 @@
* parameters: ParametersConfig,
* id: string,
* ui: UserInterfaceConfig,
+ * logging: LoggingConfig,
+ * }
+ * @phpstan-type LoggingConfig array{
+ * type: 'memory'|'null'|'stream'|'service',
+ * service: string|null,
+ * stream: array{
+ * directory: string,
+ * sub_directories: int,
+ * chars_per_directory: int,
+ * processors: list,
+ * formatter: string|null,
+ * },
* }
* @phpstan-type StorageConfig array{
* dsn?: string,
@@ -80,6 +92,7 @@ public function getConfigTreeBuilder(): TreeBuilder
->append($this->parameters())
->append($this->id())
->append($this->ui())
+ ->append($this->logging())
->end()
;
@@ -318,4 +331,47 @@ private function ui(): ArrayNodeDefinition
return $node;
}
+
+ private function logging(): ArrayNodeDefinition
+ {
+ /** @var ArrayNodeDefinition $node */
+ $node = (new TreeBuilder('logging'))->getRootNode();
+
+ $node
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->enumNode('type')
+ ->values(['memory', 'null', 'stream', 'service'])
+ ->defaultValue('memory')
+ ->end()
+ ->scalarNode('service')
+ ->defaultNull()
+ ->end()
+ ->arrayNode('stream')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('directory')
+ ->defaultValue('%kernel.logs_dir%/batch')
+ ->end()
+ ->integerNode('sub_directories')
+ ->defaultValue(0)
+ ->min(0)
+ ->end()
+ ->integerNode('chars_per_directory')
+ ->defaultValue(0)
+ ->min(0)
+ ->end()
+ ->arrayNode('processors')
+ ->scalarPrototype()->end()
+ ->end()
+ ->scalarNode('formatter')
+ ->defaultNull()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ;
+
+ return $node;
+ }
}
diff --git a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php
index 5fa2cae3..ee82ffb1 100644
--- a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php
+++ b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php
@@ -18,12 +18,15 @@
use Symfony\Component\Form\AbstractType;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALJobExecutionStorage;
+use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory;
use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Form\JobFilterType;
use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\PaginationConfiguration;
use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating;
use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\SonataAdminTemplating;
use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface;
use Yokai\Batch\Factory\JobExecutionIdGeneratorInterface;
+use Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory;
+use Yokai\Batch\Factory\JobExecutionLoggerFactory\NullJobExecutionLoggerFactory;
use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface;
use Yokai\Batch\Factory\JobExecutionParametersBuilder\PerJobJobExecutionParametersBuilder;
use Yokai\Batch\Factory\JobExecutionParametersBuilder\StaticJobExecutionParametersBuilder;
@@ -40,9 +43,23 @@
* @phpstan-import-type LauncherConfig from Configuration
* @phpstan-import-type ParametersConfig from Configuration
* @phpstan-import-type UserInterfaceConfig from Configuration
+ * @phpstan-import-type LoggingConfig from Configuration
*/
final class YokaiBatchExtension extends Extension
{
+ /**
+ * @var \Closure(string): bool
+ */
+ private \Closure $packageChecker;
+
+ /**
+ * @param (\Closure(string): bool)|null $packageChecker Optional override for package detection, defaults to {@see InstalledVersions::isInstalled()}.
+ */
+ public function __construct(\Closure|null $packageChecker = null)
+ {
+ $this->packageChecker = $packageChecker ?? InstalledVersions::isInstalled(...);
+ }
+
/**
* @param list> $configs
*/
@@ -70,6 +87,7 @@ public function load(array $configs, ContainerBuilder $container): void
$this->configureLauncher($container, $config['launcher']);
$this->configureParameters($container, $config['parameters']);
$this->configureUserInterface($container, $loader, $config['ui']);
+ $this->configureLogging($container, $config['logging']);
$jobExecutionIdGeneratorDefinition = JobExecutionIdGeneratorDefinitionFactory::fromType($config['id']);
$container->setDefinition(JobExecutionIdGeneratorInterface::class, $jobExecutionIdGeneratorDefinition);
@@ -79,8 +97,8 @@ public function load(array $configs, ContainerBuilder $container): void
private function installed(string $package): bool
{
- return InstalledVersions::isInstalled('yokai/batch-src')
- || InstalledVersions::isInstalled('yokai/batch-' . $package);
+ return ($this->packageChecker)('yokai/batch-src')
+ || ($this->packageChecker)('yokai/batch-' . $package);
}
private function getLoader(ContainerBuilder $container): LoaderInterface
@@ -238,4 +256,49 @@ private function configureUserInterface(ContainerBuilder $container, LoaderInter
->addArgument($pagination['page_size'])
->addArgument($pagination['page_range']);
}
+
+ /**
+ * @param LoggingConfig $config
+ */
+ private function configureLogging(ContainerBuilder $container, array $config): void
+ {
+ if ($config['type'] === 'service') {
+ if ($config['service'] === null) {
+ throw new LogicException(
+ 'Cannot configure service logging: provide a service to use.',
+ );
+ }
+
+ $container->setAlias(JobExecutionLoggerFactoryInterface::class, $config['service']);
+
+ return;
+ }
+
+ if ($config['type'] === 'stream') {
+ if (!$this->installed('monolog')) {
+ throw new LogicException(
+ 'Cannot configure stream logging: install "yokai/batch-monolog" first.',
+ );
+ }
+
+ $container->register(JobExecutionLoggerFactoryInterface::class, StreamJobExecutionLoggerFactory::class)
+ ->setArguments([
+ $config['stream']['directory'],
+ \array_map(fn(string $id) => new Reference($id), $config['stream']['processors']),
+ $config['stream']['formatter'] !== null ? new Reference($config['stream']['formatter']) : null,
+ $config['stream']['sub_directories'],
+ $config['stream']['chars_per_directory'],
+ ]);
+
+ return;
+ }
+
+ if ($config['type'] === 'null') {
+ $container->register(JobExecutionLoggerFactoryInterface::class, NullJobExecutionLoggerFactory::class);
+
+ return;
+ }
+
+ $container->register(JobExecutionLoggerFactoryInterface::class, InMemoryJobExecutionLoggerFactory::class);
+ }
}
diff --git a/src/batch-symfony-framework/src/Resources/services/core.php b/src/batch-symfony-framework/src/Resources/services/core.php
index 25639a78..1ae10e3b 100644
--- a/src/batch-symfony-framework/src/Resources/services/core.php
+++ b/src/batch-symfony-framework/src/Resources/services/core.php
@@ -7,7 +7,6 @@
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Yokai\Batch\Factory\JobExecutionFactory;
use Yokai\Batch\Factory\JobExecutionIdGeneratorInterface;
-use Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory;
use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface;
use Yokai\Batch\Factory\JobExecutionParametersBuilder\ChainJobExecutionParametersBuilder;
use Yokai\Batch\Factory\JobExecutionParametersBuilderInterface;
@@ -27,8 +26,6 @@
service(JobExecutionLoggerFactoryInterface::class),
])
- ->set(JobExecutionLoggerFactoryInterface::class, InMemoryJobExecutionLoggerFactory::class)
-
->set(JobExecutionFactory::class)
->args([
service(JobExecutionIdGeneratorInterface::class),
diff --git a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php
index ed7a03e8..fd3e8285 100644
--- a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php
+++ b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php
@@ -15,6 +15,7 @@
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\Reference;
use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALJobExecutionStorage;
+use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory;
use Yokai\Batch\Bridge\Symfony\Console\RunCommandJobLauncher;
use Yokai\Batch\Bridge\Symfony\Framework\DependencyInjection\YokaiBatchExtension;
use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating;
@@ -26,6 +27,8 @@
use Yokai\Batch\Bridge\Symfony\Uid\Factory\TimeBasedUuidJobExecutionIdGenerator;
use Yokai\Batch\Bridge\Symfony\Uid\Factory\UlidJobExecutionIdGenerator;
use Yokai\Batch\Factory\JobExecutionIdGeneratorInterface;
+use Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory;
+use Yokai\Batch\Factory\JobExecutionLoggerFactory\NullJobExecutionLoggerFactory;
use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface;
use Yokai\Batch\Factory\JobExecutionParametersBuilder\ChainJobExecutionParametersBuilder;
use Yokai\Batch\Factory\JobExecutionParametersBuilder\PerJobJobExecutionParametersBuilder;
@@ -446,10 +449,10 @@ public static function parameters(): \Generator
}
#[DataProvider('errors')]
- public function testErrors(array $config, Exception $error): void
+ public function testErrors(array $config, Exception $error, \Closure|null $packageChecker = null): void
{
$this->expectExceptionObject($error);
- $this->createContainer($config);
+ $this->createContainer($config, packageChecker: $packageChecker);
}
public static function errors(): \Generator
@@ -484,6 +487,15 @@ public static function errors(): \Generator
['launcher' => ['default' => 'invalid', 'launchers' => ['invalid' => 'unknown://unknown']]],
new LogicException('Unsupported job launcher type "unknown".'),
];
+ yield 'Logging : Service type without service id' => [
+ ['logging' => ['type' => 'service']],
+ new LogicException('Cannot configure service logging: provide a service to use.'),
+ ];
+ yield 'Logging : Stream type without monolog installed' => [
+ ['logging' => ['type' => 'stream']],
+ new LogicException('Cannot configure stream logging: install "yokai/batch-monolog" first.'),
+ static fn(string $package): bool => false,
+ ];
yield 'Per job parameters value must be an array' => [
['parameters' => ['per_job' => ['job.foo' => 'string']]],
new InvalidConfigurationException(
@@ -531,14 +543,110 @@ public static function id(): \Generator
];
}
- private function createContainer(array $config, \Closure|null $configure = null): ContainerBuilder
+ #[DataProvider('logging')]
+ public function testLogging(
+ array $config,
+ \Closure|null $configure,
+ string $expectedClass,
+ \Closure|null $assert = null,
+ ): void {
+ $container = $this->createContainer($config, $configure);
+
+ $definition = $this->getDefinition($container, JobExecutionLoggerFactoryInterface::class);
+ self::assertNotNull($definition);
+ self::assertSame($expectedClass, $definition->getClass());
+
+ if ($assert !== null) {
+ $assert($container->findDefinition(JobExecutionLoggerFactoryInterface::class));
+ }
+ }
+
+ public static function logging(): \Generator
{
+ yield 'Default config' => [
+ [],
+ null,
+ InMemoryJobExecutionLoggerFactory::class,
+ ];
+ yield 'Memory explicit' => [
+ ['logging' => ['type' => 'memory']],
+ null,
+ InMemoryJobExecutionLoggerFactory::class,
+ ];
+ yield 'Null' => [
+ ['logging' => ['type' => 'null']],
+ null,
+ NullJobExecutionLoggerFactory::class,
+ ];
+ yield 'Stream default directory' => [
+ ['logging' => ['type' => 'stream']],
+ null,
+ StreamJobExecutionLoggerFactory::class,
+ function (Definition $definition) {
+ [$directory] = $definition->getArguments();
+ self::assertSame('%kernel.logs_dir%/batch', $directory);
+ },
+ ];
+ yield 'Stream custom directory' => [
+ ['logging' => ['type' => 'stream', 'stream' => ['directory' => '/custom/logs']]],
+ null,
+ StreamJobExecutionLoggerFactory::class,
+ function (Definition $definition) {
+ [$directory] = $definition->getArguments();
+ self::assertSame('/custom/logs', $directory);
+ },
+ ];
+ yield 'Stream with subdirectories' => [
+ ['logging' => ['type' => 'stream', 'stream' => ['directory' => '/tmp', 'sub_directories' => 2, 'chars_per_directory' => 3]]],
+ null,
+ StreamJobExecutionLoggerFactory::class,
+ function (Definition $definition) {
+ [, , , $subDirectories, $charsPerDirectory] = $definition->getArguments();
+ self::assertSame(2, $subDirectories);
+ self::assertSame(3, $charsPerDirectory);
+ },
+ ];
+ yield 'Stream with processors' => [
+ ['logging' => ['type' => 'stream', 'stream' => ['processors' => ['my.processor']]]],
+ fn(ContainerBuilder $container) => $container->register('my.processor'),
+ StreamJobExecutionLoggerFactory::class,
+ function (Definition $definition) {
+ [, $processors] = $definition->getArguments();
+ self::assertCount(1, $processors);
+ self::assertInstanceOf(Reference::class, $processors[0]);
+ self::assertSame('my.processor', (string)$processors[0]);
+ },
+ ];
+ yield 'Stream with formatter' => [
+ ['logging' => ['type' => 'stream', 'stream' => ['formatter' => 'my.formatter']]],
+ fn(ContainerBuilder $container) => $container->register('my.formatter'),
+ StreamJobExecutionLoggerFactory::class,
+ function (Definition $definition) {
+ [, , $formatter] = $definition->getArguments();
+ self::assertInstanceOf(Reference::class, $formatter);
+ self::assertSame('my.formatter', (string)$formatter);
+ },
+ ];
+ yield 'Custom service' => [
+ ['logging' => ['type' => 'service', 'service' => NullJobExecutionLoggerFactory::class]],
+ fn(ContainerBuilder $container) => $container->register(NullJobExecutionLoggerFactory::class),
+ NullJobExecutionLoggerFactory::class,
+ ];
+ }
+
+ private function createContainer(
+ array $config,
+ \Closure|null $configure = null,
+ \Closure|null $packageChecker = null,
+ ): ContainerBuilder {
$container = new ContainerBuilder();
if ($configure !== null) {
$configure($container);
}
$bundle = new YokaiBatchBundle();
- $extension = $bundle->getContainerExtension();
+ $extension = $packageChecker !== null
+ ? new YokaiBatchExtension($packageChecker)
+ : $bundle->getContainerExtension();
\assert($extension instanceof YokaiBatchExtension);
$container->registerExtension($extension);
$container->loadFromExtension('yokai_batch', $config);
diff --git a/src/batch/composer.json b/src/batch/composer.json
index c176f5ec..b91034c7 100644
--- a/src/batch/composer.json
+++ b/src/batch/composer.json
@@ -38,6 +38,7 @@
"yokai/batch-symfony-framework": "Integrate to Symfony framework via a bundle",
"yokai/batch-symfony-messenger": "Trigger jobs using message dispatch",
"yokai/batch-symfony-serializer": "Process items using (de)normalization, serialize job execution for certain storages",
- "yokai/batch-symfony-validator": "Skip invalid items during process"
+ "yokai/batch-symfony-validator": "Skip invalid items during process",
+ "yokai/batch-monolog": "Write job execution logs to a file via Monolog"
}
}
diff --git a/src/batch/src/Factory/JobExecutionFactory.php b/src/batch/src/Factory/JobExecutionFactory.php
index 0a40e2ba..786e3b7b 100644
--- a/src/batch/src/Factory/JobExecutionFactory.php
+++ b/src/batch/src/Factory/JobExecutionFactory.php
@@ -35,7 +35,7 @@ public function create(string $name, array $configuration = []): JobExecution
id: $id,
jobName: $name,
parameters: new JobParameters($configuration),
- logger: $this->loggerFactory->create(),
+ logger: $this->loggerFactory->create($id),
);
$jobExecution->setLaunchedAt(new DateTimeImmutable());
diff --git a/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php b/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php
index 34f5a937..50e3570b 100644
--- a/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php
+++ b/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php
@@ -5,19 +5,17 @@
namespace Yokai\Batch\Factory\JobExecutionLoggerFactory;
use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface;
-use Yokai\Batch\JobExecutionLogs;
use Yokai\Batch\Logger\InMemoryJobExecutionLogger;
use Yokai\Batch\Logger\JobExecutionLoggerInterface;
/**
* Default {@see JobExecutionLoggerFactoryInterface} implementation.
*
- * Creates a {@see InMemoryJobExecutionLogger} backed by an in-memory {@see JobExecutionLogs} string.
- * This preserves the behaviour that existed before the logger became pluggable.
+ * Creates an {@see InMemoryJobExecutionLogger}, preserving the behaviour that existed before the logger became pluggable.
*/
final class InMemoryJobExecutionLoggerFactory implements JobExecutionLoggerFactoryInterface
{
- public function create(): JobExecutionLoggerInterface
+ public function create(string $jobExecutionId): JobExecutionLoggerInterface
{
return new InMemoryJobExecutionLogger();
}
diff --git a/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php b/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php
index 5892e465..fa8141d2 100644
--- a/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php
+++ b/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php
@@ -15,7 +15,7 @@
*/
final class NullJobExecutionLoggerFactory implements JobExecutionLoggerFactoryInterface
{
- public function create(): JobExecutionLoggerInterface
+ public function create(string $jobExecutionId): JobExecutionLoggerInterface
{
return new NullJobExecutionLogger();
}
diff --git a/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php b/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php
index c4d51ebb..96c7d81a 100644
--- a/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php
+++ b/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php
@@ -18,7 +18,7 @@ interface JobExecutionLoggerFactoryInterface
/**
* Creates a fresh logger for a new job execution.
*/
- public function create(): JobExecutionLoggerInterface;
+ public function create(string $jobExecutionId): JobExecutionLoggerInterface;
/**
* Restores a logger from a previously stored reference.
diff --git a/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php b/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php
index 486762e0..a0a60382 100644
--- a/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php
+++ b/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php
@@ -14,7 +14,7 @@ public function testCreatesJobExecutionLogger(): void
{
$factory = new InMemoryJobExecutionLoggerFactory();
- $logger = $factory->create();
+ $logger = $factory->create('job-execution-id');
self::assertInstanceOf(InMemoryJobExecutionLogger::class, $logger);
}
@@ -23,8 +23,8 @@ public function testCreatesDistinctInstancesEachCall(): void
{
$factory = new InMemoryJobExecutionLoggerFactory();
- $first = $factory->create();
- $second = $factory->create();
+ $first = $factory->create('job-execution-id');
+ $second = $factory->create('job-execution-id');
self::assertNotSame($first, $second);
}
@@ -33,7 +33,7 @@ public function testLogsAreInitiallyEmpty(): void
{
$factory = new InMemoryJobExecutionLoggerFactory();
- $logger = $factory->create();
+ $logger = $factory->create('job-execution-id');
self::assertSame('', $logger->getReference());
self::assertSame([], \iterator_to_array($logger->getLogs()));
@@ -43,8 +43,8 @@ public function testLogsAreIsolatedBetweenInstances(): void
{
$factory = new InMemoryJobExecutionLoggerFactory();
- $first = $factory->create();
- $second = $factory->create();
+ $first = $factory->create('job-execution-id');
+ $second = $factory->create('job-execution-id');
$first->info('message for first');
diff --git a/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php b/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php
index a9d6e8ed..af76999d 100644
--- a/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php
+++ b/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php
@@ -14,7 +14,7 @@ public function testCreateReturnsNullLogger(): void
{
$factory = new NullJobExecutionLoggerFactory();
- self::assertInstanceOf(NullJobExecutionLogger::class, $factory->create());
+ self::assertInstanceOf(NullJobExecutionLogger::class, $factory->create('job-execution-id'));
}
public function testRestoreReturnsNullLoggerRegardlessOfReference(): void
diff --git a/tests/convention/Documentation/DocumentationLinksTest.php b/tests/convention/Documentation/DocumentationLinksTest.php
index 5ebf4bba..f439bd82 100644
--- a/tests/convention/Documentation/DocumentationLinksTest.php
+++ b/tests/convention/Documentation/DocumentationLinksTest.php
@@ -6,6 +6,7 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
+use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface;
use Yokai\Batch\Job\Item\ItemProcessorInterface;
use Yokai\Batch\Job\Item\ItemReaderInterface;
use Yokai\Batch\Job\Item\ItemWriterInterface;
@@ -127,5 +128,9 @@ public static function interfaceRules(): iterable
'docs/docs/core-concepts/item-job/item-writer.rst',
ItemWriterInterface::class,
];
+ yield 'JobExecutionLoggerFactoryInterface' => [
+ 'docs/docs/core-concepts/job-execution-logger.rst',
+ JobExecutionLoggerFactoryInterface::class,
+ ];
}
}