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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to **parallel-sdk** are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### Added
- `ParallelWorker::write()` and `ParallelWorker::writeln()` methods to emit console messages from workers without them being overwritten by the ProgressBar.
- `WriteOutputMessage` command to route `write()`/`writeln()` calls through the existing channel infrastructure.

### Changed
- `ProgressBarWorker` now uses a shared `stderr` `OutputInterface` for both the ProgressBar and messages, so `clear()`/`write()`/`display()` work correctly together.

## `3.0.0` – 2025-07-04

### Added
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ Available methods are:
- `setProgress(int $step)`
- `display()`
- `clear()`
- `write(string $message, bool $newline = false)`
- `writeln(string $message)`

```php
use HDSSolutions\Console\Parallel\ParallelWorker;
Expand All @@ -332,6 +334,7 @@ final class ExampleWorker extends ParallelWorker {
$microseconds = random_int(100, 500);
$this->setMessage(sprintf("ExampleWorker >> Hello from task #%u, I'll wait %sms", $number, $microseconds));
usleep($microseconds * 1000);
$this->writeln(sprintf("ExampleWorker >> Finished task #%u", $number));
$this->advance();
// end example process

Expand All @@ -349,6 +352,44 @@ final class ExampleWorker extends ParallelWorker {
memory: 562 KiB, threads: 12x ~474 KiB, Σ 5,6 MiB ↑ 5,6 MiB
```

#### Console messages

Use `write()` or `writeln()` to emit ad-hoc console messages from a worker. When a ProgressBar is active, the bar is temporarily hidden, the message is printed, and the bar is redrawn below it so the message is not overwritten.

These methods also work for workers that did not enable `withProgress()`; messages are then written directly to the console.

```php
use HDSSolutions\Console\Parallel\ParallelWorker;

final class ExampleWorker extends ParallelWorker {

protected function process(int $number = 0): int {
$this->writeln(sprintf("Processing task #%u", $number));

// ... do work ...

$this->writeln(sprintf("Finished task #%u", $number));

return $number;
}

}
```

#### Example output
When used together with `withProgress()`, messages are printed between progress-bar redraws instead of being overwritten:

```bash
0 of 10: Starting...
[>------------------------------------------------------------------------] 0%
elapsed: < 1 sec, remaining: < 1 sec, ?? items/s,memory: ??
Processing task #5
Finished task #5
1 of 10: Task #5
[=====>-------------------------------------------------------------------] 10%
elapsed: < 1 sec, remaining: < 1 sec, ~1.00 items/s,memory: ??
```

