Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]


### Added
- optional $tags argument in TaskTreeBuilder
- immutable setters in TaskNode
- optional TaskNodeFactory
- pattern filtering can exclude dependencies (PatternTaskNodeFactory/TaskTreeBuilder flag + `--no-deps` CLI option)

## [1.0.0] - 2025-11-07
### Added
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ To limit which tasks run, pass a pattern as the first argument. Wildcards work t
php bin/console app:tasks cache-* # run only tasks whose ID or tag matches "cache-*"
```

By default, Multitron will also include transitive dependencies of the matched tasks so the resulting execution graph stays valid. If you want to run only the matched tasks (and ignore dependencies outside the selection), use `--no-deps`:

```bash
php bin/console app:tasks cache-* --no-deps
```

You can combine multiple patterns by separating them with commas. The filter applies
to both task IDs and tags and is an OR pattern.

Expand Down Expand Up @@ -318,4 +324,3 @@ Your feedback, issues, and contributions are highly encouraged. Open a GitHub is
## License

Multitron is MIT licensed. See the [LICENSE](LICENSE) file for full details.

6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
],
"require": {
"php": "^8.2",
"symfony/console": "^7.2",
"symfony/console": "^7.2|^8.0",
"riki137/stream-ipc": "^1.0",
"psr/container": "^2.0",
"psr/container-implementation": "*"
Expand All @@ -50,10 +50,10 @@
"psr/log": "^3.0",
"nette/di": "^3.2",
"phpstan/phpstan": "^2.1",
"contributte/psr11-container-interface": "^0.6.0",
"contributte/psr11-container-interface": "^0.7.0",
"phpunit/phpunit": "^11.5",
"squizlabs/php_codesniffer": "^3.13|^4.0",
"symfony/dependency-injection": "^7.3",
"symfony/dependency-injection": "^7.3|^8.0",
"illuminate/support": "^12",
"illuminate/container": "^12",
"pimple/pimple": "^3.5"
Expand Down
16 changes: 15 additions & 1 deletion src/Bridge/Native/MultitronFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Multitron\Orchestrator\Output\ProgressOutputFactory;
use Multitron\Orchestrator\Output\TableOutputFactory;
use Multitron\Orchestrator\TaskOrchestrator;
use Multitron\Tree\TaskNodeFactory;
use Multitron\Tree\TaskTreeBuilderFactory;
use Psr\Container\ContainerInterface;
use StreamIpc\NativeIpcPeer;
Expand Down Expand Up @@ -49,6 +50,8 @@ final class MultitronFactory

private ?TaskTreeBuilderFactory $taskTreeBuilderFactory = null;

private ?TaskNodeFactory $taskNodeFactory = null;

public function __construct(private readonly ?ContainerInterface $container)
{
}
Expand Down Expand Up @@ -92,7 +95,7 @@ public function setTaskCommandDeps(?TaskCommandDeps $taskCommandDeps): self

public function getTaskTreeBuilderFactory(): TaskTreeBuilderFactory
{
return $this->taskTreeBuilderFactory ??= new TaskTreeBuilderFactory($this->container);
return $this->taskTreeBuilderFactory ??= new TaskTreeBuilderFactory($this->container, $this->getTaskNodeFactory());
}

public function setTaskTreeBuilderFactory(?TaskTreeBuilderFactory $taskTreeBuilderFactory): self
Expand All @@ -101,6 +104,17 @@ public function setTaskTreeBuilderFactory(?TaskTreeBuilderFactory $taskTreeBuild
return $this;
}

public function getTaskNodeFactory(): ?TaskNodeFactory
{
return $this->taskNodeFactory;
}

public function setTaskNodeFactory(?TaskNodeFactory $taskNodeFactory): self
{
$this->taskNodeFactory = $taskNodeFactory;
return $this;
}

public function getTaskOrchestrator(): TaskOrchestrator
{
return $this->taskOrchestrator ??= new TaskOrchestrator(
Expand Down
5 changes: 5 additions & 0 deletions src/Bridge/Native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ $app->run();
```bash
./bin/multitron.php app:tasks Test%
```
By default, Multitron includes transitive dependencies of the matched tasks. Use `--no-deps` to run only the matched tasks:

```bash
./bin/multitron.php app:tasks Test% --no-deps
```
* **Limit concurrency**

```bash
Expand Down
17 changes: 16 additions & 1 deletion src/Console/TaskCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

abstract class TaskCommand extends Command
{
public const OPTION_DEPS = 'deps';

private readonly TaskTreeBuilderFactory $builderFactory;

private readonly TaskOrchestrator $orchestrator;
Expand Down Expand Up @@ -52,6 +54,13 @@ protected function configure(): void
InputArgument::OPTIONAL,
'fnmatch() pattern to filter tasks. You can optionally use % instead of * for wildcards. Works for groups too.'
);
$this->addOption(
self::OPTION_DEPS,
null,
InputOption::VALUE_NEGATABLE,
'Include transitive dependencies when filtering by pattern (use --no-deps to run only matched tasks)',
true
);
$this->addOption(TaskOrchestrator::OPTION_CONCURRENCY, 'c', InputOption::VALUE_REQUIRED, 'Max concurrent tasks executed', $this->defaultConcurrency);
$this->addOption(TaskOrchestrator::OPTION_UPDATE_INTERVAL, 'u', InputOption::VALUE_REQUIRED, 'Update interval in seconds', TaskOrchestrator::DEFAULT_UPDATE_INTERVAL);
$this->addOption(TaskOrchestrator::OPTION_MEMORY_LIMIT, 'm', InputOption::VALUE_REQUIRED, 'PHP memory_limit for all processes', TaskOrchestrator::DEFAULT_MEMORY_LIMIT);
Expand Down Expand Up @@ -79,7 +88,13 @@ final public function getTaskList(?InputInterface $input = null): TaskList
$builder = $this->builderFactory->create();
$pattern = $input?->getArgument('pattern');
if (is_string($pattern) && trim($pattern) !== '') {
$node = $builder->patternFilter('root', $pattern, $this->getNodes($builder));
$includeDeps = (bool)($input?->getOption(self::OPTION_DEPS) ?? true);
$node = $builder->patternFilter(
'root',
$pattern,
$this->getNodes($builder),
includeDependencies: $includeDeps
);
} else {
$node = new TaskNode('root', null, $this->getNodes($builder));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public function __construct(private readonly ResponsePromise $promise)
* Block until the read request completes and return the full set of
* retrieved values keyed by cache identifier.
*
* @return array<string, mixed>
* @return array<int|string, mixed>
*/
public function await(): array
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function doRead(array &$storage): MasterCacheReadResponse
*
* @param array<int|string, mixed> $storage data source to read from
* @param array<int|string, mixed> $keysSpec description of keys to fetch
* @return array<string, mixed> fetched data
* @return array<int|string, mixed> fetched data
*/
private function fetchKeys(array &$storage, array $keysSpec): array
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
final readonly class MasterCacheReadResponse implements Message
{
/**
* @param array<string, mixed> $data
* @param array<int|string, mixed> $data
*/
public function __construct(public array $data)
{
Expand Down
59 changes: 47 additions & 12 deletions src/Tree/PatternTaskNodeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,31 @@
class PatternTaskNodeFactory
{
/**
* @param TaskNode[] $children
* @param string $id The ID of the task node.
* @param string $pattern Comma-separated list of fnmatch patterns to match task IDs or tags
* @param TaskNode[] $children Child task nodes to filter.
* @param string[] $tags Tags associated with this filter node.
* @param bool $includeDependencies When true (default), include transitive dependencies of matched tasks.
* When false, only matched tasks are returned and dependency edges pointing
* outside the matched set are removed.
*/
public static function create(
string $id,
string $pattern,
array $children = [],
array $tags = [],
bool $includeDependencies = true,
): TaskNode {
$patterns = array_map(fn($p) => strtr($p, ['%' => '*']), explode(',', $pattern));
$patterns = array_values(array_filter(array_map(
static fn(string $p): string => strtr(trim($p), ['%' => '*']),
explode(',', $pattern)
), static fn(string $p): bool => $p !== ''));

return new TaskNode(
$id,
children: $children,
postProcess: function (array $tasks) use ($patterns): iterable {
tags: $tags,
postProcess: function (array $tasks) use ($patterns, $includeDependencies): iterable {
$selected = [];

foreach ($tasks as $task) {
Expand All @@ -27,26 +40,48 @@ public static function create(
}
}

$queue = array_values($selected);
while ($queue) {
$current = array_pop($queue);
foreach ($current->dependencies as $dep) {
if (!isset($selected[$dep]) && isset($tasks[$dep])) {
$selected[$dep] = $tasks[$dep];
$queue[] = $tasks[$dep];
if ($includeDependencies) {
$queue = array_values($selected);
while ($queue) {
$current = array_pop($queue);
foreach ($current->dependencies as $dep) {
if (!isset($selected[$dep]) && isset($tasks[$dep])) {
$selected[$dep] = $tasks[$dep];
$queue[] = $tasks[$dep];
}
}
}

foreach ($selected as $task) {
yield $task;
}

return;
}

foreach ($selected as $task) {
yield $task;
$filteredDeps = array_values(array_filter(
$task->dependencies,
static fn(string $depId): bool => isset($selected[$depId])
));

yield new CompiledTaskNode(
id: $task->id,
factory: $task->factory,
dependencies: $filteredDeps,
tags: $task->tags
);
}

return;
}
);
}

/**
* @param string[] $patterns
* Check if a task matches any of the given patterns by ID or tags.
* @param CompiledTaskNode $task The task to check.
* @param string[] $patterns Array of fnmatch patterns
*/
private static function matches(CompiledTaskNode $task, array $patterns): bool
{
Expand Down
110 changes: 104 additions & 6 deletions src/Tree/TaskNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
use Closure;
use Multitron\Execution\Task;

/**
* Represents a node in the task tree before compilation.
*/
final readonly class TaskNode
{
/**
* @internal
* @param string $id
* @param ?Closure(): Task $factory
* @param TaskNode[] $children
* @param array<TaskNode|string> $dependencies array of TaskNode IDs or TaskNode objects that this node depends on
* @param string[] $tags Tags can be used for post-processing, for example, to run only tasks with a specific tag.
* @param ?Closure(CompiledTaskNode[] $tasks): iterable<CompiledTaskNode> $postProcess This can be used for filtering, for example.
* @param ?Closure(): Task $factory Creates the task instance.
* @param array<TaskNode> $children Child task nodes.
* @param array<TaskNode|string> $dependencies IDs or TaskNode objects this node depends on.
* @param array<string> $tags Tags used for filtering or post-processing.
* @param ?Closure(array<CompiledTaskNode> $tasks): iterable<CompiledTaskNode> $postProcess Custom hook for post-processing compiled tasks.
*/
public function __construct(
public string $id,
Expand All @@ -27,4 +29,100 @@ public function __construct(
public ?Closure $postProcess = null,
) {
}

/**
* Returns a new instance with the specified properties replaced.
*
* @param array<TaskNode>|null $children
* @param array<TaskNode|string>|null $dependencies
* @param array<string>|null $tags
*/
public function with(
?Closure $factory = null,
?array $children = null,
?array $dependencies = null,
?array $tags = null,
?Closure $postProcess = null,
): self {
return new TaskNode(
id: $this->id,
factory: $factory ?? $this->factory,
children: $children ?? $this->children,
dependencies: $dependencies ?? $this->dependencies,
tags: $tags ?? $this->tags,
postProcess: $postProcess ?? $this->postProcess,
);
}

/**
* Returns a new instance with additional children, dependencies, and tags.
*
* @param array<TaskNode> $children
* @param array<TaskNode|string> $dependencies
* @param array<string> $tags
*/
public function withAdded(
array $children = [],
array $dependencies = [],
array $tags = [],
): self {
return $this->with(
children: array_merge($this->children, $children),
dependencies: array_merge($this->dependencies, $dependencies),
tags: array_merge($this->tags, $tags),
);
}

/**
* Returns a new instance with the specified properties removed or reset.
*
* @param bool|array<TaskNode> $children Pass true to clear, or an array of nodes to remove.
* @param bool|array<TaskNode|string> $dependencies Pass true to clear, or an array of items to remove.
* @param bool|array<string> $tags Pass true to clear, or an array of tags to remove.
*/
public function without(
bool $factory = false,
bool|array $children = false,
bool|array $dependencies = false,
bool|array $tags = false,
bool $postProcess = false,
): self {
return new TaskNode(
id: $this->id,
factory: $factory ? null : $this->factory,
children: $this->filterProperty($children, $this->children),
dependencies: $this->filterProperty($dependencies, $this->dependencies),
tags: $this->filterProperty($tags, $this->tags),
postProcess: $postProcess ? null : $this->postProcess,
);
}

/**
* Internal helper to handle the removal or clearing of collection properties.
*
* @template T
* @param bool|array<T> $property
* @param array<T> $current
* @return array<T>
*/
private function filterProperty(bool|array $property, array $current): array
{
if ($property === false) {
return $current;
}

if (is_array($property)) {
return array_values(array_filter($current, fn($item) => !in_array($item, $property, true)));
}

return [];
}

/**
* Whether the node has no children.
*/
public function isLeaf(): bool
{
return [] === $this->children;
}
}
Loading