Update README.md, consistent use of spaces instead of tabs, better examples

This commit is contained in:
Joop Schilder 2020-12-12 13:05:48 +01:00
parent 92bc0ab407
commit db081158d7
30 changed files with 997 additions and 1041 deletions

211
README.md
View File

@ -4,7 +4,7 @@ Welcome to Toalett, a humble initiative based around the idea that all software
Toalett is the Norwegian word for toilet. It feels fancier than plain "toilet".
## Why `toalett/multiprocessing`?
[Multiprocessing](https://nl.wikipedia.org/wiki/Multiprocessing) is a technique that is often used in PHP (CLI-)applications to execute tasks asynchronously.
[Multiprocessing](https://nl.wikipedia.org/wiki/Multiprocessing) is a technique that is often used in PHP (cli) applications to execute tasks asynchronously.
Due to the lack of native [multithreading](https://en.wikipedia.org/wiki/Multithreading_(computer_architecture)) in PHP, developers have to rely on
good old multiprocessing to do this.
@ -22,6 +22,56 @@ Workers are a representation of child processes that are working on a task.
The Context uses a [ReactPHP EventLoop](https://reactphp.org/event-loop/) internally
and emits events using the simple (but elegant) [Evenement](https://github.com/igorw/Evenement) library.
## Events
The context emits events when something of interest happens.
You can react to these events by calling:
`$context->on('name_of_event', fn() => ...);`.
These are the events emitted by the context:
1. `booted`
2. `worker_started`
3. `worker_stopped`
4. `congestion`
5. `congestion_relieved`
6. `no_workers_remaining`
7. `stopped`
#### 1. `booted`
This event is emitted after `$context->run()` is called.
This is the very first event dispatched by the context.
It is dispatched as soon as the event loop has started.
#### 2. `worker_started`
This event is emitted when a worker has been started (the process has been forked).
The PID of the child process is supplied as an argument to a listener.
#### 3. `worker_stopped`
This event is emitted when a worker has been stopped (child process has stopped).
The PID of the child process is supplied as an argument to a listener.
#### 4. `congestion`
This event is emitted when the imposed concurrency limit is reached.
This happens when (for example) the concurrency is set to at most 2 child processes,
and a third task gets submitted while 2 tasks are already running.
The system naively waits for a child to stop before starting another worker.
#### 5. `congestion_relieved`
This event is emitted when congestion is relieved.
This means that a child has stopped, allowing for the execution of a new task.
#### 6. `no_workers_remaining`
This event is emitted when there are no workers left running.
This usually means there is no more work to do.
It's possible to automatically stop the context when this event occurs.
This is shown in the first and last example.
#### 7. `stopped`
The context can be stopped by calling `$context->stop()`.
When the workers and the event loop are succesfully stopped, the context
emits a `stopped` event.
## Examples
For most developers, the quickest way to learn something is by looking at examples.
Three examples are provided.
@ -30,181 +80,98 @@ There is a simple example, which demonstrates event emission with the creation o
A counter is incremented every time a job stops.
When all jobs are done, the context is stopped.
### [Simple example](bin/simple_example.php)
The cleanup interval is the interval at which the context checks for dead
worker processes and reads their exit codes.
It defaults to 5 seconds and is in some examples explicitely set to a low
value to improve example responsiveness.
### [Counting stopped workers using events](bin/counting_stopped_workers.php)
```php
<?php
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\Task\Interval;
require_once '/path/to/autoload.php';
// We will run 50 jobs
const NUM_JOBS = 50;
$counter = new class {
public int $value = 0;
public function increment(): void
{
$this->value++;
}
};
// Create a context (defaults to unlimited child processes).
// The cleanup interval is the interval at dead processes
// will be read. For this example it's kept low.
// The default value is 5 seconds.
$context = ContextBuilder::create()
->withCleanupInterval(Interval::seconds(0.5))
->build();
->withCleanupInterval(Interval::seconds(0.5))
->build();
$counter = new Counter();
$context->on('worker_stopped', [$counter, 'increment']);
$context->on('no_workers_remaining', [$context, 'stop']);
$context->on('stopped', fn() => printf("\nJobs completed: %d\n", $counter->value));
$context->on('stopped', fn() => printf(" %d\n", $counter->value));
// You can submit jobs before the context is running. They will be executed
// in the order in which they are submitted to the context.
// Each job (thus child process) will be sleeping for 3 seconds.
for ($i = 0; $i < NUM_JOBS; $i++) {
$context->submit(fn() => sleep(3));
print('.');
$context->submit(fn() => sleep(2));
print('.');
}
$context->run();
```
### [More elaborate example](bin/more_elaborate_example.php)
### [Triggering congestion with 4 workers](bin/triggering_congestion.php)
This example is a bit more elaborate than the previous one.
It serves to demonstrate congestion and how it is handled by the context:
the context simply blocks all execution until a worker stops and a spot becomes available.
This example shows the usage of events.
Watch for the occurence of 'C' in the output.
This denotes congestion: a worker could not be started.
```php
<?php
use React\EventLoop\Factory;
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\ConcurrencyLimit;
use React\EventLoop\Factory as EventLoopFactory;
use Toalett\Multiprocessing\Concurrency;
require_once '/path/to/autoload.php';
// Create our own EventLoop and limit and supply them to the builder
$loop = EventLoopFactory::create();
$loop = Factory::create();
$context = ContextBuilder::create()
->withEventLoop($loop)
->withLimit(ConcurrencyLimit::atMost(4))
->build();
->withEventLoop($loop)
->withConcurrency(Concurrency::atMost(4))
->build();
$context->on('booted', fn() => print("🚽 Toalett Multiprocessing Context\n"));
$context->on('booted', fn() => print("🚽 toalett context booted\n"));
$context->on('congestion', fn() => print('C'));
$context->on('congestion_relieved', fn() => print('R'));
$context->on('worker_started', fn() => print('+'));
$context->on('worker_stopped', fn() => print('-'));
// Submit a fake job every second
// A job is submitted to the context every second.
// The job sleeps for a random amount of seconds (0 - 10).
$loop->addPeriodicTimer(1, fn() => $context->submit(fn(int $s) => sleep($s), random_int(0, 10)));
print("Press CTRL+C to stop.\n");
$context->run();
```
### [Example with a Job class](bin/example_with_job_class.php)
Since the task is defined by a `callable` supplied with arguments, it's also possible to
define a class that implements the magic `__invoke()` method and submit objects of this
class to the Context. Objects implementing the `__invoke()` method can be treated as
closures. They may accept zero or more arguments.
### [Single worker with a Job class](bin/single_worker_with_job_class.php)
Since a task is really just a `Closure`, it's also possible to submit an object
with an implementation of the `__invoke()` magic method.
In this example, execution is limited to a single worker, and jobs are
instances of the `Job` class.
This idea is demonstrated here, while execution is limited to a single worker.
```php
<?php
use Toalett\Multiprocessing\ConcurrencyLimit;
use Toalett\Multiprocessing\Concurrency;
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\Task\Interval;
require_once '/path/to/vendor/autoload.php';
class Job
{
private string $title;
public function __construct(string $title)
{
$this->title = $title;
}
public function __invoke()
{
cli_set_process_title("php {$this->title}");
print("+ {$this->title}");
sleep(1);
print("\r {$this->title}\n");
}
}
$limit = ConcurrencyLimit::singleWorker();
$context = ContextBuilder::create()
->withLimit(ConcurrencyLimit::singleWorker())
->withCleanupInterval(Interval::seconds(0.2))
->build();
->withConcurrency(Concurrency::singleWorker())
->withCleanupInterval(Interval::seconds(0.2))
->build();
for ($i = 0; $i < 3; $i++) {
$title = md5(mt_rand());
$context->submit(new Job($title));
$title = md5(mt_rand());
$context->submit(new Job($title));
}
$context->on('no_workers_remaining', [$context, 'stop']);
$context->run();
```
## Events
1. `booted`
1. `worker_started`
1. `worker_stopped`
1. `congestion`
1. `congestion_relieved`
1. `no_workers_remaining`
1. `stopped`
These events are emitted by the context.
They can be subscribed to by calling `$context->on('...', fn() => ...);`.
#### `booted`
This event is emitted when `$context->run()` is called.
This is the very first event dispatched by the context.
#### `worker_started`
This event is emitted when a worker has been started (the process has been forked).
The PID of the child process is supplied as an argument to a listener.
#### `worker_stopped`
This event is emitted when a worker has been stopped (child process has stopped).
The PID of the child process is supplied as an argument to a listener.
#### `congestion`
This event is emitted when the imposed concurrency limit is reached, for example,
when the limit is set to at most 2 child processes, and a third task gets submitted
while there are already two tasks running.
The system naively waits for a child to stop before starting another worker.
#### `congestion_relieved`
This event is emitted in case the congestion explained above is relieved.
This means that a child has stopped, allowing for the execution of a new task.
#### `no_workers_remaining`
This event is emitted when there are no workers left running.
This usually means there is no more work to do.
It's possible to automatically stop the context when this event occurs.
This is shown in the first and last example.
#### `stopped`
This event is emitted when `$context->stop()` is called and the eventloop has
succesfully been stopped.
## Why no shared memory?
Shared memory in PHP is hard to manage and quickly becomes a mess. Don't ask.
Feel free to add it yourself though. 😉
## Tests
Tests can be found in the [src/Tests/](src/Tests) directory.

11
bin/classes/Counter.php Normal file
View File

@ -0,0 +1,11 @@
<?php
class Counter
{
public int $value = 0;
public function increment(): void
{
$this->value++;
}
}

19
bin/classes/Job.php Normal file
View File

@ -0,0 +1,19 @@
<?php
class Job
{
private string $title;
public function __construct(string $title)
{
$this->title = $title;
}
public function __invoke()
{
cli_set_process_title("php {$this->title}");
print("* {$this->title}");
sleep(1);
print("\r {$this->title}\n");
}
}

View File

@ -4,29 +4,21 @@ use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\Task\Interval;
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/classes/Counter.php';
const NUM_JOBS = 50;
$counter = new class {
public int $value = 0;
public function increment(): void
{
$this->value++;
}
};
$context = ContextBuilder::create()
->withCleanupInterval(Interval::seconds(0.5))
->build();
->withCleanupInterval(Interval::seconds(0.5))
->build();
$counter = new Counter();
$context->on('worker_stopped', [$counter, 'increment']);
$context->on('no_workers_remaining', [$context, 'stop']);
$context->on('stopped', fn() => printf("\nJobs completed: %d\n", $counter->value));
$context->on('stopped', fn() => printf(" %d\n", $counter->value));
for ($i = 0; $i < NUM_JOBS; $i++) {
$context->submit(fn() => sleep(3));
print('.');
$context->submit(fn() => sleep(2));
print('.');
}
$context->run();

View File

@ -1,39 +0,0 @@
<?php
use Toalett\Multiprocessing\ConcurrencyLimit;
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\Task\Interval;
require_once __DIR__ . '/../vendor/autoload.php';
class Job
{
private string $title;
public function __construct(string $title)
{
$this->title = $title;
}
public function __invoke()
{
cli_set_process_title("php {$this->title}");
print("+ {$this->title}");
sleep(1);
print("\r {$this->title}\n");
}
}
$limit = ConcurrencyLimit::singleWorker();
$context = ContextBuilder::create()
->withLimit(ConcurrencyLimit::singleWorker())
->withCleanupInterval(Interval::seconds(0.2))
->build();
for ($i = 0; $i < 3; $i++) {
$title = md5(mt_rand());
$context->submit(new Job($title));
}
$context->on('no_workers_remaining', [$context, 'stop']);
$context->run();

View File

@ -0,0 +1,21 @@
<?php
use Toalett\Multiprocessing\Concurrency;
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\Task\Interval;
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/classes/Job.php';
$context = ContextBuilder::create()
->withConcurrency(Concurrency::singleWorker())
->withCleanupInterval(Interval::seconds(0.2))
->build();
for ($i = 0; $i < 3; $i++) {
$title = md5(mt_rand());
$context->submit(new Job($title));
}
$context->on('no_workers_remaining', [$context, 'stop']);
$context->run();

View File

@ -1,16 +1,16 @@
<?php
use React\EventLoop\Factory;
use Toalett\Multiprocessing\Concurrency;
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\ConcurrencyLimit;
use React\EventLoop\Factory as EventLoopFactory;
require_once __DIR__ . '/../vendor/autoload.php';
$loop = EventLoopFactory::create();
$loop = Factory::create();
$context = ContextBuilder::create()
->withEventLoop($loop)
->withLimit(ConcurrencyLimit::atMost(4))
->build();
->withEventLoop($loop)
->withConcurrency(Concurrency::atMost(4))
->build();
$context->on('booted', fn() => print("🚽 Toalett Multiprocessing Context\n"));
$context->on('congestion', fn() => print('C'));

View File

@ -4,7 +4,7 @@ namespace Toalett\Multiprocessing;
use Toalett\Multiprocessing\Exception\InvalidArgumentException;
class ConcurrencyLimit
class Concurrency
{
private const VALUE_UNLIMITED = -1;
private int $limit;
@ -24,7 +24,7 @@ class ConcurrencyLimit
public static function atMost(int $limit): self
{
return new self($limit);
return new self($limit);
}
public static function unlimited(): self

View File

@ -11,75 +11,70 @@ use Toalett\Multiprocessing\Task\Tasks;
class Context implements EventEmitterInterface
{
public const INTERVAL_GC = 120;
public const INTERVAL_CLEANUP = 5;
use EventEmitterTrait;
public const INTERVAL_GC = 120;
public const INTERVAL_CLEANUP = 5;
use EventEmitterTrait;
private LoopInterface $eventLoop;
private ConcurrencyLimit $limit;
private Workers $workers;
private Tasks $maintenanceTasks;
private LoopInterface $eventLoop;
private Concurrency $concurrency;
private Workers $workers;
private Tasks $maintenanceTasks;
public function __construct(
LoopInterface $eventLoop,
ConcurrencyLimit $limit,
?Workers $workers = null,
?Interval $cleanupInterval = null,
?Interval $garbageCollectionInterval = null
)
{
$this->eventLoop = $eventLoop;
$this->limit = $limit;
$this->workers = $workers ?? new Workers();
$this->setupWorkerEventForwarding();
$this->setupMaintenanceTasks($cleanupInterval, $garbageCollectionInterval);
}
public function __construct(
LoopInterface $eventLoop,
Concurrency $concurrency,
?Workers $workers = null,
?Interval $cleanupInterval = null
)
{
$this->eventLoop = $eventLoop;
$this->concurrency = $concurrency;
$this->workers = $workers ?? new Workers();
$this->setupWorkerEventForwarding();
$this->setupMaintenanceTasks($cleanupInterval);
}
public function run(): void
{
$this->eventLoop->futureTick(fn() => $this->emit('booted'));
$this->eventLoop->futureTick(fn() => gc_enable());
$this->maintenanceTasks->enable($this->eventLoop);
$this->eventLoop->run();
}
public function run(): void
{
$this->eventLoop->futureTick(fn() => $this->emit('booted'));
$this->eventLoop->futureTick(fn() => gc_enable());
$this->maintenanceTasks->enable($this->eventLoop);
$this->eventLoop->run();
}
public function submit(callable $task, ...$args): void
{
$this->eventLoop->futureTick(function () use ($task, $args) {
if ($this->limit->isReachedBy(count($this->workers))) {
$this->emit('congestion');
$this->workers->awaitCongestionRelief();
$this->emit('congestion_relieved');
}
$this->workers->createWorkerFor($task, $args);
});
}
public function submit(callable $task, ...$args): void
{
$this->eventLoop->futureTick(function () use ($task, $args) {
if ($this->concurrency->isReachedBy(count($this->workers))) {
$this->emit('congestion');
$this->workers->awaitCongestionRelief();
$this->emit('congestion_relieved');
}
$this->workers->createWorkerFor($task, $args);
});
}
public function stop(): void
{
$this->maintenanceTasks->cancel();
$this->workers->stop();
$this->emit('stopped');
}
public function stop(): void
{
$this->maintenanceTasks->cancel();
$this->workers->stop();
$this->emit('stopped');
}
private function setupWorkerEventForwarding(): void
{
$this->workers->on('worker_started', fn(int $pid) => $this->emit('worker_started', [$pid]));
$this->workers->on('worker_stopped', fn(int $pid) => $this->emit('worker_stopped', [$pid]));
$this->workers->on('no_workers_remaining', fn() => $this->emit('no_workers_remaining'));
}
private function setupWorkerEventForwarding(): void
{
$this->workers->on('worker_started', fn(int $pid) => $this->emit('worker_started', [$pid]));
$this->workers->on('worker_stopped', fn(int $pid) => $this->emit('worker_stopped', [$pid]));
$this->workers->on('no_workers_remaining', fn() => $this->emit('no_workers_remaining'));
}
private function setupMaintenanceTasks(?Interval $cleanupInterval, ?Interval $garbageCollectionInterval): void
{
$this->maintenanceTasks = new Tasks(
new RepeatedTask(
$cleanupInterval ?? Interval::seconds(self::INTERVAL_CLEANUP),
fn() => $this->workers->cleanup()
),
new RepeatedTask(
$garbageCollectionInterval ?? Interval::seconds(self::INTERVAL_GC),
fn() => gc_collect_cycles()
)
);
}
private function setupMaintenanceTasks(?Interval $cleanupInterval): void
{
$cleanupInterval = $cleanupInterval ?? Interval::seconds(self::INTERVAL_CLEANUP);
$gcInterval = Interval::seconds(self::INTERVAL_GC);
$this->maintenanceTasks = new Tasks(
new RepeatedTask($cleanupInterval, [$this->workers, 'cleanup']),
new RepeatedTask($gcInterval, 'gc_collect_cycles')
);
}
}

View File

@ -8,60 +8,51 @@ use Toalett\Multiprocessing\Task\Interval;
class ContextBuilder
{
private ?LoopInterface $loop = null;
private ?ConcurrencyLimit $limit = null;
private ?Workers $workers = null;
private ?Interval $garbageCollectionInterval = null;
private ?Interval $cleanupInterval = null;
private ?LoopInterface $loop = null;
private ?Concurrency $concurrency = null;
private ?Workers $workers = null;
private ?Interval $cleanupInterval = null;
public static function create(): self
{
return new self();
}
public static function create(): self
{
return new self();
}
public function withEventLoop(LoopInterface $loop): self
{
$instance = clone $this;
$instance->loop = $loop;
return $instance;
}
public function withEventLoop(LoopInterface $loop): self
{
$instance = clone $this;
$instance->loop = $loop;
return $instance;
}
public function withLimit(ConcurrencyLimit $limit): self
{
$instance = clone $this;
$instance->limit = $limit;
return $instance;
}
public function withConcurrency(Concurrency $concurrency): self
{
$instance = clone $this;
$instance->concurrency = $concurrency;
return $instance;
}
public function withWorkers(Workers $workers): self
{
$instance = clone $this;
$instance->workers = $workers;
return $instance;
}
public function withWorkers(Workers $workers): self
{
$instance = clone $this;
$instance->workers = $workers;
return $instance;
}
public function withGarbageCollectionInterval(Interval $interval): self
{
$instance = clone $this;
$instance->garbageCollectionInterval = $interval;
return $instance;
}
public function withCleanupInterval(Interval $interval): self
{
$instance = clone $this;
$instance->cleanupInterval = $interval;
return $instance;
}
public function withCleanupInterval(Interval $interval): self
{
$instance = clone $this;
$instance->cleanupInterval = $interval;
return $instance;
}
public function build(): Context
{
return new Context(
$this->loop ?? Factory::create(),
$this->limit ?? ConcurrencyLimit::unlimited(),
$this->workers,
$this->cleanupInterval,
$this->garbageCollectionInterval
);
}
public function build(): Context
{
return new Context(
$this->loop ?? Factory::create(),
$this->concurrency ?? Concurrency::unlimited(),
$this->workers,
$this->cleanupInterval
);
}
}

View File

@ -4,25 +4,25 @@ namespace Toalett\Multiprocessing\ProcessControl;
class Fork
{
public int $pid;
public int $pid;
public function __construct(int $pid)
{
$this->pid = $pid;
}
public function __construct(int $pid)
{
$this->pid = $pid;
}
public function failed(): bool
{
return $this->pid < 0;
}
public function failed(): bool
{
return $this->pid < 0;
}
public function isChild(): bool
{
return $this->pid === 0;
}
public function isChild(): bool
{
return $this->pid === 0;
}
public function isParent(): bool
{
return $this->pid !== 0;
}
public function isParent(): bool
{
return $this->pid !== 0;
}
}

View File

@ -4,15 +4,15 @@ namespace Toalett\Multiprocessing\ProcessControl;
class PCNTL implements ProcessControl
{
public function fork(): Fork
{
$pid = pcntl_fork();
return new Fork($pid);
}
public function fork(): Fork
{
$pid = pcntl_fork();
return new Fork($pid);
}
public function wait(int $options = 0): Wait
{
$pid = pcntl_wait($status, $options);
return new Wait($pid, $status);
}
public function wait(int $options = 0): Wait
{
$pid = pcntl_wait($status, $options);
return new Wait($pid, $status);
}
}

View File

@ -4,7 +4,7 @@ namespace Toalett\Multiprocessing\ProcessControl;
interface ProcessControl
{
public function fork(): Fork;
public function fork(): Fork;
public function wait(int $options = 0): Wait;
public function wait(int $options = 0): Wait;
}

View File

@ -4,24 +4,24 @@ namespace Toalett\Multiprocessing\ProcessControl;
class Wait
{
public const NO_HANG = WNOHANG;
public const UNTRACED = WUNTRACED;
public int $pid;
public int $status;
public const NO_HANG = WNOHANG;
public const UNTRACED = WUNTRACED;
public int $pid;
public int $status;
public function __construct(int $pid, int $status = 0)
{
$this->pid = $pid;
$this->status = $status;
}
public function __construct(int $pid, int $status = 0)
{
$this->pid = $pid;
$this->status = $status;
}
public function childStopped(): bool
{
return $this->pid > 0;
}
public function childStopped(): bool
{
return $this->pid > 0;
}
public function failed(): bool
{
return $this->pid < 0;
}
public function failed(): bool
{
return $this->pid < 0;
}
}

View File

@ -6,38 +6,38 @@ use Toalett\Multiprocessing\Exception\InvalidArgumentException;
class Interval
{
private float $seconds;
private float $seconds;
private function __construct(float $seconds)
{
if ($seconds <= 0) {
throw new InvalidArgumentException('positive float', $seconds);
}
$this->seconds = $seconds;
}
private function __construct(float $seconds)
{
if ($seconds <= 0) {
throw new InvalidArgumentException('positive float', $seconds);
}
$this->seconds = $seconds;
}
public static function seconds(float $seconds): self
{
return new self($seconds);
}
public static function seconds(float $seconds): self
{
return new self($seconds);
}
public static function minutes(float $minutes): self
{
return new self(60.0 * $minutes);
}
public static function minutes(float $minutes): self
{
return new self(60.0 * $minutes);
}
public static function hours(float $hours): self
{
return new self(3600.0 * $hours);
}
public static function hours(float $hours): self
{
return new self(3600.0 * $hours);
}
public function asFloat(): float
{
return $this->seconds;
}
public function asFloat(): float
{
return $this->seconds;
}
public function asInt(): int
{
return (int)$this->seconds;
}
public function asInt(): int
{
return (int)$this->seconds;
}
}

View File

@ -7,16 +7,16 @@ use React\EventLoop\TimerInterface;
class RepeatedTask extends Task
{
public Interval $interval;
public Interval $interval;
public function __construct(Interval $interval, callable $callable, ...$arguments)
{
$this->interval = $interval;
parent::__construct($callable, $arguments);
}
public function __construct(Interval $interval, callable $callable, ...$arguments)
{
$this->interval = $interval;
parent::__construct($callable, $arguments);
}
protected function generateTimer(LoopInterface $loop): TimerInterface
{
return $loop->addPeriodicTimer($this->interval->asFloat(), $this->createDeferredCall());
}
protected function generateTimer(LoopInterface $loop): TimerInterface
{
return $loop->addPeriodicTimer($this->interval->asFloat(), $this->createDeferredCall());
}
}

View File

@ -7,42 +7,42 @@ use React\EventLoop\TimerInterface;
abstract class Task
{
public $callable;
public array $arguments;
protected ?TimerInterface $timer = null;
public $callable;
public array $arguments;
protected ?TimerInterface $timer = null;
public function __construct(callable $callable, ...$arguments)
{
$this->callable = $callable;
$this->arguments = $arguments;
}
public function __construct(callable $callable, ...$arguments)
{
$this->callable = $callable;
$this->arguments = $arguments;
}
abstract protected function generateTimer(LoopInterface $loop): TimerInterface;
abstract protected function generateTimer(LoopInterface $loop): TimerInterface;
protected function createDeferredCall(): callable
{
return fn() => call_user_func_array(
$this->callable,
$this->arguments
);
}
protected function createDeferredCall(): callable
{
return fn() => call_user_func_array(
$this->callable,
$this->arguments
);
}
public function enable(LoopInterface $loop): void
{
if (!$this->isBound()) {
$this->timer = $this->generateTimer($loop);
}
}
public function enable(LoopInterface $loop): void
{
if (!$this->isBound()) {
$this->timer = $this->generateTimer($loop);
}
}
public function isBound(): bool
{
return !is_null($this->timer);
}
public function isBound(): bool
{
return !is_null($this->timer);
}
public function cancel(LoopInterface $loop): void
{
if ($this->isBound()) {
$loop->cancelTimer($this->timer);
}
}
public function cancel(LoopInterface $loop): void
{
if ($this->isBound()) {
$loop->cancelTimer($this->timer);
}
}
}

View File

@ -6,32 +6,32 @@ use React\EventLoop\LoopInterface;
class Tasks
{
/** @var Task[] */
private array $tasks;
private ?LoopInterface $loop = null;
/** @var Task[] */
private array $tasks;
private ?LoopInterface $loop = null;
public function __construct(Task ...$tasks)
{
$this->tasks = $tasks;
}
public function __construct(Task ...$tasks)
{
$this->tasks = $tasks;
}
public function enable(LoopInterface $loop): void
{
if (is_null($this->loop)) {
$this->loop = $loop;
foreach ($this->tasks as $task) {
$task->enable($this->loop);
}
}
}
public function enable(LoopInterface $loop): void
{
if (is_null($this->loop)) {
$this->loop = $loop;
foreach ($this->tasks as $task) {
$task->enable($this->loop);
}
}
}
public function cancel(): void
{
if (!is_null($this->loop)) {
foreach ($this->tasks as $task) {
$task->cancel($this->loop);
}
$this->loop = null;
}
}
public function cancel(): void
{
if (!is_null($this->loop)) {
foreach ($this->tasks as $task) {
$task->cancel($this->loop);
}
$this->loop = null;
}
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace Toalett\Multiprocessing\Tests;
use PHPUnit\Framework\TestCase;
use Toalett\Multiprocessing\ConcurrencyLimit;
use Toalett\Multiprocessing\Exception\InvalidArgumentException;
use Toalett\Multiprocessing\Tests\Tools\PropertyInspector;
class ConcurrencyLimitTest extends TestCase
{
use PropertyInspector;
public function testItDoesNotAcceptZero(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Expected -1 or positive integer, got \'0\'');
ConcurrencyLimit::atMost(0);
}
public function testItAcceptsNegativeOneAsUnlimited(): void
{
$limit = ConcurrencyLimit::atMost(-1);
self::assertTrue($limit->isUnlimited());
}
/**
* @param int $negativeNumber
* @dataProvider negativeValueProvider
*/
public function testItDoesNotAllowAnyOtherNegativeValue(int $negativeNumber): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Expected -1 or positive integer, got \'%s\'', $negativeNumber));
ConcurrencyLimit::atMost($negativeNumber);
}
public function testTheLimitMayBeUnlimited(): void
{
$limit = ConcurrencyLimit::unlimited();
self::assertTrue($limit->isUnlimited());
}
public function testTheLimitMayBeASingleWorker(): void
{
$limit = ConcurrencyLimit::singleWorker();
self::assertFalse($limit->isUnlimited());
self::assertEquals(1, $this->getProperty($limit, 'limit'));
}
public function testAnUnlimitedLimitCanNeverBeReached(): void
{
$limit = ConcurrencyLimit::unlimited();
self::assertFalse($limit->isReachedBy(PHP_INT_MIN));
self::assertFalse($limit->isReachedBy(0));
self::assertFalse($limit->isReachedBy(PHP_INT_MAX));
}
public function testABoundLimitCanBeReached(): void
{
$three = ConcurrencyLimit::atMost(3);
$seven = ConcurrencyLimit::atMost(7);
self::assertTrue($three->isReachedBy(3));
self::assertFalse($three->isReachedBy(2));
self::assertFalse($three->isReachedBy(1));
self::assertTrue($seven->isReachedBy(7));
self::assertTrue($seven->isReachedBy(120));
self::assertFalse($seven->isReachedBy(-2));
}
public function negativeValueProvider(): array
{
return [
'-2' => [-2],
'-3' => [-3],
'-10000' => [-10000],
'PHP_INT_MIN' => [PHP_INT_MIN],
];
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace Toalett\Multiprocessing\Tests;
use PHPUnit\Framework\TestCase;
use Toalett\Multiprocessing\Concurrency;
use Toalett\Multiprocessing\Exception\InvalidArgumentException;
use Toalett\Multiprocessing\Tests\Tools\PropertyInspector;
class ConcurrencyTest extends TestCase
{
use PropertyInspector;
public function testItAcceptsNegativeOneAsUnlimited(): void
{
$concurrency = Concurrency::atMost(-1);
self::assertTrue($concurrency->isUnlimited());
}
public function testItDoesNotAcceptZero(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Expected -1 or positive integer, got \'0\'');
Concurrency::atMost(0);
}
/**
* @param int $negativeNumber
* @dataProvider negativeValueProvider
*/
public function testItDoesNotAllowAnyOtherNegativeValue(int $negativeNumber): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Expected -1 or positive integer, got \'%s\'', $negativeNumber));
Concurrency::atMost($negativeNumber);
}
public function testTheLimitMayBeUnlimited(): void
{
$concurrency = Concurrency::unlimited();
self::assertTrue($concurrency->isUnlimited());
}
public function testTheLimitMayBeASingleWorker(): void
{
$concurrency = Concurrency::singleWorker();
self::assertFalse($concurrency->isUnlimited());
self::assertEquals(1, $this->getProperty($concurrency, 'limit'));
}
public function testAnUnlimitedLimitCanNeverBeReached(): void
{
$concurrency = Concurrency::unlimited();
self::assertFalse($concurrency->isReachedBy(PHP_INT_MIN));
self::assertFalse($concurrency->isReachedBy(0));
self::assertFalse($concurrency->isReachedBy(PHP_INT_MAX));
}
public function testABoundLimitCanBeReached(): void
{
$three = Concurrency::atMost(3);
$seven = Concurrency::atMost(7);
self::assertTrue($three->isReachedBy(3));
self::assertFalse($three->isReachedBy(2));
self::assertFalse($three->isReachedBy(1));
self::assertTrue($seven->isReachedBy(7));
self::assertTrue($seven->isReachedBy(120));
self::assertFalse($seven->isReachedBy(-2));
}
public function negativeValueProvider(): array
{
return [
'-2' => [-2],
'-3' => [-3],
'-10000' => [-10000],
'PHP_INT_MIN' => [PHP_INT_MIN],
];
}
}

View File

@ -4,77 +4,77 @@ namespace Toalett\Multiprocessing\Tests;
use PHPUnit\Framework\TestCase;
use React\EventLoop\LoopInterface;
use Toalett\Multiprocessing\ConcurrencyLimit;
use Toalett\Multiprocessing\Concurrency;
use Toalett\Multiprocessing\ContextBuilder;
use Toalett\Multiprocessing\Tests\Tools\PropertyInspector;
use Toalett\Multiprocessing\Workers;
class ContextBuilderTest extends TestCase
{
use PropertyInspector;
use PropertyInspector;
public function testItIsImmutable(): void
{
$builder = ContextBuilder::create();
$eventLoop = $this->createMock(LoopInterface::class);
$limit = $this->createMock(ConcurrencyLimit::class);
public function testItIsImmutable(): void
{
$builder = ContextBuilder::create();
$eventLoop = $this->createMock(LoopInterface::class);
$concurrency = $this->createMock(Concurrency::class);
self::assertNotSame($builder->withEventLoop($eventLoop), $builder);
self::assertNotSame($builder->withLimit($limit), $builder);
}
self::assertNotSame($builder->withEventLoop($eventLoop), $builder);
self::assertNotSame($builder->withConcurrency($concurrency), $builder);
}
public function testItBuildsANewContextEveryTime(): void
{
$builder = ContextBuilder::create();
public function testItBuildsANewContextEveryTime(): void
{
$builder = ContextBuilder::create();
self::assertNotSame($builder->build(), $builder->build());
}
self::assertNotSame($builder->build(), $builder->build());
}
public function testTheDefaultConcurrencyLimitIsUnlimited(): void
{
$builder = ContextBuilder::create();
public function testTheDefaultConcurrencyIsUnlimited(): void
{
$builder = ContextBuilder::create();
$context = $builder->build();
self::assertIsObject($context);
self::assertInstanceOf(LoopInterface::class, $this->getProperty($context, 'eventLoop'));
$context = $builder->build();
self::assertIsObject($context);
self::assertInstanceOf(LoopInterface::class, $this->getProperty($context, 'eventLoop'));
/** @var ConcurrencyLimit|null $limit */
$limit = $this->getProperty($context, 'limit');
self::assertIsObject($limit);
self::assertInstanceOf(ConcurrencyLimit::class, $limit);
self::assertTrue($limit->isUnlimited());
}
/** @var Concurrency|null $concurrency */
$concurrency = $this->getProperty($context, 'concurrency');
self::assertIsObject($concurrency);
self::assertInstanceOf(Concurrency::class, $concurrency);
self::assertTrue($concurrency->isUnlimited());
}
public function testWhenGivenAnEventLoopItUsesThatLoop(): void
{
$builder = ContextBuilder::create();
$eventLoop = $this->createMock(LoopInterface::class);
public function testWhenGivenAnEventLoopItUsesThatLoop(): void
{
$builder = ContextBuilder::create();
$eventLoop = $this->createMock(LoopInterface::class);
$context = $builder->withEventLoop($eventLoop)->build();
$usedEventLoop = $this->getProperty($context, 'eventLoop');
$context = $builder->withEventLoop($eventLoop)->build();
$usedEventLoop = $this->getProperty($context, 'eventLoop');
self::assertSame($eventLoop, $usedEventLoop);
}
self::assertSame($eventLoop, $usedEventLoop);
}
public function testWhenGivenAConcurrencyLimitItUsesThatLimit(): void
{
$builder = ContextBuilder::create();
$limit = $this->createMock(ConcurrencyLimit::class);
public function testWhenGivenAConcurrencyItUsesThatConcurrency(): void
{
$builder = ContextBuilder::create();
$concurrency = $this->createMock(Concurrency::class);
$context = $builder->withLimit($limit)->build();
$usedLimit = $this->getProperty($context, 'limit');
$context = $builder->withConcurrency($concurrency)->build();
$usedConcurrency = $this->getProperty($context, 'concurrency');
self::assertSame($limit, $usedLimit);
}
self::assertSame($concurrency, $usedConcurrency);
}
public function testWhenGivenWorkersItUsesThatWorkers(): void
{
$builder = ContextBuilder::create();
$workers = $this->createMock(Workers::class);
public function testWhenGivenWorkersItUsesThatWorkers(): void
{
$builder = ContextBuilder::create();
$workers = $this->createMock(Workers::class);
$context = $builder->withWorkers($workers)->build();
$usedWorkers = $this->getProperty($context, 'workers');
$context = $builder->withWorkers($workers)->build();
$usedWorkers = $this->getProperty($context, 'workers');
self::assertSame($workers, $usedWorkers);
}
self::assertSame($workers, $usedWorkers);
}
}

View File

@ -6,108 +6,108 @@ use PHPUnit\Framework\TestCase;
use React\EventLoop\Factory;
use React\EventLoop\LoopInterface;
use React\EventLoop\Timer\Timer;
use Toalett\Multiprocessing\ConcurrencyLimit;
use Toalett\Multiprocessing\Concurrency;
use Toalett\Multiprocessing\Context;
use Toalett\Multiprocessing\Workers;
class ContextTest extends TestCase
{
public function testItEmitsAnEventWhenBooted(): void
{
$limit = $this->createMock(ConcurrencyLimit::class);
$loop = Factory::create();
$context = new Context($loop, $limit);
public function testItEmitsAnEventWhenBooted(): void
{
$concurrency = $this->createMock(Concurrency::class);
$loop = Factory::create();
$context = new Context($loop, $concurrency);
$loop->futureTick(fn() => $context->stop());
$loop->futureTick(fn() => $context->stop());
$bootEventHasTakenPlace = false;
$context->on('booted', function () use (&$bootEventHasTakenPlace) {
$bootEventHasTakenPlace = true;
});
$bootEventHasTakenPlace = false;
$context->on('booted', function () use (&$bootEventHasTakenPlace) {
$bootEventHasTakenPlace = true;
});
self::assertFalse($bootEventHasTakenPlace);
$context->run();
self::assertTrue($bootEventHasTakenPlace);
}
self::assertFalse($bootEventHasTakenPlace);
$context->run();
self::assertTrue($bootEventHasTakenPlace);
}
public function testItEmitsEventsWhenCongestionOccursAndIsRelieved(): void
{
$loop = Factory::create();
$limit = $this->createMock(ConcurrencyLimit::class);
$context = new Context($loop, $limit);
public function testItEmitsEventsWhenCongestionOccursAndIsRelieved(): void
{
$loop = Factory::create();
$concurrency = $this->createMock(Concurrency::class);
$context = new Context($loop, $concurrency);
$limit->method('isReachedBy')->willReturn(true); // trigger congestion
$concurrency->method('isReachedBy')->willReturn(true); // trigger congestion
$congestionEventHasTakenPlace = false;
$context->on('congestion', function () use (&$congestionEventHasTakenPlace) {
$congestionEventHasTakenPlace = true;
});
$congestionEventHasTakenPlace = false;
$context->on('congestion', function () use (&$congestionEventHasTakenPlace) {
$congestionEventHasTakenPlace = true;
});
$congestionRelievedEventHasTakenPlace = false;
$context->on('congestion_relieved', function () use (&$congestionRelievedEventHasTakenPlace) {
$congestionRelievedEventHasTakenPlace = true;
});
$congestionRelievedEventHasTakenPlace = false;
$context->on('congestion_relieved', function () use (&$congestionRelievedEventHasTakenPlace) {
$congestionRelievedEventHasTakenPlace = true;
});
self::assertFalse($congestionEventHasTakenPlace);
self::assertFalse($congestionRelievedEventHasTakenPlace);
self::assertFalse($congestionEventHasTakenPlace);
self::assertFalse($congestionRelievedEventHasTakenPlace);
$loop->futureTick(fn() => $context->stop());
$context->submit(static fn() => null);
$context->run();
$loop->futureTick(fn() => $context->stop());
$context->submit(static fn() => null);
$context->run();
self::assertTrue($congestionEventHasTakenPlace);
self::assertTrue($congestionRelievedEventHasTakenPlace);
}
self::assertTrue($congestionEventHasTakenPlace);
self::assertTrue($congestionRelievedEventHasTakenPlace);
}
public function testItCreatesAWorkerForASubmittedTask(): void
{
$limit = $this->createMock(ConcurrencyLimit::class);
$loop = $this->createMock(LoopInterface::class);
$context = new Context($loop, $limit);
public function testItCreatesAWorkerForASubmittedTask(): void
{
$concurrency = $this->createMock(Concurrency::class);
$loop = $this->createMock(LoopInterface::class);
$context = new Context($loop, $concurrency);
$limit->method('isReachedBy')->willReturn(false);
$loop->expects(self::once())
->method('futureTick')
->withConsecutive([
static fn() => null,
]);
$concurrency->method('isReachedBy')->willReturn(false);
$loop->expects(self::once())
->method('futureTick')
->withConsecutive([
static fn() => null,
]);
$context->submit(static fn() => null);
}
$context->submit(static fn() => null);
}
public function testItRegistersMaintenanceTasksOnTheEventLoop(): void
{
$loop = $this->createMock(LoopInterface::class);
$limit = $this->createMock(ConcurrencyLimit::class);
public function testItRegistersMaintenanceTasksOnTheEventLoop(): void
{
$loop = $this->createMock(LoopInterface::class);
$concurrency = $this->createMock(Concurrency::class);
$loop->expects(self::exactly(2))
->method('addPeriodicTimer')
->withConsecutive(
[Context::INTERVAL_CLEANUP, static fn() => null],
[Context::INTERVAL_GC, static fn() => null]
)->willReturnOnConsecutiveCalls(
new Timer(Context::INTERVAL_CLEANUP, static fn() => null),
new Timer(Context::INTERVAL_GC, static fn() => null),
);
$loop->expects(self::exactly(2))
->method('addPeriodicTimer')
->withConsecutive(
[Context::INTERVAL_CLEANUP, static fn() => null],
[Context::INTERVAL_GC, static fn() => null]
)->willReturnOnConsecutiveCalls(
new Timer(Context::INTERVAL_CLEANUP, static fn() => null),
new Timer(Context::INTERVAL_GC, static fn() => null),
);
$context = new Context($loop, $limit);
$context->run();
}
$context = new Context($loop, $concurrency);
$context->run();
}
public function testItForwardsWorkersEventsToSelf(): void
{
$loop = $this->createMock(LoopInterface::class);
$limit = $this->createMock(ConcurrencyLimit::class);
$workers = $this->createMock(Workers::class);
public function testItForwardsWorkersEventsToSelf(): void
{
$loop = $this->createMock(LoopInterface::class);
$concurrency = $this->createMock(Concurrency::class);
$workers = $this->createMock(Workers::class);
$workers->expects(self::exactly(3))
->method('on')
->withConsecutive(
['worker_started', static fn() => null],
['worker_stopped', static fn() => null],
['no_workers_remaining', static fn() => null]
);
$workers->expects(self::exactly(3))
->method('on')
->withConsecutive(
['worker_started', static fn() => null],
['worker_stopped', static fn() => null],
['no_workers_remaining', static fn() => null]
);
new Context($loop, $limit, $workers);
}
new Context($loop, $concurrency, $workers);
}
}

View File

@ -7,55 +7,55 @@ use Toalett\Multiprocessing\ProcessControl\Fork;
class ForkTest extends TestCase
{
/**
* @param int $pid
* @dataProvider positiveIntegerProvider
*/
public function testItSaysItIsAParentProcessWhenAPositivePidIsProvided(int $pid): void
{
$fork = new Fork($pid);
self::assertTrue($fork->isParent());
self::assertFalse($fork->isChild());
self::assertFalse($fork->failed());
}
/**
* @param int $pid
* @dataProvider positiveIntegerProvider
*/
public function testItSaysItIsAParentProcessWhenAPositivePidIsProvided(int $pid): void
{
$fork = new Fork($pid);
self::assertTrue($fork->isParent());
self::assertFalse($fork->isChild());
self::assertFalse($fork->failed());
}
/**
* @param int $pid
* @dataProvider negativeIntegerProvider
*/
public function testItSaysItFailedWhenANegativePidIsProvided(int $pid): void
{
$fork = new Fork($pid);
self::assertTrue($fork->isParent());
self::assertFalse($fork->isChild());
self::assertTrue($fork->failed());
}
/**
* @param int $pid
* @dataProvider negativeIntegerProvider
*/
public function testItSaysItFailedWhenANegativePidIsProvided(int $pid): void
{
$fork = new Fork($pid);
self::assertTrue($fork->isParent());
self::assertFalse($fork->isChild());
self::assertTrue($fork->failed());
}
public function testItSaysItIsAChildProcessWhenPidZeroIsProvided(): void
{
$fork = new Fork(0);
self::assertFalse($fork->isParent());
self::assertTrue($fork->isChild());
self::assertFalse($fork->failed());
}
public function testItSaysItIsAChildProcessWhenPidZeroIsProvided(): void
{
$fork = new Fork(0);
self::assertFalse($fork->isParent());
self::assertTrue($fork->isChild());
self::assertFalse($fork->failed());
}
public function positiveIntegerProvider(): array
{
return [
[1],
[10],
[1000],
[PHP_INT_MAX],
];
}
public function positiveIntegerProvider(): array
{
return [
[1],
[10],
[1000],
[PHP_INT_MAX],
];
}
public function negativeIntegerProvider(): array
{
return [
[-1],
[-10],
[-1000],
[PHP_INT_MIN],
];
}
public function negativeIntegerProvider(): array
{
return [
[-1],
[-10],
[-1000],
[PHP_INT_MIN],
];
}
}

View File

@ -7,45 +7,45 @@ use Toalett\Multiprocessing\ProcessControl\Wait;
class WaitTest extends TestCase
{
/**
* @param int $pid
* @dataProvider positiveIntegerProvider
*/
public function testItSaysAChildStoppedWhenAPositivePidIsProvided(int $pid): void
{
$wait = new Wait($pid, 0);
self::assertTrue($wait->childStopped());
self::assertFalse($wait->failed());
}
/**
* @param int $pid
* @dataProvider positiveIntegerProvider
*/
public function testItSaysAChildStoppedWhenAPositivePidIsProvided(int $pid): void
{
$wait = new Wait($pid, 0);
self::assertTrue($wait->childStopped());
self::assertFalse($wait->failed());
}
/**
* @param int $pid
* @dataProvider negativeIntegerProvider
*/
public function testItSaysItFailedWhenANegativePidIsProvided(int $pid): void
{
$wait = new Wait($pid, 0);
self::assertFalse($wait->childStopped());
self::assertTrue($wait->failed());
}
/**
* @param int $pid
* @dataProvider negativeIntegerProvider
*/
public function testItSaysItFailedWhenANegativePidIsProvided(int $pid): void
{
$wait = new Wait($pid, 0);
self::assertFalse($wait->childStopped());
self::assertTrue($wait->failed());
}
public function positiveIntegerProvider(): array
{
return [
[1],
[10],
[1000],
[PHP_INT_MAX],
];
}
public function positiveIntegerProvider(): array
{
return [
[1],
[10],
[1000],
[PHP_INT_MAX],
];
}
public function negativeIntegerProvider(): array
{
return [
[-1],
[-10],
[-1000],
[PHP_INT_MIN],
];
}
public function negativeIntegerProvider(): array
{
return [
[-1],
[-10],
[-1000],
[PHP_INT_MIN],
];
}
}

View File

@ -9,50 +9,50 @@ use Toalett\Multiprocessing\Task\Interval;
class IntervalTest extends TestCase
{
/**
* @param $method
* @param $val
* @param $calculatedVal
* @dataProvider zeroAndDownProvider
*/
public function testItDoesNotAllowLessThanZeroOrZero($method, $val, $calculatedVal): void
{
$this->setName(sprintf('It does not allow %d for %s', $val, $method));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Expected positive float, got \'%s\'', $calculatedVal));
Interval::{$method}($val);
}
/**
* @param $method
* @param $val
* @param $calculatedVal
* @dataProvider zeroAndDownProvider
*/
public function testItDoesNotAllowLessThanZeroOrZero($method, $val, $calculatedVal): void
{
$this->setName(sprintf('It does not allow %d for %s', $val, $method));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Expected positive float, got \'%s\'', $calculatedVal));
Interval::{$method}($val);
}
/**
* @param $method
* @param $val
* @param $expected
* @dataProvider oneAndUpProvider
*/
public function testItCalculatesTheCorrectInterval($method, $val, $expected): void
{
$this->setName('It calculates the correct interval in ' . $method);
$interval = Interval::{$method}($val);
/**
* @param $method
* @param $val
* @param $expected
* @dataProvider oneAndUpProvider
*/
public function testItCalculatesTheCorrectInterval($method, $val, $expected): void
{
$this->setName('It calculates the correct interval in ' . $method);
$interval = Interval::{$method}($val);
self::assertEquals($expected, $interval->asFloat());
}
self::assertEquals($expected, $interval->asFloat());
}
public function zeroAndDownProvider(): Generator
{
return $this->createProvider(0, -5, -9000);
}
public function zeroAndDownProvider(): Generator
{
return $this->createProvider(0, -5, -9000);
}
public function oneAndUpProvider(): Generator
{
return $this->createProvider(1, 5, 7500);
}
public function oneAndUpProvider(): Generator
{
return $this->createProvider(1, 5, 7500);
}
public function createProvider(...$args): Generator
{
foreach ($args as $arg) {
yield "$arg seconds" => ['seconds', $arg, $arg];
yield "$arg minutes" => ['minutes', $arg, $arg * 60.0];
yield "$arg hours" => ['hours', $arg, $arg * 3600.0];
}
}
public function createProvider(...$args): Generator
{
foreach ($args as $arg) {
yield "$arg seconds" => ['seconds', $arg, $arg];
yield "$arg minutes" => ['minutes', $arg, $arg * 60.0];
yield "$arg hours" => ['hours', $arg, $arg * 3600.0];
}
}
}

