Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
39 changes: 29 additions & 10 deletions src/Instrument/ClassLoading/AopComposerLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>
* @var array<class-string, string>
*/
private array $cacheState;
private array $classMap;

/**
* Classes known to the cache but not transformed - served natively by composer
*
* @var array<class-string, true>
*/
private array $skippedClasses;

/**
* Was initialization successful or not
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down Expand Up @@ -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);
}
}
Expand Down
227 changes: 200 additions & 27 deletions src/Instrument/ClassLoading/CachePathManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
lisachenko marked this conversation as resolved.

/** @phpstan-var KernelOptions */
protected array $options;

Expand All @@ -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<string, mixed>
*/
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<class-string, string>
*/
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<class-string, true>
*/
protected array $skippedClasses = [];

/**
* Class names discovered by the weaver per original file, pending until
* setCacheState() folds them into the metadata record
*
* @var array<string, list<class-string>>
*/
private array $pendingClasses = [];

/**
* New metadata items, that was not present in $cacheState
*
Expand Down Expand Up @@ -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<class-string, string>
*/
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<class-string, true>
*/
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
*/
Expand Down Expand Up @@ -146,6 +258,8 @@ public function getCachePathForResource(string $resource)
*/
public function queryCacheState(?string $resource = null): ?array
{
$this->loadCacheState();

if ($resource === null) {
return $this->cacheState;
}
Expand All @@ -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]);
}
}
}

/**
Expand All @@ -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 = '<?php return ' . var_export($fullCacheMap, true) . ';';
$cacheData = strtr(
$cacheData,
[
'\'' . $cachePath => '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<string, mixed> $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 = '<?php return ' . var_export($data, true) . ';';
$cacheData = strtr(
$cacheData,
[
'\'' . $cachePath => '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);
}
}

Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/Instrument/Transformer/WeavingTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion tests/Core/CachedAspectLoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading