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
11 changes: 6 additions & 5 deletions docs/package-extension-seams-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ One important part of this plan has now started landing:

- constructor parameter attributes can provide their own dependency value through a generic `resolveParameterValue()` seam
- `core` no longer hardcodes `#[InjectRepository]`
- queue injection is now using the same seam too
- queue injection now uses the registered resolver seam and the application `QueueFactory`
- `core` now also exposes `ParameterResolverInterface`
- modules can now configure injector extensions before provider resolution begins
- `OrmModule` uses that module-level seam to register repository resolution without teaching `core` about ORM classes
Expand All @@ -22,7 +22,8 @@ Today, some package behavior leaks into `core`.
The clearest example is constructor parameter resolution:

- `orm` currently depends on `core` knowing about `#[InjectRepository]`
- queue support currently depends on `core` knowing about `#[InjectQueue]`

Queue injection is a framework-owned facility and now resolves its metadata through the same generic resolver registry used by package extensions.

That works in the short term, but it creates a scaling problem:

Expand Down Expand Up @@ -188,7 +189,7 @@ The context would carry things like:
### How packages would use it

- `orm` ships a resolver for `#[InjectRepository]`
- queue support ships a resolver for `#[InjectQueue]`
- core queue support registers a resolver for `#[InjectQueue]`
- future packages can ship resolvers for their own attributes

`core` only loops through registered resolvers. It does not need to recognize each package decorator by name.
Expand Down Expand Up @@ -325,12 +326,12 @@ Add to `core`:

Keep existing hardcoded branches temporarily so current apps do not break.

### Phase 3: move package logic out of `core`
### Phase 3: move specialized logic behind resolvers

Move:

- `InjectRepository` handling into `orm`
- `InjectQueue` handling into queue support
- `InjectQueue` handling into the dedicated core queue resolver and `QueueFactory` (complete)

This should happen behind compatibility shims first.

Expand Down
38 changes: 26 additions & 12 deletions docs/queues-and-background-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,30 @@ That is a very Assegai-style pattern:
- the service stays injectable
- the queue connection is resolved through attributes and configuration

Queue resolution does not contact the broker. Core keeps one configured queue instance per named connection in the application graph, while the transport opens its connection only when `add()`, `process()`, or another broker operation first needs it. An unavailable queue backend therefore does not prevent the HTTP application from booting; the operation that needs the backend reports the connection failure.

When an application needs to choose a connection programmatically, inject `QueueFactory`:

```php
<?php

use Assegai\Core\Attributes\Injectable;
use Assegai\Core\Queues\QueueFactory;

#[Injectable]
final readonly class TenantQueueRouter
{
public function __construct(private QueueFactory $queues)
{
}

public function dispatch(object $job): void
{
$this->queues->connection('rabbitmq.notifications')->add($job);
}
}
```

## Use a controller to hand off work quickly

```php
Expand Down Expand Up @@ -290,21 +314,11 @@ If you do not want to use the CLI worker yet, you can still run a simple PHP scr

declare(strict_types=1);

use Assegai\Common\Interfaces\Queues\QueueInterface;
use Assegai\Core\Queues\QueueFactory;

require __DIR__ . '/../vendor/autoload.php';

$driverClass = config('queues.drivers.beanstalk');
$queueConfig = config('queues.connections.beanstalk.notifications');

if (!is_string($driverClass) || !class_exists($driverClass) || !is_array($queueConfig)) {
throw new RuntimeException('Queue configuration for beanstalk.notifications is missing.');
}

$queueConfig['name'] ??= 'notifications';

/** @var QueueInterface $queue */
$queue = $driverClass::create($queueConfig);
$queue = (new QueueFactory())->connection('beanstalk.notifications');

while (true) {
$result = $queue->process(function (object $job): void {
Expand Down
24 changes: 24 additions & 0 deletions docs/releases/0.10.1-release-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# AssegaiPHP Core 0.10.1

Core 0.10.1 decouples application bootstrap from queue backend availability.

## What changed

- adds an injectable `QueueFactory` for resolving configured `driver.connection` queue names programmatically
- keeps one queue instance per named connection for the lifetime of the application graph
- resolves `#[InjectQueue]` through the container's parameter-resolver seam instead of performing work inside the attribute constructor
- keeps queue attributes as metadata and delegates driver validation and creation to the named factory
- retains the application queue factory across request-scope refreshes in reusable runtimes
- documents that queue drivers must defer broker I/O until an operation needs the transport