### References
1. [parallel\bootstrap()](https://www.php.net/manual/en/parallel.bootstrap.php)
2. [parallel\Runtime](https://www.php.net/manual/en/class.parallel-runtime.php)
Expand Down
21 changes: 21 additions & 0 deletions src/Internals/Commands/Output/WriteOutputMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php declare(strict_types=1);

namespace HDSSolutions\Console\Parallel\Internals\Commands\Output;

use HDSSolutions\Console\Parallel\Internals\Commands\ParallelCommandMessage;

/**
* Message sent to {@see \HDSSolutions\Console\Parallel\Internals\ProgressBarWorker}
* to execute {@see \HDSSolutions\Console\Parallel\Internals\ProgressBarWorker::writeOutput()}.
*/
final readonly class WriteOutputMessage extends ParallelCommandMessage {

/**
* @param string $message Text to output
* @param bool $newline Whether to add a trailing newline
*/
public function __construct(string $message, bool $newline = true) {
parent::__construct('write_output', [ $message, $newline ]);
}

}
8 changes: 8 additions & 0 deletions src/Internals/ProgressBarWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace HDSSolutions\Console\Parallel\Internals;

use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Output\ConsoleOutput;

final class ProgressBarWorker {
use ProgressBarWorker\HasChannels;
Expand Down Expand Up @@ -64,6 +65,13 @@ private function progressBarAction(string $action, array $args): void {
}
}

private function writeOutput(string $message, bool $newline = true): void {
// clear the bar, write the message, then redraw the bar below it
$this->progressBar->clear();
(new ConsoleOutput)->getErrorOutput()->write($message, $newline);
$this->progressBar->display();
}

private function statsReport(string $worker_id, int $memory_usage): void {
// save memory usage of thread
$this->threads_memory['current'][$worker_id] = $memory_usage;
Expand Down
1 change: 1 addition & 0 deletions src/Internals/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class Runner {
use Runner\HasChannels;
use Runner\HasEater;
use Runner\HasSharedProgressBar;
use Runner\HasSharedConsole;

use Runner\ManagesWorkers;
use Runner\ManagesTasks;
Expand Down
26 changes: 26 additions & 0 deletions src/Internals/Runner/HasSharedConsole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types=1);

namespace HDSSolutions\Console\Parallel\Internals\Runner;

use Symfony\Component\Console\Output\ConsoleOutput;

trait HasSharedConsole {

/**
* @var ConsoleOutput|null Local ConsoleOutput used on non-threaded environments
*/
private ?ConsoleOutput $consoleOutput = null;

private function initConsole(): void {
// on non-threaded environments, just initialize the local ConsoleOutput
if (! PARALLEL_EXT_LOADED) {
$this->consoleOutput ??= new ConsoleOutput();
}
}

private function writeOutput(string $message, bool $newline = true): void {
$this->consoleOutput ??= new ConsoleOutput();
$this->consoleOutput->getErrorOutput()->write($message, $newline);
}

}
8 changes: 7 additions & 1 deletion src/Internals/Runner/ManagesTasks.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ private function startNextPendingTask(): void {
// process task using user Worker
: [ ...$task->getInput() ];

// initialize console output (also initializes the local ConsoleOutput on non-threaded environments)
$this->initConsole();

// check if worker has ProgressBar enabled
if ($registered_worker->hasProgressEnabled()) {
// init progressbar
Expand All @@ -151,9 +154,12 @@ private function startNextPendingTask(): void {
steps: $registered_worker->getSteps(),
));
// connect worker to ProgressBar
$worker->connectProgressBar(fn(Commands\ProgressBar\ProgressBarActionMessage $message) => $this->progressBar->processMessage($message));
$worker->connectProgressBar(fn(Commands\ParallelCommandMessage $message) => $this->progressBar->processMessage($message));
}

// connect worker to console output fallback
$worker->connectConsole(fn(Commands\Output\WriteOutputMessage $message) => $this->writeOutput(...$message->args));

$task->setState(Task::STATE_Processing);

// process task using worker
Expand Down
51 changes: 51 additions & 0 deletions src/Internals/Worker/CommunicatesWithProgressBarWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ trait CommunicatesWithProgressBarWorker {
*/
private TwoWayChannel | Closure | null $progressbar_channel = null;

/**
* @var TwoWayChannel|Closure|null Channel of communication between Task and Console output
*/
private TwoWayChannel | Closure | null $console_channel = null;

final public function connectProgressBar(string | Closure $uuid, string $identifier = null): bool {
if (! PARALLEL_EXT_LOADED) {
$this->progressbar_channel = $uuid;
Expand All @@ -36,6 +41,17 @@ final public function connectProgressBar(string | Closure $uuid, string $identif
return true;
}

final public function connectConsole(string | Closure $uuid, string $identifier = null): bool {
// on non-threaded environments the Runner provides a closure that writes to ConsoleOutput
if (! PARALLEL_EXT_LOADED) {
$this->console_channel = $uuid;
}

// on threaded environments there is no console coordinator to connect to:
// messages are written directly to STDERR when no progress bar is active
return true;
}

final public function setMessage(string $message, string $name = 'message'): void {
$this->newProgressBarAction(__FUNCTION__, $message, $name);
}
Expand All @@ -56,6 +72,41 @@ final public function clear(): void {
$this->newProgressBarAction(__FUNCTION__);
}

final public function write(string $message, bool $newline = false): void {
$this->sendOutputMessage(new Commands\Output\WriteOutputMessage($message, $newline));
}

final public function writeln(string $message): void {
$this->write($message, true);
}

private function sendOutputMessage(Commands\Output\WriteOutputMessage $message): void {
// if a progress bar is active, route the message through the ProgressBar worker
if ($this->progressbar_channel !== null) {
if (PARALLEL_EXT_LOADED) {
$this->progressbar_channel->send($message);
} else {
($this->progressbar_channel)($message);
}

return;
}

// if a console output channel is connected, route the message there
if ($this->console_channel !== null) {
if (PARALLEL_EXT_LOADED) {
$this->console_channel->send($message);
} else {
($this->console_channel)($message);
}

return;
}

// fallback when no coordinator is available
fwrite(STDERR, $message->args[0].($message->args[1] ? PHP_EOL : ''));
}

private function newProgressBarAction(string $action, ...$args): void {
// check if progressbar is active
if ($this->progressbar_channel === null) return;
Expand Down
70 changes: 70 additions & 0 deletions tests/ParallelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,74 @@ public function testThatCpuUsageCanBeControlled(): void {
$this->assertGreaterThanOrEqual(1, time() - $start);
}

public function testThatWorkerCanWriteMessagesWithoutProgressBar(): void {
$output = $this->runWorkerScript(<<<'PHP'
Scheduler::using(Writer::class);
foreach (range(1, 3) as $i) {
Scheduler::runTask($i);
}
Scheduler::awaitTasksCompletion();
PHP);

$this->assertStringContainsString('Starting #1', $output);
$this->assertStringContainsString('Starting #2', $output);
$this->assertStringContainsString('Starting #3', $output);
}

public function testThatWorkerCanWriteMessagesWithProgressBar(): void {
$output = $this->runWorkerScript(<<<'PHP'
Scheduler::using(Writer::class)->withProgress(steps: 3);
foreach (range(1, 3) as $i) {
Scheduler::runTask($i);
}
Scheduler::awaitTasksCompletion();
PHP);

$this->assertStringContainsString('Starting #1', $output);
$this->assertStringContainsString('Done #3', $output);
$this->assertStringContainsString('3 of 3: Task #3', $output);
}

private function runWorkerScript(string $body): string {
$autoload = __DIR__.'/../vendor/autoload.php';

$script = <<<'PHP'
<?php declare(strict_types=1);
require __AUTOLOAD__;

if (extension_loaded('parallel')) {
parallel\bootstrap(__AUTOLOAD__);
}

use HDSSolutions\Console\Parallel\ParallelWorker;
use HDSSolutions\Console\Parallel\Scheduler;

final class Writer extends ParallelWorker {
protected function process(int $n = 0): int {
$this->setMessage(sprintf('Task #%d', $n));
$this->writeln(sprintf('Starting #%d', $n));
$this->writeln(sprintf('Done #%d', $n));
$this->advance();

return $n;
}
}

__BODY__
PHP;

$file = tempnam(sys_get_temp_dir(), 'parallel_sdk_test_').'.php';
file_put_contents($file, str_replace(['__AUTOLOAD__', '__BODY__'], [var_export($autoload, true), $body], $script));

$output = [];
$exit = 0;
exec(sprintf('%s %s 2>&1', escapeshellarg(PHP_BINARY), escapeshellarg($file)), $output, $exit);

unlink($file);

$this->assertSame(0, $exit, 'Worker script exited with an error');

return implode("\n", $output);
}

}
Loading