Skip to content

Commit e5ad9f2

Browse files
committed
fix: exclude event dispatcher from worker services reset
ResetServicesListener calls ServicesResetter::reset() during WorkerRunningEvent dispatch. In debug mode, the global services_resetter includes debug.event_dispatcher (TraceableEventDispatcher), whose reset() clears dispatchDepth mid-dispatch — causing "Undefined array key" in postProcess() with symfony/event-dispatcher >= 8.0.8. Add WorkerResetCompilerPass that builds a worker-specific ServicesResetter excluding the debug.event_dispatcher, so the global resetter remains unchanged for HTTP request resets. Fixes #309
1 parent 1eb6b6b commit e5ad9f2

4 files changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcingBundle\DependencyInjection;
6+
7+
use Patchlevel\EventSourcingBundle\Subscription\ResetServicesListener;
8+
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
9+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
10+
use Symfony\Component\DependencyInjection\ContainerBuilder;
11+
use Symfony\Component\DependencyInjection\ContainerInterface;
12+
use Symfony\Component\DependencyInjection\Reference;
13+
use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter;
14+
15+
/**
16+
* Builds a worker-specific ServicesResetter that excludes the event dispatcher.
17+
*
18+
* In debug mode, Symfony decorates the event dispatcher with TraceableEventDispatcher
19+
* and tags it with kernel.reset. When the ResetServicesListener fires during
20+
* WorkerRunningEvent dispatch, the global services_resetter calls reset() on the
21+
* TraceableEventDispatcher — clearing its internal state (dispatchDepth) while the
22+
* event is still being dispatched. This causes an "Undefined array key" warning
23+
* in postProcess() (symfony/event-dispatcher >= 8.0.8).
24+
*
25+
* This pass creates a separate ServicesResetter for the worker that excludes the
26+
* debug.event_dispatcher, so the global services_resetter remains unchanged for
27+
* HTTP request resets.
28+
*/
29+
final class WorkerResetCompilerPass implements CompilerPassInterface
30+
{
31+
public function process(ContainerBuilder $container): void
32+
{
33+
if (!$container->hasDefinition(ResetServicesListener::class)) {
34+
return;
35+
}
36+
37+
if (!$container->hasDefinition('debug.event_dispatcher')) {
38+
return;
39+
}
40+
41+
$services = [];
42+
/** @var array<string, list<string>> $methods */
43+
$methods = [];
44+
45+
foreach ($container->findTaggedServiceIds('kernel.reset', true) as $id => $tags) {
46+
if ($id === 'debug.event_dispatcher') {
47+
continue;
48+
}
49+
50+
$services[$id] = new Reference($id, ContainerInterface::IGNORE_ON_UNINITIALIZED_REFERENCE);
51+
52+
foreach ($tags as $attributes) {
53+
/** @var array{method?: string, on_invalid?: string} $attributes */
54+
if (!isset($attributes['method'])) {
55+
continue;
56+
}
57+
58+
$methods[$id] ??= [];
59+
60+
$method = $attributes['method'];
61+
62+
if (($attributes['on_invalid'] ?? null) === 'ignore') {
63+
$method = '?' . $method;
64+
}
65+
66+
$methods[$id][] = $method;
67+
}
68+
}
69+
70+
if ($services === []) {
71+
return;
72+
}
73+
74+
$container->register('patchlevel.worker.services_resetter', ServicesResetter::class)
75+
->setArguments([
76+
new IteratorArgument($services),
77+
$methods,
78+
]);
79+
80+
$container->getDefinition(ResetServicesListener::class)
81+
->setArgument(0, new Reference('patchlevel.worker.services_resetter'));
82+
}
83+
}

src/PatchlevelEventSourcingBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Patchlevel\EventSourcingBundle\DependencyInjection\RepositoryCompilerPass;
1313
use Patchlevel\EventSourcingBundle\DependencyInjection\SubscriberGuardCompilePass;
1414
use Patchlevel\EventSourcingBundle\DependencyInjection\TranslatorCompilerPass;
15+
use Patchlevel\EventSourcingBundle\DependencyInjection\WorkerResetCompilerPass;
1516
use Symfony\Component\DependencyInjection\ContainerBuilder;
1617
use Symfony\Component\HttpKernel\Bundle\Bundle;
1718