## Compatibility

- requires `assegaiphp/common:^0.10.0`
- lazy broker availability requires `assegaiphp/rabbitmq:^1.1.2` or `assegaiphp/beanstalkd:^1.1.2`
- existing `#[InjectQueue('driver.connection')] QueueInterface` constructor injection remains source compatible

## Upgrade notes

Applications can continue using `#[InjectQueue]` without changes. Code that manually reads `queues.php` and calls a driver's static `create()` method can instead inject `QueueFactory` and call `connection()`.

Queue connection failures now occur at the first broker-backed operation rather than while the application graph is constructed. Handle enqueue failures at the domain boundary where the application can decide whether to return an error, persist an outbox record, or apply another explicit fallback.
10 changes: 0 additions & 10 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1222,16 +1222,6 @@ parameters:
count: 1
path: src/Pipes/ValidationPipe.php

-
message: "#^Method Assegai\\\\Core\\\\Queues\\\\Attributes\\\\InjectQueue\\:\\:resolveParameterValue\\(\\) return type with generic interface Assegai\\\\Common\\\\Interfaces\\\\Queues\\\\QueueInterface does not specify its types\\: T$#"
count: 1
path: src/Queues/Attributes/InjectQueue.php

-
message: "#^Property Assegai\\\\Core\\\\Queues\\\\Attributes\\\\InjectQueue\\:\\:\\$queue with generic interface Assegai\\\\Common\\\\Interfaces\\\\Queues\\\\QueueInterface does not specify its types\\: T$#"
count: 1
path: src/Queues/Attributes/InjectQueue.php

-
message: "#^Method Assegai\\\\Core\\\\RPC\\\\RpcArgumentsHost\\:\\:getArgs\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
Expand Down
2 changes: 2 additions & 0 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
use Assegai\Core\Interfaces\OnModuleInitInterface;
use Assegai\Core\Rendering\Engines\DefaultTemplateEngine;
use Assegai\Core\Rendering\Interfaces\TemplateEngineInterface;
use Assegai\Core\Queues\QueueFactory;
use Assegai\Core\Routing\Router;
use Assegai\Core\Runtimes\PhpHttpRuntime;
use Assegai\Core\Runtimes\RuntimeContext;
Expand Down Expand Up @@ -456,6 +457,7 @@ protected function resetRequestScopedDependencies(): void
ControllerManager::class,
ModuleManager::class,
Injector::class,
QueueFactory::class,
TemplateEngineInterface::class,
LoggerInterface::class,
DefaultTemplateEngine::class,
Expand Down
6 changes: 5 additions & 1 deletion src/Injector.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
use Assegai\Core\Interfaces\IEntryNotFoundException;
use Assegai\Core\Interfaces\ParameterResolverInterface;
use Assegai\Core\Interfaces\ITokenStoreOwner;
use Assegai\Core\Queues\Attributes\InjectQueue;
use Assegai\Core\Queues\QueueFactory;
use Assegai\Core\Queues\QueueParameterResolver;
use Assegai\Core\Runtimes\RuntimeContext;
use Assegai\Core\Util\TypeManager;
use Codeception\Attribute\Skip;
Expand Down Expand Up @@ -90,6 +91,9 @@ final class Injector implements ITokenStoreOwner, IContainer
private final function __construct()
{
$this->logger = new ConsoleLogger(new ConsoleOutput());
$queueFactory = new QueueFactory();
$this->add(QueueFactory::class, $queueFactory);
Comment thread
amasiye marked this conversation as resolved.
$this->registerParameterResolver(new QueueParameterResolver($queueFactory));
}

