From 1de98a053c7bf9bc1aeaca634eaa5f41e13a6fe8 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Tue, 28 Jul 2026 08:00:00 +0200 Subject: [PATCH 1/3] fix: resolve dumped container cache path at runtime A dumped container keeps the absolute cache directory of the machine that built it, so a deployment warming the cache on one host and running the artifact from another path fails as soon as a gateway proxy is generated lazily. Loading a cached container now supplies the runtime cache configuration to it, overriding the instance baked into the dump. Fixes #690 --- .../EcotoneSymfonyContainerFactory.php | 6 ++ ...cotoneSymfonyContainerFactoryCacheTest.php | 28 +++++++ .../phpunit/RelocatedContainerCacheTest.php | 82 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 packages/Symfony/tests/phpunit/RelocatedContainerCacheTest.php diff --git a/packages/Ecotone/src/SymfonyContainer/EcotoneSymfonyContainerFactory.php b/packages/Ecotone/src/SymfonyContainer/EcotoneSymfonyContainerFactory.php index 4ba6e0013..d20e457da 100644 --- a/packages/Ecotone/src/SymfonyContainer/EcotoneSymfonyContainerFactory.php +++ b/packages/Ecotone/src/SymfonyContainer/EcotoneSymfonyContainerFactory.php @@ -117,6 +117,10 @@ private static function defaultRuntimeServices( } /** + * The dumped container holds the cache path from the machine that built it. + * Deployments that warm the cache in one directory and run it from another + * must resolve the path from the configuration given here, not from the dump. + * * @param array $runtimeServices */ public static function loadCached( @@ -134,6 +138,8 @@ public static function loadCached( return null; } + $runtimeServices = [ServiceCacheConfiguration::REFERENCE_NAME => $serviceCacheConfiguration] + $runtimeServices; + return self::wrapWithExternalFallback($container, $externalContainer, $runtimeServices, $serviceCacheConfiguration->getPath()); } diff --git a/packages/Ecotone/tests/SymfonyContainer/EcotoneSymfonyContainerFactoryCacheTest.php b/packages/Ecotone/tests/SymfonyContainer/EcotoneSymfonyContainerFactoryCacheTest.php index 8bd8fbed1..4995873cb 100644 --- a/packages/Ecotone/tests/SymfonyContainer/EcotoneSymfonyContainerFactoryCacheTest.php +++ b/packages/Ecotone/tests/SymfonyContainer/EcotoneSymfonyContainerFactoryCacheTest.php @@ -142,6 +142,34 @@ public function process(ContainerBuilder $builder): void self::assertSame($cacheConfiguration, $loaded->get(ServiceCacheConfiguration::REFERENCE_NAME)); } + public function test_it_overrides_dumped_cache_configuration_with_the_one_used_to_load_from(): void + { + $buildDirectory = $this->uniqueCacheDirectory(); + $buildCacheConfiguration = new ServiceCacheConfiguration($buildDirectory, true); + $builder = new ContainerBuilder(); + $builder->register(ServiceCacheConfiguration::REFERENCE_NAME, $buildCacheConfiguration); + $builder->replace('aService', new Definition(ACachedService::class, ['someName', new Reference(ServiceCacheConfiguration::REFERENCE_NAME)])); + EcotoneSymfonyContainerFactory::build($builder, $buildCacheConfiguration); + + $runtimeDirectory = $this->uniqueCacheDirectory(); + $this->relocate($buildDirectory, $runtimeDirectory); + $runtimeCacheConfiguration = new ServiceCacheConfiguration($runtimeDirectory, true); + + $loaded = EcotoneSymfonyContainerFactory::loadCached($runtimeCacheConfiguration); + + self::assertSame($runtimeCacheConfiguration, $loaded->get(ServiceCacheConfiguration::REFERENCE_NAME)); + self::assertSame($runtimeDirectory, $loaded->get('aService')->dependency->getPath()); + } + + private function relocate(string $buildDirectory, string $runtimeDirectory): void + { + mkdir($runtimeDirectory, 0777, true); + foreach (glob($buildDirectory . '/*') as $file) { + rename($file, $runtimeDirectory . '/' . basename($file)); + } + rmdir($buildDirectory); + } + private function uniqueCacheDirectory(): string { return sys_get_temp_dir() . '/ecotone_container_cache_test/' . uniqid('', true); diff --git a/packages/Symfony/tests/phpunit/RelocatedContainerCacheTest.php b/packages/Symfony/tests/phpunit/RelocatedContainerCacheTest.php new file mode 100644 index 000000000..b794bbd5f --- /dev/null +++ b/packages/Symfony/tests/phpunit/RelocatedContainerCacheTest.php @@ -0,0 +1,82 @@ +filesystem = new Filesystem(); + $this->temporaryDirectory = sys_get_temp_dir() . '/ecotone-relocation-' . bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + $this->filesystem->remove($this->temporaryDirectory); + } + + public function test_cached_proxies_are_written_next_to_the_relocated_container(): void + { + $buildCacheDirectory = $this->temporaryDirectory . '/build/ecotone'; + $runtimeCacheDirectory = $this->temporaryDirectory . '/runtime/ecotone'; + + $this->buildEcotoneContainerIn($buildCacheDirectory); + $this->relocateCacheFrom($buildCacheDirectory, $runtimeCacheDirectory); + + $container = EcotoneContainerLoader::load($runtimeCacheDirectory, InMemoryPSRContainer::createEmpty()); + + $proxyFile = $container + ->get(ProxyFactory::class) + ->generateCachedProxyFileFor(new GatewayProxyReference(QueryBus::class, QueryBus::class), true); + + self::assertStringStartsWith($runtimeCacheDirectory . '/', $proxyFile); + self::assertFileExists($proxyFile); + } + + private function buildEcotoneContainerIn(string $cacheDirectory): void + { + $serviceCacheConfiguration = new ServiceCacheConfiguration($cacheDirectory, true); + + $containerBuilder = new ContainerBuilder(); + $containerBuilder->register(ServiceCacheConfiguration::REFERENCE_NAME, $serviceCacheConfiguration); + $containerBuilder->addCompilerPass(MessagingSystemConfiguration::prepareWithDefaultsForTesting()); + $containerBuilder->addCompilerPass(new RegisterInterfaceToCallReferences()); + $containerBuilder->addCompilerPass(new ValidityCheckPass()); + + MessagingSystemConfiguration::prepareCacheDirectory($serviceCacheConfiguration); + EcotoneSymfonyContainerFactory::build($containerBuilder, $serviceCacheConfiguration); + } + + private function relocateCacheFrom(string $buildCacheDirectory, string $runtimeCacheDirectory): void + { + $this->filesystem->mirror($buildCacheDirectory, $runtimeCacheDirectory); + $this->filesystem->remove($this->temporaryDirectory . '/build'); + $this->filesystem->touch($this->temporaryDirectory . '/build'); + } +} From ecd588ed6be22ce961ca4d8cc37e0663967357b5 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Tue, 28 Jul 2026 08:00:00 +0200 Subject: [PATCH 2/3] fix: skip container cache for configurations built from inline classes An anonymous class is named after its file and a counter that changes between processes, so a dumped container referencing one can never be resolved again and a later bootstrap is served another bootstrap's container. Configurations registering an anonymous class are now rebuilt rather than cached. This is silent because caching is an optimisation, and raising here would break suites passing useCachedVersion alongside inline handlers. --- .../SymfonyContainer/ContainerCacheLayout.php | 20 ++++- .../CachedBootstrapIsolationTest.php | 77 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/Ecotone/tests/SymfonyContainer/CachedBootstrapIsolationTest.php diff --git a/packages/Ecotone/src/SymfonyContainer/ContainerCacheLayout.php b/packages/Ecotone/src/SymfonyContainer/ContainerCacheLayout.php index 836b0109e..3a1a9fd0a 100644 --- a/packages/Ecotone/src/SymfonyContainer/ContainerCacheLayout.php +++ b/packages/Ecotone/src/SymfonyContainer/ContainerCacheLayout.php @@ -61,9 +61,27 @@ public static function resolve( $annotationFinder, new ServiceCacheConfiguration( $useHashSubDirectory ? $cacheDirectory . DIRECTORY_SEPARATOR . $configHash : $cacheDirectory, - $shouldUseCache, + $shouldUseCache && ! self::containsAnonymousClass($annotationFinder->registeredClasses()), ), $configHash, ); } + + /** + * An anonymous class is named after the file and a counter that changes + * between processes, so a dumped container referencing one can never be + * resolved again. Configurations built from them are always rebuilt. + * + * @param class-string[] $registeredClasses + */ + private static function containsAnonymousClass(array $registeredClasses): bool + { + foreach ($registeredClasses as $registeredClass) { + if (str_contains($registeredClass, '@anonymous')) { + return true; + } + } + + return false; + } } diff --git a/packages/Ecotone/tests/SymfonyContainer/CachedBootstrapIsolationTest.php b/packages/Ecotone/tests/SymfonyContainer/CachedBootstrapIsolationTest.php new file mode 100644 index 000000000..145096698 --- /dev/null +++ b/packages/Ecotone/tests/SymfonyContainer/CachedBootstrapIsolationTest.php @@ -0,0 +1,77 @@ +notes[] = $note; + } + + #[QueryHandler('first.retrieve')] + public function retrieve(): array + { + return $this->notes; + } + }; + + $secondService = new class () { + private array $labels = []; + + #[CommandHandler('second.store')] + public function store(#[Header('label')] string $label): void + { + $this->labels[] = $label; + } + + #[QueryHandler('second.retrieve')] + public function retrieve(): array + { + return $this->labels; + } + }; + + $firstEcotone = $this->bootstrapWithCache($firstService); + $secondEcotone = $this->bootstrapWithCache($secondService); + + $firstEcotone->getCommandBus()->sendWithRouting('first.store', metadata: ['note' => 'from first']); + $secondEcotone->getCommandBus()->sendWithRouting('second.store', metadata: ['label' => 'from second']); + + self::assertSame(['from first'], $firstEcotone->getQueryBus()->sendWithRouting('first.retrieve')); + self::assertSame(['from second'], $secondEcotone->getQueryBus()->sendWithRouting('second.retrieve')); + } + + private function bootstrapWithCache(object $service): ConfiguredMessagingSystem + { + return EcotoneLite::bootstrap( + [$service::class], + [$service], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ->withCacheDirectoryPath(sys_get_temp_dir() . '/ecotone_cached_bootstrap_isolation'), + useCachedVersion: true, + ); + } +} From abeacd0caf7609873735c5632be58c447330fb52 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Tue, 28 Jul 2026 08:00:00 +0200 Subject: [PATCH 3/3] fix: include composer.lock and registered class names in the cache key The lock file never reached the cache key: its path was concatenated without a separator, so the file was never found and upgrading dependencies reused the previous container. The key also recorded which files a configuration registered rather than which classes, so two configurations declared in the same file shared one cache entry. --- .../FileSystem/FileSystemAnnotationFinder.php | 7 +- .../ContainerCacheLayoutTest.php | 66 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php b/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php index 54227dcd8..d8b806030 100644 --- a/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php +++ b/packages/Ecotone/src/AnnotationFinder/FileSystem/FileSystemAnnotationFinder.php @@ -565,11 +565,12 @@ public function getCacheMessagingFileNameBasedOnConfig( foreach ($this->registeredClasses() as $class) { $filePath = (new ReflectionClass($class))->getFileName(); - $fileSha .= sha1_file($filePath); + $fileSha .= $class . sha1_file($filePath); } - if (file_exists($pathToRootCatalog . 'composer.lock')) { - $fileSha .= sha1_file($pathToRootCatalog . 'composer.lock'); + $composerLockPath = rtrim($pathToRootCatalog, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'composer.lock'; + if (file_exists($composerLockPath)) { + $fileSha .= sha1_file($composerLockPath); } $fileSha .= sha1(serialize($serviceConfiguration)); diff --git a/packages/Ecotone/tests/SymfonyContainer/ContainerCacheLayoutTest.php b/packages/Ecotone/tests/SymfonyContainer/ContainerCacheLayoutTest.php index 798a5bef4..179de4193 100644 --- a/packages/Ecotone/tests/SymfonyContainer/ContainerCacheLayoutTest.php +++ b/packages/Ecotone/tests/SymfonyContainer/ContainerCacheLayoutTest.php @@ -44,6 +44,43 @@ classesToResolve: [self::class], self::assertTrue($cacheLayout->serviceCacheConfiguration->shouldUseCache()); } + public function test_it_resolves_different_config_hash_for_different_classes_declared_in_the_same_file(): void + { + $firstLayout = $this->resolveFor([FirstCacheKeyFixture::class]); + $secondLayout = $this->resolveFor([SecondCacheKeyFixture::class]); + + self::assertNotSame($firstLayout->configHash, $secondLayout->configHash); + } + + public function test_it_disables_cache_when_anonymous_class_is_registered(): void + { + $anonymousClass = new class () { + }; + + $layout = $this->resolveFor([$anonymousClass::class]); + + self::assertFalse($layout->serviceCacheConfiguration->shouldUseCache()); + } + + public function test_it_resolves_different_config_hash_when_installed_dependencies_change(): void + { + $rootCatalog = sys_get_temp_dir() . '/ecotone_composer_lock_test_' . bin2hex(random_bytes(6)); + mkdir($rootCatalog, 0777, true); + + try { + file_put_contents($rootCatalog . '/composer.lock', '{"packages":[{"name":"ecotone/ecotone","version":"1.322.0"}]}'); + $beforeUpgrade = $this->resolveFor([FirstCacheKeyFixture::class], $rootCatalog); + + file_put_contents($rootCatalog . '/composer.lock', '{"packages":[{"name":"ecotone/ecotone","version":"1.323.0"}]}'); + $afterUpgrade = $this->resolveFor([FirstCacheKeyFixture::class], $rootCatalog); + + self::assertNotSame($beforeUpgrade->configHash, $afterUpgrade->configHash); + } finally { + @unlink($rootCatalog . '/composer.lock'); + @rmdir($rootCatalog); + } + } + public function test_it_resolves_fixed_cache_directory_without_hash_sub_directory(): void { $cacheDirectory = sys_get_temp_dir() . '/ecotone_cache_layout_test_fixed'; @@ -60,4 +97,33 @@ classesToResolve: [self::class], self::assertSame($cacheDirectory, $cacheLayout->serviceCacheConfiguration->getPath()); } + + /** + * @param class-string[] $classesToResolve + */ + private function resolveFor(array $classesToResolve, ?string $rootCatalog = null): ContainerCacheLayout + { + return ContainerCacheLayout::resolve( + $rootCatalog ?? __DIR__ . '/../../', + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()), + sys_get_temp_dir() . '/ecotone_cache_layout_test', + shouldUseCache: true, + classesToResolve: $classesToResolve, + ); + } +} + +/** + * licence Apache-2.0 + */ +class FirstCacheKeyFixture +{ +} + +/** + * licence Apache-2.0 + */ +class SecondCacheKeyFixture +{ }