Skip to content

Commit 0dae3e4

Browse files
committed
Route queued jobs via a class-keyed registry instead of a shared static
AbstractJob::$onQueue was a static property on the base class. A subclass that does not redeclare a static shares its parent's storage, so every job extending AbstractJob without its own $onQueue shared a single slot — routing two job classes made the last assignment win for both, and the same class could resolve to different queues in different processes. This silently mis-routed jobs onto whichever queue was assigned last. Route jobs by a container-side map keyed by class name instead, setting the queue on the instance (Laravel's $queue via onQueue()) rather than a shared static: - Remove AbstractJob::$onQueue; the constructor resolves the job's queue from the new flarum.queue.routes registry. - Bind flarum.queue.routes (job class => queue) in QueueServiceProvider. - Add Extend\Queue->route(JobClass::class, 'queue') to populate it. Nothing is stored on the job class, so routing is collision-proof and deterministic across processes. Breaking: code setting $onQueue must move to Extend\Queue::route().
1 parent 2500adc commit 0dae3e4

5 files changed

Lines changed: 248 additions & 26 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Extend;
11+
12+
use Flarum\Extension\Extension;
13+
use Illuminate\Contracts\Container\Container;
14+
15+
class Queue implements ExtenderInterface
16+
{
17+
/**
18+
* @var array<class-string, string>
19+
*/
20+
private array $routes = [];
21+
22+
/**
23+
* Route all instances of a job class onto a named queue.
24+
*
25+
* The job's queue is set on the instance (Laravel's `$queue`, via
26+
* `onQueue()`) when it is constructed, so dispatch sites do not need to
27+
* change. Routing is stored in a `[job class => queue]` map rather than on
28+
* the job class itself — a per-class static would be shared by any subclass
29+
* that does not redeclare it, so routing two sibling jobs would silently
30+
* collide.
31+
*
32+
* Routing a base/abstract job class does not automatically cover its
33+
* subclasses (resolution is by exact class name); route each concrete class
34+
* you dispatch, or the base if you dispatch it directly.
35+
*
36+
* @param class-string $jobClass A job extending Flarum\Queue\AbstractJob.
37+
* @param string $queue The queue name to dispatch its instances on.
38+
*/
39+
public function route(string $jobClass, string $queue): self
40+
{
41+
$this->routes[$jobClass] = $queue;
42+
43+
return $this;
44+
}
45+
46+
public function extend(Container $container, ?Extension $extension = null): void
47+
{
48+
if (empty($this->routes)) {
49+
return;
50+
}
51+
52+
$container->extend('flarum.queue.routes', function (array $routes) {
53+
return array_merge($routes, $this->routes);
54+
});
55+
}
56+
}

framework/core/src/Queue/AbstractJob.php

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace Flarum\Queue;
1111

1212
use Illuminate\Bus\Queueable;
13+
use Illuminate\Container\Container;
1314
use Illuminate\Contracts\Queue\ShouldQueue;
1415
use Illuminate\Queue\InteractsWithQueue;
1516
use Illuminate\Queue\SerializesModels;
@@ -32,20 +33,41 @@ class AbstractJob implements ShouldQueue
3233
*/
3334
public bool $deleteWhenMissingModels = true;
3435

36+
public function __construct()
37+
{
38+
$this->applyQueueRoute();
39+
}
40+
3541
/**
36-
* Optional queue name onto which jobs of this class should be routed.
42+
* Route this job onto a dedicated queue if one has been registered for its
43+
* class, by setting the instance's queue (Laravel's normal `$queue`
44+
* property, via `onQueue()`).
45+
*
46+
* Routing is looked up in the `flarum.queue.routes` container binding — a
47+
* `[job class => queue name]` map that operators and extensions populate
48+
* (see `Flarum\Extend\Queue::route()`). This keeps the routing decision off
49+
* the job class itself: a per-class static would be shared by every
50+
* subclass that does not redeclare it, so routing two sibling jobs would
51+
* silently collide. A container-side map keyed by class name cannot.
3752
*
38-
* Operators (or extensions) may set this on a subclass to dispatch all of
39-
* its instances onto a dedicated queue without modifying the dispatch
40-
* sites. Subclasses overriding `__construct` must call `parent::__construct()`
41-
* to opt in.
53+
* Subclasses overriding `__construct` must call `parent::__construct()` to
54+
* keep their routing.
4255
*/
43-
public static ?string $onQueue = null;
44-
45-
public function __construct()
56+
protected function applyQueueRoute(): void
4657
{
47-
if (static::$onQueue !== null) {
48-
$this->onQueue(static::$onQueue);
58+
// Jobs are instantiated directly (`new SomeJob(...)`) at dispatch time,
59+
// so pull the registry from the current container rather than requiring
60+
// it to be injected.
61+
$container = Container::getInstance();
62+
63+
if ($container === null || ! $container->bound('flarum.queue.routes')) {
64+
return;
65+
}
66+
67+
$queue = $container->make('flarum.queue.routes')[static::class] ?? null;
68+
69+
if ($queue !== null) {
70+
$this->onQueue($queue);
4971
}
5072
}
5173
}