/**
Expand Down
37 changes: 0 additions & 37 deletions src/Queues/Attributes/InjectQueue.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,11 @@

namespace Assegai\Core\Queues\Attributes;

use Assegai\Common\Interfaces\Queues\QueueInterface;
use Assegai\Core\Queues\QueueFactory;
use Attribute;
use InvalidArgumentException;

#[Attribute(Attribute::TARGET_PARAMETER)]
readonly class InjectQueue
{
public QueueInterface $queue;

/**
* InjectQueue constructor.
*
Expand All @@ -20,37 +15,5 @@
public function __construct(
public string $path = ''
) {
$queueConfig = config("queues.connections.$path");

if (empty($queueConfig)) {
throw new InvalidArgumentException("Queue configuration for '$path' not found.");
}

[$driver, $name] = explode('.', $path, 2) + [null, null];

if (is_null($name)) {
throw new InvalidArgumentException("Invalid queue path '$path'. Expected format: 'driver.name'.");
}

if (!isset($queueConfig['name'])) {
$queueConfig['name'] = $name;
}

$driverClass = config("queues.drivers.$driver");

if (!$driverClass || !class_exists($driverClass)) {
throw new InvalidArgumentException("Queue driver '$driver' is not configured or does not exist.");
}

if (!is_subclass_of($driverClass, QueueInterface::class)) {
throw new InvalidArgumentException("Queue driver '$driver' must implement the QueueInterface.");
}

$this->queue = $driverClass::create($queueConfig);
}

public function resolveParameterValue(): QueueInterface
{
return $this->queue;
}
}
56 changes: 56 additions & 0 deletions src/Queues/QueueFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Assegai\Core\Queues;

use Assegai\Common\Interfaces\Queues\QueueInterface;
use Assegai\Core\Attributes\Injectable;
use InvalidArgumentException;

#[Injectable]
final class QueueFactory
{
/** @var array<string, QueueInterface<object>> */
private array $connections = [];

/**
* Resolves one configured queue for the lifetime of the application graph.
*
* Driver creation must remain configuration-only. Transport implementations
* establish their broker connection when an operation first needs it.
*
* @return QueueInterface<object>
*/
public function connection(string $path): QueueInterface
{
$path = trim($path);

if (isset($this->connections[$path])) {
return $this->connections[$path];
}

[$driver, $name] = explode('.', $path, 2) + [null, null];

if (!is_string($driver) || $driver === '' || !is_string($name) || $name === '') {
throw new InvalidArgumentException("Invalid queue path '$path'. Expected format: 'driver.name'.");
}

$queueConfig = config("queues.connections.$path");

if (!is_array($queueConfig)) {
throw new InvalidArgumentException("Queue configuration for '$path' not found.");
}

$queueConfig['name'] ??= $name;
$driverClass = config("queues.drivers.$driver");

if (!is_string($driverClass) || !class_exists($driverClass)) {
throw new InvalidArgumentException("Queue driver '$driver' is not configured or does not exist.");
}

if (!is_subclass_of($driverClass, QueueInterface::class)) {
throw new InvalidArgumentException("Queue driver '$driver' must implement the QueueInterface.");
}

return $this->connections[$path] = $driverClass::create($queueConfig);
}
}
28 changes: 28 additions & 0 deletions src/Queues/QueueParameterResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Assegai\Core\Queues;

use Assegai\Core\Injector;
use Assegai\Core\Interfaces\ParameterResolverInterface;
use Assegai\Core\Queues\Attributes\InjectQueue;
use ReflectionParameter;

final readonly class QueueParameterResolver implements ParameterResolverInterface
{
public function __construct(private QueueFactory $factory)
{
}

public function supports(ReflectionParameter $parameter, Injector $injector): bool
{
return $parameter->getAttributes(InjectQueue::class) !== [];
}

public function resolve(ReflectionParameter $parameter, Injector $injector): mixed
{
/** @var InjectQueue $attribute */
$attribute = $parameter->getAttributes(InjectQueue::class)[0]->newInstance();

return $this->factory->connection($attribute->path);
}
}
Loading
Loading