View File

@ -11,27 +11,27 @@ use Toalett\Multiprocessing\Task\RepeatedTask;
class RepeatedTaskTest extends TestCase
{
/**
* @param $interval
* @dataProvider dataProvider
*/
public function testItRegistersWithTheProvidedInterval(Interval $interval): void
{
$loop = $this->createMock(LoopInterface::class);
$loop->expects(self::once())
->method('addPeriodicTimer')
->with($interval->asFloat(), static fn() => null)
->willReturn(new Timer($interval->asFloat(), static fn() => null, true));
/**
* @param $interval
* @dataProvider dataProvider
*/
public function testItRegistersWithTheProvidedInterval(Interval $interval): void
{
$loop = $this->createMock(LoopInterface::class);
$loop->expects(self::once())
->method('addPeriodicTimer')
->with($interval->asFloat(), static fn() => null)
->willReturn(new Timer($interval->asFloat(), static fn() => null, true));
$task = new RepeatedTask($interval, static fn() => null);
$task->enable($loop);
}
$task = new RepeatedTask($interval, static fn() => null);
$task->enable($loop);
}
public function dataProvider(): Generator
{
yield "3 seconds" => [Interval::seconds(3)];
yield "5 minutes" => [Interval::minutes(5)];
yield "half an hour" => [Interval::hours(0.5)];
yield "a day" => [Interval::hours(24)];
}
public function dataProvider(): Generator
{
yield "3 seconds" => [Interval::seconds(3)];
yield "5 minutes" => [Interval::minutes(5)];
yield "half an hour" => [Interval::hours(0.5)];
yield "a day" => [Interval::hours(24)];
}
}