framework/core/src/Queue/QueueServiceProvider.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,21 @@ public function register(): void
6565
});
6666

6767
// The queue names known to this installation. Extensions that route
68-
// jobs onto named queues (e.g. via AbstractJob::$onQueue) should
69-
// append theirs so admin tooling can offer per-queue controls.
68+
// jobs onto named queues should append theirs so admin tooling can
69+
// offer per-queue controls.
7070
$this->container->singleton('flarum.queue.queues', function () {
7171
return ['default'];
7272
});
7373

74+
// Job-class => queue-name routing. AbstractJob consults this map in its
75+
// constructor to place instances onto a dedicated queue without the
76+
// dispatch site (or the job class) having to know. Populated via
77+
// Flarum\Extend\Queue::route(); extensions (and FoF Horizon's built-in
78+
// routing) add entries here.
79+
$this->container->singleton('flarum.queue.routes', function () {
80+
return [];
81+
});
82+
7483
$this->container->singleton(Factory::class, function (Container $container) {
7584
return new QueueFactory(
7685
function () use ($container) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\integration\extenders;
11+
12+
use Flarum\Extend;
13+
use Flarum\Queue\AbstractJob;
14+
use Flarum\Testing\integration\TestCase;
15+
use PHPUnit\Framework\Attributes\Test;
16+
17+
class QueueTest extends TestCase
18+
{
19+
#[Test]
20+
public function jobs_are_not_routed_by_default(): void
21+
{
22+
$this->app();
23+
24+
$this->assertNull((new QueueTestJobA())->queue);
25+
}
26+
27+
#[Test]
28+
public function route_sends_a_job_class_to_its_queue(): void
29+
{
30+
$this->extend(
31+
(new Extend\Queue())->route(QueueTestJobA::class, 'priority')
32+
);
33+
34+
$this->app();
35+
36+
$this->assertSame('priority', (new QueueTestJobA())->queue);
37+
}
38+
39+
/**
40+
* Regression: routing several job classes must not collide. Each class
41+
* keeps its own queue when they are all dispatched in the same process.
42+
*/
43+
#[Test]
44+
public function sibling_job_classes_route_independently(): void
45+
{
46+
$this->extend(
47+
(new Extend\Queue())
48+
->route(QueueTestJobA::class, 'realtime')
49+
->route(QueueTestJobB::class, 'gdpr')
50+
);
51+
52+
$this->app();
53+
54+
$this->assertSame('realtime', (new QueueTestJobA())->queue);
55+
$this->assertSame('gdpr', (new QueueTestJobB())->queue);
56+
// An unrouted sibling is unaffected.
57+
$this->assertNull((new QueueTestJobC())->queue);
58+
}
59+
60+
#[Test]
61+
public function multiple_extenders_compose(): void
62+
{
63+
$this->extend(
64+
(new Extend\Queue())->route(QueueTestJobA::class, 'a')
65+
);
66+
$this->extend(
67+
(new Extend\Queue())->route(QueueTestJobB::class, 'b')
68+
);
69+
70+
$this->app();
71+
72+
$this->assertSame('a', (new QueueTestJobA())->queue);
73+
$this->assertSame('b', (new QueueTestJobB())->queue);
74+
}
75+
}
76+
77+
class QueueTestJobA extends AbstractJob
78+
{
79+
}
80+
81+
class QueueTestJobB extends AbstractJob
82+
{
83+
}
84+
85+
class QueueTestJobC extends AbstractJob
86+
{
87+
}

framework/core/tests/unit/Queue/AbstractJobTest.php

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,53 +11,101 @@
1111

1212
use Flarum\Queue\AbstractJob;
1313
use Flarum\Testing\unit\TestCase;
14+
use Illuminate\Container\Container;
1415
use PHPUnit\Framework\Attributes\Test;
1516

1617
class AbstractJobTest extends TestCase
1718
{
1819
protected function tearDown(): void
1920
{
20-
AbstractJobTestStub::$onQueue = null;
21-
AbstractJobTestSubclassStub::$onQueue = null;
21+
Container::setInstance(null);
2222

2323
parent::tearDown();
2424
}
2525

26+
/**
27+
* @param array<class-string, string> $routes
28+
*/
29+
private function containerWithRoutes(array $routes = []): void
30+
{
31+
$container = new Container();
32+
$container->instance('flarum.queue.routes', $routes);
33+
Container::setInstance($container);
34+
}
35+
2636
#[Test]
2737
public function defaults_to_no_queue_routing(): void
2838
{
29-
$job = new AbstractJobTestStub();
39+
$this->containerWithRoutes();
40+
41+
$this->assertNull((new AbstractJobTestStub())->queue);
42+
}
43+
44+
#[Test]
45+
public function no_container_is_harmless(): void
46+
{
47+
Container::setInstance(null);
48+
49+
$this->assertNull((new AbstractJobTestStub())->queue);
50+
}
51+
52+
#[Test]
53+
public function routes_onto_the_queue_registered_for_its_class(): void
54+
{
55+
$this->containerWithRoutes([AbstractJobTestStub::class => 'priority']);
3056

31-
$this->assertNull($job->queue);
57+
$this->assertSame('priority', (new AbstractJobTestStub())->queue);
3258
}
3359

3460
#[Test]
35-
public function routes_onto_queue_named_by_static_property(): void
61+
public function an_unrouted_class_is_unaffected_by_other_routes(): void
3662
{
37-
AbstractJobTestStub::$onQueue = 'priority';
63+
$this->containerWithRoutes([AbstractJobTestOtherStub::class => 'other']);
64+
65+
$this->assertNull((new AbstractJobTestStub())->queue);
66+
}
3867

39-
$job = new AbstractJobTestStub();
68+
/**
69+
* The regression test for the shared-static bug: routing several sibling
70+
* job classes must not collide. A previous implementation stored the queue
71+
* in a static on AbstractJob, which siblings that did not redeclare it
72+
* shared — so routing one overwrote the others and, across processes, the
73+
* same class could even land on different queues. A class-keyed registry
74+
* gives each class independent routing.
75+
*/
76+
#[Test]
77+
public function sibling_classes_route_independently_in_the_same_process(): void
78+
{
79+
$this->containerWithRoutes([
80+
AbstractJobTestStub::class => 'realtime',
81+
AbstractJobTestOtherStub::class => 'gdpr',
82+
AbstractJobTestSubclassStub::class => 'exports',
83+
]);
4084

41-
$this->assertSame('priority', $job->queue);
85+
$this->assertSame('realtime', (new AbstractJobTestStub())->queue);
86+
$this->assertSame('gdpr', (new AbstractJobTestOtherStub())->queue);
87+
$this->assertSame('exports', (new AbstractJobTestSubclassStub())->queue);
4288
}
4389

4490
#[Test]
45-
public function static_property_is_resolved_via_late_static_binding(): void
91+
public function resolution_is_by_exact_class_not_inherited_from_parent(): void
4692
{
47-
AbstractJobTestStub::$onQueue = 'parent-queue';
48-
AbstractJobTestSubclassStub::$onQueue = 'child-queue';
93+
// Routing only the parent does not implicitly route the subclass.
94+
$this->containerWithRoutes([AbstractJobTestStub::class => 'parent-queue']);
4995

5096
$this->assertSame('parent-queue', (new AbstractJobTestStub())->queue);
51-
$this->assertSame('child-queue', (new AbstractJobTestSubclassStub())->queue);
97+
$this->assertNull((new AbstractJobTestSubclassStub())->queue);
5298
}
5399
}
54100

55101
class AbstractJobTestStub extends AbstractJob
56102
{
57-
public static ?string $onQueue = null;
103+
}
104+
105+
class AbstractJobTestOtherStub extends AbstractJob
106+
{
58107
}
59108

60109
class AbstractJobTestSubclassStub extends AbstractJobTestStub
61110
{
62-
public static ?string $onQueue = null;
63111
}

0 commit comments

Comments
 (0)