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/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/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/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, + ); + } +} 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 +{ } 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'); + } +}