View File

@ -3,89 +3,89 @@
namespace Toalett\Multiprocessing\Tests\Task;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use React\EventLoop\LoopInterface;
use Toalett\Multiprocessing\Task\Task;
use Toalett\Multiprocessing\Task\Tasks;
use PHPUnit\Framework\TestCase;
use Toalett\Multiprocessing\Tests\Tools\PropertyInspector;
class TasksTest extends TestCase
{
use PropertyInspector;
use PropertyInspector;
public function testItAcceptsZeroTasks(): void
{
$this->expectNotToPerformAssertions();
new Tasks();
}
public function testItAcceptsZeroTasks(): void
{
$this->expectNotToPerformAssertions();
new Tasks();
}
public function testItAcceptsMultipleTasks(): void
{
$this->expectNotToPerformAssertions();
new Tasks(
$this->createMock(Task::class),
$this->createMock(Task::class)
);
}
public function testItAcceptsMultipleTasks(): void
{
$this->expectNotToPerformAssertions();
new Tasks(
$this->createMock(Task::class),
$this->createMock(Task::class)
);
}
public function testItDoesNotReEnableWhenEnabled(): void
{
$loop = $this->createMock(LoopInterface::class);
$task = $this->createMock(Task::class);
$tasks = new Tasks($task);
public function testItDoesNotReEnableWhenEnabled(): void
{
$loop = $this->createMock(LoopInterface::class);
$task = $this->createMock(Task::class);
$tasks = new Tasks($task);
$task->expects(self::once())
->method('enable')
->with($loop);
$task->expects(self::once())
->method('enable')
->with($loop);
$tasks->enable($loop);
$tasks->enable($loop);
}
$tasks->enable($loop);
$tasks->enable($loop);
}
public function testItEnablesAllTasksWhenEnableCalled(): void
{
$loop = $this->createMock(LoopInterface::class);
$task1 = $this->createMock(Task::class);
$task2 = $this->createMock(Task::class);
$task3 = $this->createMock(Task::class);
public function testItEnablesAllTasksWhenEnableCalled(): void
{
$loop = $this->createMock(LoopInterface::class);
$task1 = $this->createMock(Task::class);
$task2 = $this->createMock(Task::class);
$task3 = $this->createMock(Task::class);
foreach([$task1, $task2, $task3] as $task) {
/** @var MockObject|Task $task */
$task->expects(self::once())->method('enable')->with($loop);
}
foreach ([$task1, $task2, $task3] as $task) {
/** @var MockObject|Task $task */
$task->expects(self::once())->method('enable')->with($loop);
}
(new Tasks($task1, $task2, $task3))->enable($loop);
}
(new Tasks($task1, $task2, $task3))->enable($loop);
}
public function testItCancelsAllTasksWhenCancelCalled(): void
{
$loop = $this->createMock(LoopInterface::class);
$task1 = $this->createMock(Task::class);
$task2 = $this->createMock(Task::class);
$task3 = $this->createMock(Task::class);
public function testItCancelsAllTasksWhenCancelCalled(): void
{
$loop = $this->createMock(LoopInterface::class);
$task1 = $this->createMock(Task::class);
$task2 = $this->createMock(Task::class);
$task3 = $this->createMock(Task::class);
foreach([$task1, $task2, $task3] as $task) {
/** @var MockObject|Task $task */
$task->expects(self::once())->method('cancel')->with($loop);
}
foreach ([$task1, $task2, $task3] as $task) {
/** @var MockObject|Task $task */
$task->expects(self::once())->method('cancel')->with($loop);
}
$tasks = new Tasks($task1, $task2, $task3);
$this->setProperty($tasks, 'loop', $loop);
$tasks->cancel();
}
$tasks = new Tasks($task1, $task2, $task3);
$this->setProperty($tasks, 'loop', $loop);
$tasks->cancel();
}
public function testItDoesNotCancelTasksWhenTheyAreNotEnabled(): void
{
$task1 = $this->createMock(Task::class);
$task2 = $this->createMock(Task::class);
$task3 = $this->createMock(Task::class);
public function testItDoesNotCancelTasksWhenTheyAreNotEnabled(): void
{
$task1 = $this->createMock(Task::class);
$task2 = $this->createMock(Task::class);
$task3 = $this->createMock(Task::class);
foreach([$task1, $task2, $task3] as $task) {
/** @var MockObject|Task $task */
$task->expects(self::never())->method('cancel');
}
foreach ([$task1, $task2, $task3] as $task) {
/** @var MockObject|Task $task */
$task->expects(self::never())->method('cancel');
}
$tasks = new Tasks($task1, $task2, $task3);
$tasks->cancel();
}
$tasks = new Tasks($task1, $task2, $task3);
$tasks->cancel();
}
}

