From 42d6a2584f096ac285a9642deab2ff6173c09cae Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 8 Oct 2026 16:23:09 +0700 Subject: [PATCH 1/2] perf: Halve coordinator memory by adding __unserialize() to analysis nodes --- src/Analyser/AnonymousClassNode.php | 1 + src/Analyser/AnonymousFunctionNode.php | 1 + src/Analyser/ClassNode.php | 1 + src/Analyser/ConstantNode.php | 2 + src/Analyser/EnumCaseNode.php | 2 + src/Analyser/FileAnalysis.php | 2 + src/Analyser/FunctionNode.php | 1 + src/Analyser/MethodNode.php | 2 + src/Analyser/PropertyNode.php | 2 + src/Analyser/PropertyUnserializeTrait.php | 26 ++++ .../Analyser/PropertyUnserializeTraitTest.php | 131 ++++++++++++++++++ 11 files changed, 171 insertions(+) create mode 100644 src/Analyser/PropertyUnserializeTrait.php create mode 100644 tests/Analyser/PropertyUnserializeTraitTest.php diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index 8ad79ea0..d7e3def4 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -32,6 +32,7 @@ final class AnonymousClassNode { use MemberQueryTrait; use NodeQueryTrait; + use PropertyUnserializeTrait; use RecursiveParentsTrait; /** diff --git a/src/Analyser/AnonymousFunctionNode.php b/src/Analyser/AnonymousFunctionNode.php index 08f59e01..e24bd417 100644 --- a/src/Analyser/AnonymousFunctionNode.php +++ b/src/Analyser/AnonymousFunctionNode.php @@ -19,6 +19,7 @@ final class AnonymousFunctionNode { use NodeQueryTrait; + use PropertyUnserializeTrait; /** * Scope label reported by {@see enclosingScopeName()} for an anonymous diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 26e0851c..2a1ca7b7 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -17,6 +17,7 @@ final class ClassNode use MemberQueryTrait; use NameQueryTrait; use NodeQueryTrait; + use PropertyUnserializeTrait; use RecursiveParentsTrait; /** @var list */ diff --git a/src/Analyser/ConstantNode.php b/src/Analyser/ConstantNode.php index 5b6cc906..b1f846fd 100644 --- a/src/Analyser/ConstantNode.php +++ b/src/Analyser/ConstantNode.php @@ -6,6 +6,8 @@ final readonly class ConstantNode { + use PropertyUnserializeTrait; + public function __construct( public string $name, public string $visibility = 'public', diff --git a/src/Analyser/EnumCaseNode.php b/src/Analyser/EnumCaseNode.php index 5309b429..7523a258 100644 --- a/src/Analyser/EnumCaseNode.php +++ b/src/Analyser/EnumCaseNode.php @@ -6,6 +6,8 @@ final readonly class EnumCaseNode { + use PropertyUnserializeTrait; + /** * @param int|string|null $value Statically resolved backing value of the case. Null for * a pure enum case, and also for a backed case whose value diff --git a/src/Analyser/FileAnalysis.php b/src/Analyser/FileAnalysis.php index 3297a14d..b22f4341 100644 --- a/src/Analyser/FileAnalysis.php +++ b/src/Analyser/FileAnalysis.php @@ -6,6 +6,8 @@ final readonly class FileAnalysis { + use PropertyUnserializeTrait; + /** * @param list $nonCanonicalKeywordConstants `true`, `false`, and `null` * fetches not spelled in lowercase, diff --git a/src/Analyser/FunctionNode.php b/src/Analyser/FunctionNode.php index 37bb08ca..cb095f84 100644 --- a/src/Analyser/FunctionNode.php +++ b/src/Analyser/FunctionNode.php @@ -18,6 +18,7 @@ final class FunctionNode { use NameQueryTrait; use NodeQueryTrait; + use PropertyUnserializeTrait; /** @var list */ public readonly array $layers; diff --git a/src/Analyser/MethodNode.php b/src/Analyser/MethodNode.php index 6b3ad39a..ebfdbde8 100644 --- a/src/Analyser/MethodNode.php +++ b/src/Analyser/MethodNode.php @@ -8,6 +8,8 @@ final readonly class MethodNode { + use PropertyUnserializeTrait; + public function __construct( public string $name, public string $visibility, // public, protected, private diff --git a/src/Analyser/PropertyNode.php b/src/Analyser/PropertyNode.php index 9b462111..98ece48f 100644 --- a/src/Analyser/PropertyNode.php +++ b/src/Analyser/PropertyNode.php @@ -6,6 +6,8 @@ final readonly class PropertyNode { + use PropertyUnserializeTrait; + public function __construct( public string $name, public string $visibility, diff --git a/src/Analyser/PropertyUnserializeTrait.php b/src/Analyser/PropertyUnserializeTrait.php new file mode 100644 index 00000000..e902082e --- /dev/null +++ b/src/Analyser/PropertyUnserializeTrait.php @@ -0,0 +1,26 @@ + $data */ + public function __unserialize(array $data): void + { + foreach ($data as $property => $value) { + $this->{$property} = $value; + } + } +} diff --git a/tests/Analyser/PropertyUnserializeTraitTest.php b/tests/Analyser/PropertyUnserializeTraitTest.php new file mode 100644 index 00000000..b080c5e9 --- /dev/null +++ b/tests/Analyser/PropertyUnserializeTraitTest.php @@ -0,0 +1,131 @@ +assertInstanceOf($node::class, $copy); + $this->assertEquals($node, $copy); + } + + /** @return iterable */ + public static function nodes(): iterable + { + yield 'method' => [new MethodNode('handle', 'protected', true, true, 2, 3, 12, true, 7, false)]; + yield 'property' => [new PropertyNode('name', 'private', true, 4)]; + yield 'constant' => [new ConstantNode('LIMIT', 'public', true, 3)]; + yield 'enum case' => [new EnumCaseNode('Active', 5, 'active')]; + yield 'file analysis' => [ + new FileAnalysis( + file: '/src/Foo.php', + hasUtf8Bom: false, + hasValidUtf8: true, + invalidPhpTagLine: null, + hasValidAst: true, + declaresSymbols: true, + hasSideEffects: true, + sideEffectLine: 9, + nonCanonicalKeywordConstants: [[9, 'TRUE']], + numericLiterals: [[11, '1000000', 1000000]], + ), + ]; + yield 'class' => [ + new ClassNode( + className: 'App\Domain\Order', + file: '/src/Domain/Order.php', + line: 5, + layer: 'Domain', + extends: 'App\Domain\AggregateRoot', + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + dependencies: ['App\Domain\Money', 'App\Domain\OrderLine'], + implements: ['JsonSerializable'], + traits: ['App\Domain\HasEvents'], + methods: [new MethodNode('total', 'public', true, false, 0, 2, 6, true, 12)], + constants: [new ConstantNode('STATUS_NEW', 'public', true, 7)], + properties: [new PropertyNode('lines', 'private', true, 9)], + functionCalls: ['array_sum'], + superglobals: ['$_SERVER'], + languageConstructs: ['exit'], + layers: ['Domain', 'Core'], + enumCases: [new EnumCaseNode('Draft', 8, 'draft')], + nonClassDependencies: ['App\Domain\helper'], + constantFetches: ['PHP_EOL'], + ), + ]; + yield 'function' => [ + new FunctionNode( + functionName: 'App\Support\format', + file: '/src/Support/functions.php', + line: 3, + layer: 'Support', + hasReturnType: true, + paramCount: 1, + cyclomaticComplexity: 2, + lineCount: 8, + dependencies: ['App\Support\Formatter'], + functionCalls: ['sprintf'], + layers: ['Support'], + isReferenced: true, + ), + ]; + yield 'anonymous class' => [ + new AnonymousClassNode( + file: '/src/Infrastructure/Clock.php', + line: 14, + extends: null, + implements: ['App\Domain\Clock'], + layer: 'Infrastructure', + enclosingClassName: 'App\Infrastructure\ClockFactory', + layers: ['Infrastructure'], + dependencies: ['DateTimeImmutable'], + methods: [new MethodNode('now', 'public', true, false, 0, 1, 3, true, 16)], + ), + ]; + yield 'anonymous function' => [ + new AnonymousFunctionNode( + file: '/src/Infrastructure/ClockFactory.php', + line: 20, + layer: 'Infrastructure', + isArrowFunction: true, + isStatic: true, + enclosingClassName: 'App\Infrastructure\ClockFactory', + enclosingFunctionName: 'create', + paramCount: 1, + dependencies: ['DateTimeImmutable'], + layers: ['Infrastructure'], + ), + ]; + } +} From e30654d4295a5aa11e942c358fe910e38a9a1a22 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 8 Oct 2026 16:25:20 +0700 Subject: [PATCH 2/2] clean up comment --- src/Analyser/PropertyUnserializeTrait.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Analyser/PropertyUnserializeTrait.php b/src/Analyser/PropertyUnserializeTrait.php index e902082e..4cc7d675 100644 --- a/src/Analyser/PropertyUnserializeTrait.php +++ b/src/Analyser/PropertyUnserializeTrait.php @@ -5,14 +5,10 @@ namespace Boundwize\StructArmed\Analyser; /** - * Assigns unserialized data straight into the declared properties. - * - * Without __unserialize(), unserialize() writes every property through the - * object's property hash table, which it builds and then keeps for the - * object's lifetime: about 1.7 KB for a ten-property node against 350 bytes - * for the declared slots alone. The analysis-node graph crosses from the - * parallel workers to the coordinator through serialize(), so on a large - * project that table costs the coordinator more than the graph itself. + * Without __unserialize(), unserialize() builds a property hash table on every + * object and keeps it, which roughly doubles what a node graph costs. Worker + * results reach the coordinator through serialize(), so on a large project + * this halves what the coordinator holds. */ trait PropertyUnserializeTrait {