diff --git a/CHANGELOG.md b/CHANGELOG.md index df364499..9e6ac885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Changelog * [Performance] **Direct static joinpoint initialization** — leveraging PHP 8.3+ support for dynamic expressions in static variable initializers, all generated proxy method bodies now initialize their static joinpoint variables directly. * [Performance] [BC BREAK] **Truly lazy container services** — `AspectContainer::addLazyService()` stores a factory instead of building a PHP 8.4 lazy proxy, so registering the built-in services no longer reflects/autoloads them on every request; a service is constructed on first retrieval. Code relying on lazy-proxy instances being available from boot must use `getService()` instead. * [Performance] [BC BREAK] **Lazy aspect registration** — `AspectContainer::registerAspect()` accepts an aspect class-name plus an optional factory closure (required for aspects with constructor dependencies); such aspects are constructed on first use instead of during `configureAop()`. The signature widened to `registerAspect(Aspect|string $aspectOrClassName, ?Closure $aspectFactory = null)`; instance registration behaves as before. +* [Performance] [BC BREAK] **Class-keyed runtime cache map, integrated into composer** — the weaver records the FQCN of every discovered class into the cache metadata, and the runtime cache file `_include.cache` holds a woven-class => cached-file map plus a skip set of known untransformed classes. At production boot the class map is handed to composer via `ClassLoader::addClassMap()`, so woven classes resolve natively to their cached files (composer consults the class map before PSR-4) with no per-class `realpath()`; untransformed classes are served untouched. `_transformation.cache` keeps the full build metadata and is loaded lazily, only on the cache-miss/weaving paths. **Cache format bump**: a pre-4.0 cache directory carries no class names and is treated as stale — everything re-weaves once (or run `cache:warmup:aop` at deploy). New accessors: `CachePathManager::queryClassMap()`, `querySkippedClasses()`, `registerClassForResource()`. * [Performance] [BC BREAK] **`PREBUILT_CACHE` now really trusts the cache** — with `Features::PREBUILT_CACHE` enabled, an existing cache record is used without any freshness checks: cache directory existence/writability probes, source `filemtime` comparisons, tracked-resource checks and advisor cache freshness are all skipped (previously the flag only skipped one writability check). Build the cache at deploy time (`bin/aspect cache:warmup:aop`); staleness is the deployer's responsibility. A corrupt advisor cache file falls back to the direct loader without writing (read-only file systems stay safe). * [Performance] [BC BREAK] **Lazy transformation pipeline** — every source transformer is a deferred container service, tagged by the `SourceTransformer` interface and assembled in registration order; the stream filter and the transformers are only registered/constructed on the first cache miss (`SourceTransformingLoader::ensureRegistered()`), never on a warm-cache request. The protected `AspectKernel::registerTransformers()` hook (returning transformer instances, used e.g. by AspectMock to swap in its own weaver) is replaced by `AspectKernel::registerTransformerServices(AspectContainer $container)`, which registers deferred container definitions: override it to replace, omit, reorder or extend the built-in transformers, or simply `addLazyService()` an extra `SourceTransformer` service from `configureAop()` to append one. diff --git a/src/Instrument/ClassLoading/AopComposerLoader.php b/src/Instrument/ClassLoading/AopComposerLoader.php index 4b76d937..96b5e170 100644 --- a/src/Instrument/ClassLoading/AopComposerLoader.php +++ b/src/Instrument/ClassLoading/AopComposerLoader.php @@ -46,11 +46,18 @@ class AopComposerLoader protected Enumerator $fileEnumerator; /** - * Cache state + * Runtime class map: woven class name => cached file (also fed to composer's classmap) * - * @var array + * @var array */ - private array $cacheState; + private array $classMap; + + /** + * Classes known to the cache but not transformed - served natively by composer + * + * @var array + */ + private array $skippedClasses; /** * Was initialization successful or not @@ -89,7 +96,18 @@ public function __construct(ClassLoader $original, AspectContainer $container, a $fileEnumerator = new Enumerator($options['appDir'], $options['includePaths'], $excludePaths); $this->fileEnumerator = $fileEnumerator; - $this->cacheState = $container->getService(CachePathManager::class)->queryCacheState() ?? []; + + $cachePathManager = $container->getService(CachePathManager::class); + $this->classMap = $cachePathManager->queryClassMap(); + $this->skippedClasses = $cachePathManager->querySkippedClasses(); + + // In production the woven class map is handed to composer directly: its findFile() + // consults the class map before PSR-4/PSR-0, so woven classes resolve natively to + // their cached files. Untransformed classes are deliberately NOT added - composer + // already resolves them to their original files. + if (!$options['debug'] && $this->classMap !== []) { + $original->addClassMap($this->classMap); + } } /** @@ -151,16 +169,17 @@ public function findFile(string $class): false|string $file = $this->original->findFile($class); if ($file !== false) { + if ($this->isProduction && (isset($this->classMap[$class]) || isset($this->skippedClasses[$class]))) { + // Known class: composer already resolved it to the cached file (via the + // injected class map) or to the untouched original - nothing left to do + return $file; + } $resolved = PathResolver::realpath($file); if (is_string($resolved)) { $file = $resolved; } - $cacheState = $this->cacheState[$file] ?? null; - if ($cacheState && $this->isProduction) { - $cacheUri = is_array($cacheState) && is_string($cacheState['cacheUri'] ?? null) ? $cacheState['cacheUri'] : null; - $file = $cacheUri ?: $file; - } elseif (($this->isAllowedFilter)(new SplFileInfo($file))) { - // can be optimized here with $cacheState even for debug mode, but no needed right now + if (($this->isAllowedFilter)(new SplFileInfo($file))) { + // can be optimized here with the class map even for debug mode, but no needed right now $file = FilterInjectorTransformer::rewrite($file); } } diff --git a/src/Instrument/ClassLoading/CachePathManager.php b/src/Instrument/ClassLoading/CachePathManager.php index 1615e07a..35e90363 100644 --- a/src/Instrument/ClassLoading/CachePathManager.php +++ b/src/Instrument/ClassLoading/CachePathManager.php @@ -27,10 +27,15 @@ class CachePathManager { /** - * Name of the file with cache paths + * Name of the file with full transformation metadata (build-time data, loaded lazily) */ private const CACHE_FILE_NAME = '/_transformation.cache'; + /** + * Name of the file with the minimal runtime include map (originalPath => cacheUri|null) + */ + private const INCLUDE_MAP_FILE_NAME = '/_include.cache'; + /** @phpstan-var KernelOptions */ protected array $options; @@ -51,10 +56,42 @@ class CachePathManager /** * Cached metadata for transformation state for the concrete file * + * Loaded lazily from the metadata file: only the cache-miss/weaving paths need it, + * a hot request works from the include map alone. + * * @var array */ protected array $cacheState = []; + /** + * Whether the full transformation metadata was already loaded from its file + */ + private bool $cacheStateLoaded = false; + + /** + * Minimal runtime map of woven class name to its cached file, integrateable + * directly into the composer loader via ClassLoader::addClassMap() + * + * @var array + */ + protected array $classMap = []; + + /** + * Set of class names that are known to the cache but were not transformed, + * so the autoloader can serve them natively without any filtering + * + * @var array + */ + protected array $skippedClasses = []; + + /** + * Class names discovered by the weaver per original file, pending until + * setCacheState() folds them into the metadata record + * + * @var array> + */ + private array $pendingClasses = []; + /** * New metadata items, that was not present in $cacheState * @@ -91,15 +128,90 @@ public function __construct(AspectKernel $kernel) } } - if (file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { - $cacheData = include $this->cacheDir . self::CACHE_FILE_NAME; - if (is_array($cacheData)) { - $this->cacheState = $cacheData; + if (file_exists($this->cacheDir . self::INCLUDE_MAP_FILE_NAME)) { + $includeData = include $this->cacheDir . self::INCLUDE_MAP_FILE_NAME; + if (is_array($includeData)) { + $rawClassMap = is_array($includeData['map'] ?? null) ? $includeData['map'] : []; + foreach ($rawClassMap as $className => $cacheUri) { + if (is_string($className) && is_string($cacheUri)) { + /** @var class-string $className */ + $this->classMap[$className] = $cacheUri; + } + } + $rawSkip = is_array($includeData['skip'] ?? null) ? $includeData['skip'] : []; + foreach (array_keys($rawSkip) as $className) { + if (is_string($className)) { + /** @var class-string $className */ + $this->skippedClasses[$className] = true; + } + } } + } elseif (file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { + // Legacy cache directory (pre-class-map format): the metadata records carry + // no class names, so the cache cannot serve the class map. Treat the whole + // cache as stale - everything re-weaves once and both files are rewritten + // in the new format (or run `cache:warmup:aop` at deploy time). + $this->cacheStateLoaded = true; + } + } + } + + /** + * Loads the full transformation metadata from its file on first demand + */ + private function loadCacheState(): void + { + if ($this->cacheStateLoaded) { + return; + } + $this->cacheStateLoaded = true; + + if ($this->cacheDir !== null && file_exists($this->cacheDir . self::CACHE_FILE_NAME)) { + $cacheData = include $this->cacheDir . self::CACHE_FILE_NAME; + if (is_array($cacheData)) { + $this->cacheState = $cacheData; } } } + /** + * Returns the runtime map of woven class names to their cached files + * + * Suitable for direct integration into composer via ClassLoader::addClassMap(). + * Unlike queryCacheState(), this accessor never materializes the full metadata array. + * + * @return array + */ + public function queryClassMap(): array + { + return $this->classMap; + } + + /** + * Returns the set of class names known to the cache but not transformed + * + * The autoloader serves these natively, without any include-path filtering. + * + * @return array + */ + public function querySkippedClasses(): array + { + return $this->skippedClasses; + } + + /** + * Records a class name discovered by the weaver in the given original file + * + * The pending names are folded into the file's metadata record by setCacheState() + * and become the runtime class map / skip set on flush. + * + * @param class-string $className + */ + public function registerClassForResource(string $resource, string $className): void + { + $this->pendingClasses[$resource][] = $className; + } + /** * Returns current cache directory for aspects, can be null */ @@ -146,6 +258,8 @@ public function getCachePathForResource(string $resource) */ public function queryCacheState(?string $resource = null): ?array { + $this->loadCacheState(); + if ($resource === null) { return $this->cacheState; } @@ -172,7 +286,23 @@ public function queryCacheState(?string $resource = null): ?array */ public function setCacheState(string $resource, array $metadata): void { + $classNames = $this->pendingClasses[$resource] ?? []; + unset($this->pendingClasses[$resource]); + $metadata['classes'] = $classNames; + $this->newCacheState[$resource] = $metadata; + + // Keep the in-memory runtime map coherent within this request + $cacheUri = $metadata['cacheUri'] ?? null; + foreach ($classNames as $className) { + if (is_string($cacheUri)) { + $this->classMap[$className] = $cacheUri; + unset($this->skippedClasses[$className]); + } else { + $this->skippedClasses[$className] = true; + unset($this->classMap[$className]); + } + } } /** @@ -191,27 +321,66 @@ public function __destruct() public function flushCacheState(bool $force = false): void { if ((!empty($this->newCacheState) && $this->cacheDir !== null && is_writable($this->cacheDir)) || $force) { - $fullCacheMap = $this->newCacheState + $this->cacheState; - $cachePath = substr(var_export($this->cacheDir, true), 1, -1); - $rootPath = substr(var_export($this->appDir, true), 1, -1); - $cacheData = ' 'AOP_CACHE_DIR . \'', - '\'' . $rootPath => 'AOP_ROOT_DIR . \'' - ] - ); - $fullCacheFileName = $this->cacheDir . self::CACHE_FILE_NAME; - file_put_contents($fullCacheFileName, $cacheData, LOCK_EX); - // For cache files we don't want executable bits by default - chmod($fullCacheFileName, $this->fileMode & (~0111)); - - if (function_exists('opcache_invalidate')) { - opcache_invalidate($fullCacheFileName, true); + // The full metadata must be loaded before merging, otherwise entries that were + // never queried during this request would be dropped from the written file + $this->loadCacheState(); + $fullCacheMap = $this->newCacheState + $this->cacheState; + + $classMap = []; + $skippedClasses = []; + foreach ($fullCacheMap as $metadata) { + if (!is_array($metadata)) { + continue; + } + $cacheUri = $metadata['cacheUri'] ?? null; + $classNames = is_array($metadata['classes'] ?? null) ? $metadata['classes'] : []; + foreach ($classNames as $className) { + if (!is_string($className)) { + continue; + } + /** @var class-string $className */ + if (is_string($cacheUri)) { + $classMap[$className] = $cacheUri; + } else { + $skippedClasses[$className] = true; + } + } } - $this->cacheState = $this->newCacheState + $this->cacheState; - $this->newCacheState = []; + + $this->writeCacheFile(self::CACHE_FILE_NAME, $fullCacheMap); + $this->writeCacheFile(self::INCLUDE_MAP_FILE_NAME, ['map' => $classMap, 'skip' => $skippedClasses]); + + $this->cacheState = $fullCacheMap; + $this->classMap = $classMap; + $this->skippedClasses = $skippedClasses; + $this->newCacheState = []; + } + } + + /** + * Writes one cache file as an opcache-friendly PHP return-array with portable paths + * + * @param array $data + */ + private function writeCacheFile(string $relativeFileName, array $data): void + { + $cachePath = substr(var_export($this->cacheDir, true), 1, -1); + $rootPath = substr(var_export($this->appDir, true), 1, -1); + $cacheData = ' 'AOP_CACHE_DIR . \'', + '\'' . $rootPath => 'AOP_ROOT_DIR . \'' + ] + ); + $fullCacheFileName = $this->cacheDir . $relativeFileName; + file_put_contents($fullCacheFileName, $cacheData, LOCK_EX); + // For cache files we don't want executable bits by default + chmod($fullCacheFileName, $this->fileMode & (~0111)); + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($fullCacheFileName, true); } } @@ -220,8 +389,12 @@ public function flushCacheState(bool $force = false): void */ public function clearCacheState(): void { - $this->cacheState = []; - $this->newCacheState = []; + $this->cacheState = []; + $this->cacheStateLoaded = true; + $this->classMap = []; + $this->skippedClasses = []; + $this->pendingClasses = []; + $this->newCacheState = []; $this->flushCacheState(true); } diff --git a/src/Instrument/Transformer/WeavingTransformer.php b/src/Instrument/Transformer/WeavingTransformer.php index 4454bcb3..992565a1 100644 --- a/src/Instrument/Transformer/WeavingTransformer.php +++ b/src/Instrument/Transformer/WeavingTransformer.php @@ -98,6 +98,10 @@ public function transform(StreamMetaData $metadata): TransformerResultEnum foreach ($namespaces as $namespace) { $classes = $namespace->getClasses(); foreach ($classes as $class) { + // Every discovered class (woven or not) is recorded so the runtime class + // map / skip set can be built for the autoloader at flush time + $this->cachePathManager->registerClassForResource($metadata->uri, $class->getName()); + // Skip interfaces and aspects — enums are now supported via EnumProxyGenerator if ($class->isInterface() || in_array(Aspect::class, $class->getInterfaceNames(), true)) { continue; diff --git a/tests/Core/CachedAspectLoaderTest.php b/tests/Core/CachedAspectLoaderTest.php index 01ea6a1f..6c5a3744 100644 --- a/tests/Core/CachedAspectLoaderTest.php +++ b/tests/Core/CachedAspectLoaderTest.php @@ -34,7 +34,14 @@ protected function setUp(): void protected function tearDown(): void { - array_map(unlink(...), glob($this->cacheDir . '/_aspect/*') ?: []); + // Guard the cleanup: deletions must stay inside the uniquely-named temp + // directory this test created, whatever happens to the property value + $this->assertStringStartsWith(sys_get_temp_dir() . '/goaop-cached-loader-', $this->cacheDir); + foreach (glob($this->cacheDir . '/_aspect/*') ?: [] as $cacheFile) { + if (is_file($cacheFile)) { + unlink($cacheFile); + } + } @rmdir($this->cacheDir . '/_aspect'); @rmdir($this->cacheDir); } diff --git a/tests/Instrument/ClassLoading/CachePathManagerTest.php b/tests/Instrument/ClassLoading/CachePathManagerTest.php new file mode 100644 index 00000000..a469532b --- /dev/null +++ b/tests/Instrument/ClassLoading/CachePathManagerTest.php @@ -0,0 +1,139 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Instrument\ClassLoading; + +use Go\Core\AspectKernel; +use Go\Core\Container; +use PHPUnit\Framework\TestCase; +use ReflectionProperty; + +// Separate processes: the cache files reference the AOP_ROOT_DIR/AOP_CACHE_DIR constants, +// which other tests in the shared process define with the fixture-project paths +#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses] +#[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] +class CachePathManagerTest extends TestCase +{ + private static string $appDir; + private static string $cacheDir; + + public static function setUpBeforeClass(): void + { + // The cache files reference these constants for path portability, so they must + // match the directories used by every test in this class exactly + self::$appDir = sys_get_temp_dir() . '/goaop-cpm-app'; + self::$cacheDir = sys_get_temp_dir() . '/goaop-cpm-cache'; + if (!defined('AOP_ROOT_DIR')) { + define('AOP_ROOT_DIR', self::$appDir); + define('AOP_CACHE_DIR', self::$cacheDir); + } + if (!is_dir(self::$appDir)) { + mkdir(self::$appDir, 0777, true); + } + if (!is_dir(self::$cacheDir)) { + mkdir(self::$cacheDir, 0777, true); + } + } + + public static function tearDownAfterClass(): void + { + self::removeKnownCacheFiles(); + @rmdir(self::$cacheDir); + @rmdir(self::$appDir); + } + + protected function setUp(): void + { + self::removeKnownCacheFiles(); + } + + /** + * Deletes only the exact files this test writes, never a glob/recursive sweep: + * a wrong directory value must not be able to erase anything else + */ + private static function removeKnownCacheFiles(): void + { + self::assertStringStartsWith(sys_get_temp_dir() . '/goaop-cpm-', self::$cacheDir); + foreach (['/_transformation.cache', '/_include.cache'] as $knownFile) { + if (is_file(self::$cacheDir . $knownFile)) { + unlink(self::$cacheDir . $knownFile); + } + } + } + + private function createManager(): CachePathManager + { + $kernel = $this->createMock(AspectKernel::class); + $kernel->method('getOptions')->willReturn([ + 'debug' => false, + 'appDir' => self::$appDir, + 'cacheDir' => self::$cacheDir, + 'cacheFileMode' => 0770, + 'features' => 0, + 'includePaths' => [], + 'excludePaths' => [], + 'containerClass' => Container::class, + ]); + $kernel->method('hasFeature')->willReturn(false); + + return new CachePathManager($kernel); + } + + public function testFlushWritesBothFilesAndClassMapLoadsWithoutFullMetadata(): void + { + $original = self::$appDir . '/src/Some.php'; + $transformed = self::$cacheDir . '/src/Some.php'; + $known = self::$appDir . '/src/Untransformed.php'; + + $writer = $this->createManager(); + $writer->registerClassForResource($original, 'App\Some'); + $writer->setCacheState($original, ['filemtime' => 12345, 'cacheUri' => $transformed]); + $writer->registerClassForResource($known, 'App\Untransformed'); + $writer->setCacheState($known, ['filemtime' => 12345, 'cacheUri' => null]); + $writer->flushCacheState(); + + $this->assertFileExists(self::$cacheDir . '/_transformation.cache'); + $this->assertFileExists(self::$cacheDir . '/_include.cache'); + + $reader = $this->createManager(); + // The runtime class map and skip set are available immediately... + $this->assertSame(['App\Some' => $transformed], $reader->queryClassMap()); + $this->assertSame(['App\Untransformed' => true], $reader->querySkippedClasses()); + // ...while the full metadata was not materialized yet (loaded lazily on demand) + $loadedFlag = new ReflectionProperty(CachePathManager::class, 'cacheStateLoaded'); + $this->assertFalse($loadedFlag->getValue($reader), 'Full metadata should not be loaded eagerly'); + + $this->assertSame( + ['filemtime' => 12345, 'cacheUri' => $transformed, 'classes' => ['App\Some']], + $reader->queryCacheState($original) + ); + $this->assertTrue($loadedFlag->getValue($reader)); + } + + public function testLegacyCacheDirectoryWithoutClassMapIsTreatedAsStale(): void + { + $original = self::$appDir . '/src/Legacy.php'; + $transformed = self::$cacheDir . '/src/Legacy.php'; + + $writer = $this->createManager(); + $writer->setCacheState($original, ['filemtime' => 777, 'cacheUri' => $transformed]); + $writer->flushCacheState(); + unlink(self::$cacheDir . '/_include.cache'); + + // Pre-class-map cache directories carry no class names, so the metadata is + // ignored entirely: everything re-weaves once and both files are rewritten + $reader = $this->createManager(); + $this->assertSame([], $reader->queryClassMap()); + $this->assertSame([], $reader->querySkippedClasses()); + $this->assertNull($reader->queryCacheState($original)); + } +}