View File

@ -6,19 +6,19 @@ use ReflectionObject;
trait PropertyInspector
{
protected function getProperty(object $object, string $propertyName)
{
$reflector = new ReflectionObject($object);
$property = $reflector->getProperty($propertyName);
$property->setAccessible(true);
return $property->getValue($object);
}
protected function getProperty(object $object, string $propertyName)
{
$reflector = new ReflectionObject($object);
$property = $reflector->getProperty($propertyName);
$property->setAccessible(true);
return $property->getValue($object);
}
protected function setProperty(object $object, string $propertyName, $value): void
{
$reflector = new ReflectionObject($object);
$property = $reflector->getProperty($propertyName);
$property->setAccessible(true);
$property->setValue($object, $value);
}
protected function setProperty(object $object, string $propertyName, $value): void
{
$reflector = new ReflectionObject($object);
$property = $reflector->getProperty($propertyName);
$property->setAccessible(true);
$property->setValue($object, $value);
}
}

View File

@ -11,113 +11,113 @@ use Toalett\Multiprocessing\Workers;
class WorkersTest extends TestCase
{
public function testItSaysItIsEmptyWhenNoWorkers(): void
{
$processControl = $this->createMock(ProcessControl::class);
$workers = new Workers($processControl);
self::assertEmpty($workers);
}
public function testItSaysItIsEmptyWhenNoWorkers(): void
{
$processControl = $this->createMock(ProcessControl::class);
$workers = new Workers($processControl);
self::assertEmpty($workers);
}
public function testItSaysItHasOneWorkerWhenTaskExecutes(): void
{
$workers = new Workers();
public function testItSaysItHasOneWorkerWhenTaskExecutes(): void
{
$workers = new Workers();
$workers->createWorkerFor(fn() => exit(0), []);
self::assertCount(1, $workers);
}
$workers->createWorkerFor(fn() => exit(0), []);
self::assertCount(1, $workers);
}
public function testItGivesTheAmountOfActiveWorkersOnCount(): void
{
$workers = new Workers();
public function testItGivesTheAmountOfActiveWorkersOnCount(): void
{
$workers = new Workers();
$workers->createWorkerFor(fn() => exit(0), []);
$workers->createWorkerFor(fn() => exit(0), []);
self::assertCount(2, $workers);
$workers->createWorkerFor(fn() => exit(0), []);
$workers->createWorkerFor(fn() => exit(0), []);
self::assertCount(2, $workers);
$workers->createWorkerFor(fn() => exit(0), []);
self::assertCount(3, $workers);
$workers->createWorkerFor(fn() => exit(0), []);
self::assertCount(3, $workers);
$workers->stop();
self::assertEmpty($workers);
}
$workers->stop();
self::assertEmpty($workers);
}
public function testItEmitsAnEventWhenAWorkerIsStarted(): void
{
$workers = new Workers();
public function testItEmitsAnEventWhenAWorkerIsStarted(): void
{
$workers = new Workers();
$workerStartedEventHasTakenPlace = false;
$workers->on('worker_started', function () use (&$workerStartedEventHasTakenPlace) {
$workerStartedEventHasTakenPlace = true;
});
$workerStartedEventHasTakenPlace = false;
$workers->on('worker_started', function () use (&$workerStartedEventHasTakenPlace) {
$workerStartedEventHasTakenPlace = true;
});
self::assertFalse($workerStartedEventHasTakenPlace);
$workers->createWorkerFor(fn() => exit(0), []);
self::assertTrue($workerStartedEventHasTakenPlace);
}
self::assertFalse($workerStartedEventHasTakenPlace);
$workers->createWorkerFor(fn() => exit(0), []);
self::assertTrue($workerStartedEventHasTakenPlace);
}
public function testItEmitsAnEventWhenAWorkerIsRemoved(): void
{
$workers = new Workers();
$reflector = new ReflectionObject($workers);
$method = $reflector->getMethod('remove');
$method->setAccessible(true);
public function testItEmitsAnEventWhenAWorkerIsRemoved(): void
{
$workers = new Workers();
$reflector = new ReflectionObject($workers);
$method = $reflector->getMethod('remove');
$method->setAccessible(true);
$workerStoppedEventHasTakenPlace = false;
$workers->on('worker_stopped', function () use (&$workerStoppedEventHasTakenPlace) {
$workerStoppedEventHasTakenPlace = true;
});
$workerStoppedEventHasTakenPlace = false;
$workers->on('worker_stopped', function () use (&$workerStoppedEventHasTakenPlace) {
$workerStoppedEventHasTakenPlace = true;
});
self::assertFalse($workerStoppedEventHasTakenPlace);
$method->invoke($workers, 0);
self::assertTrue($workerStoppedEventHasTakenPlace);
}
self::assertFalse($workerStoppedEventHasTakenPlace);
$method->invoke($workers, 0);
self::assertTrue($workerStoppedEventHasTakenPlace);
}
public function testItEmitsAnEventWhenNoWorkersRemain(): void
{
$workers = new Workers();
public function testItEmitsAnEventWhenNoWorkersRemain(): void
{
$workers = new Workers();
$noWorkersRemainingEventHasTakenPlace = false;
$workers->on('no_workers_remaining', function () use (&$noWorkersRemainingEventHasTakenPlace) {
$noWorkersRemainingEventHasTakenPlace = true;
});
$noWorkersRemainingEventHasTakenPlace = false;
$workers->on('no_workers_remaining', function () use (&$noWorkersRemainingEventHasTakenPlace) {
$noWorkersRemainingEventHasTakenPlace = true;
});
self::assertFalse($noWorkersRemainingEventHasTakenPlace);
$workers->cleanup();
self::assertTrue($noWorkersRemainingEventHasTakenPlace);
}
self::assertFalse($noWorkersRemainingEventHasTakenPlace);
$workers->cleanup();
self::assertTrue($noWorkersRemainingEventHasTakenPlace);
}
public function testItCallsForkOnProcessControlWhenAskedToCreateAWorker(): void
{
$processControl = $this->createMock(ProcessControl::class);
$processControl->expects(self::once())
->method('fork')
->willReturn(new Fork(1));
public function testItCallsForkOnProcessControlWhenAskedToCreateAWorker(): void
{
$processControl = $this->createMock(ProcessControl::class);
$processControl->expects(self::once())
->method('fork')
->willReturn(new Fork(1));
$workers = new Workers($processControl);
$workers->createWorkerFor(fn() => []);
}
$workers = new Workers($processControl);
$workers->createWorkerFor(fn() => []);
}
public function testItCallsNonBlockingWaitOnProcessControlWhenPerformingCleanup(): void
{
$processControl = $this->createMock(ProcessControl::class);
$processControl->expects(self::once())
->method('wait')
->with(Wait::NO_HANG)
->willReturn(new Wait(0));
public function testItCallsNonBlockingWaitOnProcessControlWhenPerformingCleanup(): void
{
$processControl = $this->createMock(ProcessControl::class);
$processControl->expects(self::once())
->method('wait')
->with(Wait::NO_HANG)
->willReturn(new Wait(0));
$workers = new Workers($processControl);
$workers->cleanup();
}
$workers = new Workers($processControl);
$workers->cleanup();
}
public function testItCallsBlockingWaitOnProcessControlWhenAwaitingCongestionRelief(): void
{
$processControl = $this->createMock(ProcessControl::class);
$processControl->expects(self::once())
->method('wait')
->with(/* no arguments */)
->willReturn(new Wait(1));
public function testItCallsBlockingWaitOnProcessControlWhenAwaitingCongestionRelief(): void
{
$processControl = $this->createMock(ProcessControl::class);
$processControl->expects(self::once())
->method('wait')
->with(/* no arguments */)
->willReturn(new Wait(1));
$workers = new Workers($processControl);
$workers->awaitCongestionRelief();
}
$workers = new Workers($processControl);
$workers->awaitCongestionRelief();
}
}

View File

@ -13,86 +13,86 @@ use Toalett\Multiprocessing\ProcessControl\Wait;
class Workers implements Countable, EventEmitterInterface
{
use EventEmitterTrait;
use EventEmitterTrait;
/** @var int[] */
private array $workers = [];
private ProcessControl $processControl;
/** @var int[] */
private array $workers = [];
private ProcessControl $processControl;
public function __construct(?ProcessControl $processControl = null)
{
$this->processControl = $processControl ?? new PCNTL();
}
public function __construct(?ProcessControl $processControl = null)
{
$this->processControl = $processControl ?? new PCNTL();
}
public function count(): int
{
return count($this->workers);
}
public function count(): int
{
return count($this->workers);
}
public function createWorkerFor(callable $task, array $args = []): void
{
$pid = $this->forkWorker($task, $args);
$this->workers[$pid] = $pid;
$this->emit('worker_started', [$pid]);
}
public function createWorkerFor(callable $task, array $args = []): void
{
$pid = $this->forkWorker($task, $args);
$this->workers[$pid] = $pid;
$this->emit('worker_started', [$pid]);
}
public function cleanup(): void
{
while (true === $this->wait(Wait::NO_HANG)) ;
if (0 === count($this)) {
$this->emit('no_workers_remaining');
}
}
public function cleanup(): void
{
while (true === $this->wait(Wait::NO_HANG)) ;
if (0 === count($this)) {
$this->emit('no_workers_remaining');
}
}
public function awaitCongestionRelief(): void
{
$this->wait();
}
public function awaitCongestionRelief(): void
{
$this->wait();
}
private function remove(int $pid): void
{
unset($this->workers[$pid]);
$this->emit('worker_stopped', [$pid]);
}
private function remove(int $pid): void
{
unset($this->workers[$pid]);
$this->emit('worker_stopped', [$pid]);
}
private function forkWorker(callable $task, array $args): int
{
$fork = $this->processControl->fork();
if ($fork->failed()) {
throw ProcessControlException::forkFailed();
}
private function forkWorker(callable $task, array $args): int
{
$fork = $this->processControl->fork();
if ($fork->failed()) {
throw ProcessControlException::forkFailed();
}
if ($fork->isChild()) {
try {
call_user_func_array($task, $args);
} catch (Throwable $t) {
fwrite(STDERR, $t->getMessage());
exit(1);
}
exit(0);
}
if ($fork->isChild()) {
try {
call_user_func_array($task, $args);
} catch (Throwable $t) {
fwrite(STDERR, $t->getMessage());
exit(1);
}
exit(0);
}
return $fork->pid;
}
return $fork->pid;
}
/**
* @param int $options
* @return bool Whether a process was caught
*/
private function wait(int $options = 0): bool
{
$wait = $this->processControl->wait($options);
if ($wait->childStopped()) {
$this->remove($wait->pid);
return true;
}
// We ignore errors ($pid < 0). This method is called periodically, even if there is
// no child available. pcntl_wait() will return -1. This is expected behavior.
return false;
}
/**
* @param int $options
* @return bool Whether a process was caught
*/
private function wait(int $options = 0): bool
{
$wait = $this->processControl->wait($options);
if ($wait->childStopped()) {
$this->remove($wait->pid);
return true;
}
// We ignore errors ($pid < 0). This method is called periodically, even if there is
// no child available. pcntl_wait() will return -1. This is expected behavior.
return false;
}
public function stop(): void
{
while (true === $this->wait()) ;
}
public function stop(): void
{
while (true === $this->wait()) ;
}
}