@@ -27,5 +28,6 @@ public function build(ContainerBuilder $container): void
2728
$container->addCompilerPass(new TranslatorCompilerPass());
2829
$container->addCompilerPass(new DoctrineCleanupCompilerPass());
2930
$container->addCompilerPass(new HydratorCompilerPass());
31+
$container->addCompilerPass(new WorkerResetCompilerPass());
3032
}
3133
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcingBundle\Tests\Unit\DependencyInjection;
6+
7+
use Patchlevel\EventSourcingBundle\DependencyInjection\WorkerResetCompilerPass;
8+
use Patchlevel\EventSourcingBundle\Subscription\ResetServicesListener;
9+
use PHPUnit\Framework\TestCase;
10+
use Symfony\Component\DependencyInjection\ContainerBuilder;
11+
use Symfony\Component\DependencyInjection\Reference;
12+
use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter;
13+
14+
final class WorkerResetCompilerPassTest extends TestCase
15+
{
16+
public function testCreatesFilteredResetterWhenDebugDispatcherExists(): void
17+
{
18+
$container = new ContainerBuilder();
19+
20+
$container->register(ResetServicesListener::class)
21+
->setArguments([new Reference('services_resetter')]);
22+
23+
$container->register('debug.event_dispatcher')
24+
->addTag('kernel.reset', ['method' => 'reset']);
25+
26+
$container->register('some.other.service')
27+
->addTag('kernel.reset', ['method' => 'reset']);
28+
29+
$pass = new WorkerResetCompilerPass();
30+
$pass->process($container);
31+
32+
self::assertTrue($container->hasDefinition('patchlevel.worker.services_resetter'));
33+
34+
$definition = $container->getDefinition('patchlevel.worker.services_resetter');
35+
self::assertSame(ServicesResetter::class, $definition->getClass());
36+
37+
/** @var array<string, list<string>> $methods */
38+
$methods = $definition->getArgument(1);
39+
self::assertArrayNotHasKey('debug.event_dispatcher', $methods);
40+
self::assertArrayHasKey('some.other.service', $methods);
41+
self::assertSame(['reset'], $methods['some.other.service']);
42+
43+
$listenerDef = $container->getDefinition(ResetServicesListener::class);
44+
self::assertEquals(
45+
new Reference('patchlevel.worker.services_resetter'),
46+
$listenerDef->getArgument(0),
47+
);
48+
}
49+
50+
public function testSkipsWhenNoDebugDispatcher(): void
51+
{
52+
$container = new ContainerBuilder();
53+
54+
$container->register(ResetServicesListener::class)
55+
->setArguments([new Reference('services_resetter')]);
56+
57+
$container->register('some.other.service')
58+
->addTag('kernel.reset', ['method' => 'reset']);
59+
60+
$pass = new WorkerResetCompilerPass();
61+
$pass->process($container);
62+
63+
self::assertFalse($container->hasDefinition('patchlevel.worker.services_resetter'));
64+
65+
$listenerDef = $container->getDefinition(ResetServicesListener::class);
66+
self::assertEquals(
67+
new Reference('services_resetter'),
68+
$listenerDef->getArgument(0),
69+
);
70+
}
71+
72+
public function testSkipsWhenNoResetServicesListener(): void
73+
{
74+
$container = new ContainerBuilder();
75+
76+
$container->register('debug.event_dispatcher')
77+
->addTag('kernel.reset', ['method' => 'reset']);
78+
79+
$container->register('some.other.service')
80+
->addTag('kernel.reset', ['method' => 'reset']);
81+
82+
$pass = new WorkerResetCompilerPass();
83+
$pass->process($container);
84+
85+
// The pass must not create the worker resetter when the listener is absent.
86+
self::assertFalse($container->hasDefinition('patchlevel.worker.services_resetter'));
87+
}
88+
89+
public function testSkipsWhenNoResettableServicesRemain(): void
90+
{
91+
$container = new ContainerBuilder();
92+
93+
$container->register(ResetServicesListener::class)
94+
->setArguments([new Reference('services_resetter')]);
95+
96+
$container->register('debug.event_dispatcher')
97+
->addTag('kernel.reset', ['method' => 'reset']);
98+
99+
$pass = new WorkerResetCompilerPass();
100+
$pass->process($container);
101+
102+
self::assertFalse($container->hasDefinition('patchlevel.worker.services_resetter'));
103+
}
104+
105+
public function testHandlesOnInvalidIgnoreAttribute(): void
106+
{
107+
$container = new ContainerBuilder();
108+
109+
$container->register(ResetServicesListener::class)
110+
->setArguments([new Reference('services_resetter')]);
111+
112+
$container->register('debug.event_dispatcher')
113+
->addTag('kernel.reset', ['method' => 'reset']);
114+
115+
$container->register('some.service')
116+
->addTag('kernel.reset', ['method' => 'reset', 'on_invalid' => 'ignore']);
117+
118+
$pass = new WorkerResetCompilerPass();
119+
$pass->process($container);
120+
121+
self::assertTrue($container->hasDefinition('patchlevel.worker.services_resetter'));
122+
123+
/** @var array<string, list<string>> $methods */
124+
$methods = $container->getDefinition('patchlevel.worker.services_resetter')->getArgument(1);
125+
self::assertSame(['?reset'], $methods['some.service']);
126+
}
127+
128+
public function testSkipsTagsWithoutMethodAttribute(): void
129+
{
130+
$container = new ContainerBuilder();
131+
132+
$container->register(ResetServicesListener::class)
133+
->setArguments([new Reference('services_resetter')]);
134+
135+
$container->register('debug.event_dispatcher')
136+
->addTag('kernel.reset', ['method' => 'reset']);
137+
138+
$container->register('some.service')
139+
->addTag('kernel.reset', [])
140+
->addTag('kernel.reset', ['method' => 'reset']);
141+
142+
$pass = new WorkerResetCompilerPass();
143+
$pass->process($container);
144+
145+
/** @var array<string, list<string>> $methods */
146+
$methods = $container->getDefinition('patchlevel.worker.services_resetter')->getArgument(1);
147+
self::assertSame(['reset'], $methods['some.service']);
148+
}
149+
150+
public function testPreservesMultipleResetMethodsOnSameService(): void
151+
{
152+
$container = new ContainerBuilder();
153+
154+
$container->register(ResetServicesListener::class)
155+
->setArguments([new Reference('services_resetter')]);
156+
157+
$container->register('debug.event_dispatcher')
158+
->addTag('kernel.reset', ['method' => 'reset']);
159+
160+
$container->register('some.service')
161+
->addTag('kernel.reset', ['method' => 'resetA'])
162+
->addTag('kernel.reset', ['method' => 'resetB']);
163+
164+
$pass = new WorkerResetCompilerPass();
165+
$pass->process($container);
166+
167+
/** @var array<string, list<string>> $methods */
168+
$methods = $container->getDefinition('patchlevel.worker.services_resetter')->getArgument(1);
169+
self::assertSame(['resetA', 'resetB'], $methods['some.service']);
170+
}
171+
}

tests/Unit/PatchlevelEventSourcingBundleTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,6 +1543,36 @@ public function testFullBuild(): void
15431543
self::assertInstanceOf(ResetServicesListener::class, $container->get(ResetServicesListener::class));
15441544
}
15451545

1546+
public function testWorkerResetExcludesDebugDispatcher(): void
1547+
{
1548+
$container = new ContainerBuilder();
1549+
1550+
$container->register('debug.event_dispatcher')
1551+
->addTag('kernel.reset', ['method' => 'reset'])
1552+
->setPublic(true);
1553+
1554+
$container->register('some.resettable.service')
1555+
->addTag('kernel.reset', ['method' => 'reset'])
1556+
->setPublic(true);
1557+
1558+
$this->compileContainer(
1559+
$container,
1560+
[
1561+
'patchlevel_event_sourcing' => [
1562+
'connection' => ['service' => 'doctrine.dbal.eventstore_connection'],
1563+
],
1564+
],
1565+
);
1566+
1567+
self::assertTrue($container->has('patchlevel.worker.services_resetter'));
1568+
1569+
$listenerDef = $container->getDefinition(ResetServicesListener::class);
1570+
self::assertEquals(
1571+
new Reference('patchlevel.worker.services_resetter'),
1572+
$listenerDef->getArgument(0),
1573+
);
1574+
}
1575+
15461576
public function testNamedRepository(): void
15471577
{
15481578
$container = new ContainerBuilder();

0 commit comments

Comments
 (0)