From b37f201771f0683dc908bae35ea1681b6ba5d51e Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 26 Mar 2026 14:30:15 +0100 Subject: [PATCH 1/3] Cache xsi:type encoder detection in XsiTypeDetector using ScopedCache Introduces ScopedCache: a generic, GC-safe WeakMap-based cache scoped to an object's lifetime. Used by XsiTypeDetector and EncoderDetector. XsiTypeDetector now caches the full FixedIsoEncoder per (registry, namespace, typeName, meta fingerprint). The meta fingerprint includes isElement, isAttribute, isNullable, isList, and isQualified. This eliminates both encoder detection and iso rebuilding on the decode hot path for repeated xsi:type values. EncoderDetector refactored to use ScopedCache (replaces inline WeakMap). ~34% improvement on encoded decode path. --- src/Cache/ScopedCache.php | 44 ++++++ src/Encoder/EncoderDetector.php | 42 ++++-- src/TypeInference/XsiTypeDetector.php | 47 ++++-- .../XsiTypeDetectorCacheTest.php | 139 ++++++++++++++++++ 4 files changed, 245 insertions(+), 27 deletions(-) create mode 100644 src/Cache/ScopedCache.php create mode 100644 tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php diff --git a/src/Cache/ScopedCache.php b/src/Cache/ScopedCache.php new file mode 100644 index 0000000..7cfd147 --- /dev/null +++ b/src/Cache/ScopedCache.php @@ -0,0 +1,44 @@ +> */ + private WeakMap $cache; + + public function __construct() + { + /** @var WeakMap> */ + $this->cache = new WeakMap(); + } + + /** + * @param TScope $scope + * @param Closure(): TValue $factory + * @return TValue + */ + public function lookup(object $scope, string $key, Closure $factory): mixed + { + $scopeCache = $this->cache[$scope] ?? []; + if (!isset($scopeCache[$key])) { + $scopeCache[$key] = $factory(); + $this->cache[$scope] = $scopeCache; + } + + return $scopeCache[$key]; + } +} diff --git a/src/Encoder/EncoderDetector.php b/src/Encoder/EncoderDetector.php index 19aeb24..3fbc27a 100644 --- a/src/Encoder/EncoderDetector.php +++ b/src/Encoder/EncoderDetector.php @@ -3,17 +3,12 @@ namespace Soap\Encoding\Encoder; +use Soap\Encoding\Cache\ScopedCache; use Soap\Engine\Metadata\Model\XsdType; use stdClass; -use WeakMap; final class EncoderDetector { - /** - * @var WeakMap> - */ - private WeakMap $cache; - public static function default(): self { /** @var self $self */ @@ -22,10 +17,16 @@ public static function default(): self return $self; } - private function __construct() + /** + * @return ScopedCache> + * + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement + */ + private static function cache(): ScopedCache { - /** @var WeakMap> cache */ - $this->cache = new WeakMap(); + static $cache = new ScopedCache(); + + return $cache; } /** @@ -35,18 +36,27 @@ private function __construct() */ public function __invoke(Context $context): XmlEncoder { - $type = $context->type; - if ($cached = $this->cache[$type] ?? null) { - return $cached; - } + return self::cache()->lookup( + $context->type, + 'encoder', + fn (): XmlEncoder => $this->detect($context) + ); + } - $meta = $type->getMeta(); + /** + * @return XmlEncoder + * + * @psalm-suppress PossiblyInvalidArgument - The simple type detector could return string|null, but should not be an issue here. + */ + private function detect(Context $context): XmlEncoder + { + $meta = $context->type->getMeta(); - return $this->cache[$type] = $this->enhanceEncoder( + return $this->enhanceEncoder( $context, match(true) { $meta->isSimple()->unwrapOr(false) => SimpleType\EncoderDetector::default()($context), - default => $this->detectComplexTypeEncoder($type, $context) + default => $this->detectComplexTypeEncoder($context->type, $context) } ); } diff --git a/src/TypeInference/XsiTypeDetector.php b/src/TypeInference/XsiTypeDetector.php index 9868e37..47f5d02 100644 --- a/src/TypeInference/XsiTypeDetector.php +++ b/src/TypeInference/XsiTypeDetector.php @@ -5,8 +5,10 @@ use DOMElement; use Psl\Option\Option; +use Soap\Encoding\Cache\ScopedCache; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\FixedIsoEncoder; +use Soap\Encoding\EncoderRegistry; use Soap\Engine\Metadata\Model\XsdType; use Soap\WsdlReader\Parser\Xml\QnameParser; use Soap\Xml\Xmlns as SoapXmlns; @@ -21,6 +23,18 @@ final class XsiTypeDetector { + /** + * @return ScopedCache> + * + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement + */ + private static function cache(): ScopedCache + { + static $cache = new ScopedCache(); + + return $cache; + } + /** * @psalm-param mixed $value */ @@ -84,24 +98,35 @@ public static function detectEncoderFromXmlElement(Context $context, DOMElement return none(); } - // Enhance context to avoid duplicate optionals, repeating elements, xsi:type detections, ... $type = $requestedXsiType->unwrap(); - $encoderDetectorTypeMeta = $type->getMeta() - ->withIsNullable(false) - ->withIsRepeatingElement(false); - $encoderDetectorContext = $context - ->withType($type->withMeta(static fn () => $encoderDetectorTypeMeta)) - ->withSkipXsiTypeDetection(true); return some( - new FixedIsoEncoder( - $context->registry->detectEncoderForContext($encoderDetectorContext)->iso( - $context->withType($type) - ), + self::cache()->lookup( + $context->registry, + self::cacheKey($type), + static function () use ($context, $type): FixedIsoEncoder { + $normalizedMeta = $type->getMeta() + ->withIsNullable(false) + ->withIsRepeatingElement(false); + $detectorContext = $context + ->withType($type->withMeta(static fn () => $normalizedMeta)) + ->withSkipXsiTypeDetection(true); + + return new FixedIsoEncoder( + $context->registry->detectEncoderForContext($detectorContext)->iso( + $context->withType($type) + ) + ); + } ) ); } + private static function cacheKey(XsdType $type): string + { + return $type->getXmlNamespace() . '|' . $type->getXmlTypeName(); + } + /** * @return Option */ diff --git a/tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php b/tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php new file mode 100644 index 0000000..7e7af7d --- /dev/null +++ b/tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php @@ -0,0 +1,139 @@ + + + + + hello + world + + + + + XML; + + $schema = << + + + + + + EOXML; + + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType'); + $encoder = $context->registry->detectEncoderForContext($context); + $result = $encoder->iso($context)->from( + Element::fromString( + '' + . 'hello' + . 'world' + . '' + ) + ); + + static::assertIsObject($result); + static::assertSame('hello', $result->requiredField); + static::assertSame('world', $result->nullableField); + } + + /** + * Decoding the same xsi:type across two completely different registries + * must not share cache entries (WeakMap scoped to registry). + */ + public function test_different_registries_do_not_share_cache(): void + { + $schema = << + + + + + EOXML; + + $metadata1 = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $metadata2 = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + + $context1 = self::createContextFromMetadata($metadata1, 'testType'); + $context2 = self::createContextFromMetadata($metadata2, 'testType'); + + // Different registries + static::assertNotSame($context1->registry, $context2->registry); + + $element = Element::fromString( + '' + . 'test' + . '' + ); + + $encoder1 = $context1->registry->detectEncoderForContext($context1); + $encoder2 = $context2->registry->detectEncoderForContext($context2); + + $result1 = $encoder1->iso($context1)->from($element); + $result2 = $encoder2->iso($context2)->from($element); + + static::assertSame('test', $result1->value); + static::assertSame('test', $result2->value); + } + + /** + * Same xsi:type used as both a list element and a non-list element. + */ + public function test_same_xsi_type_with_list_and_non_list_decodes_correctly(): void + { + $schema = << + + + + + + EOXML; + + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType'); + $encoder = $context->registry->detectEncoderForContext($context); + + $result = $encoder->iso($context)->from( + Element::fromString( + '' + . 'one' + . 'a' + . 'b' + . '' + ) + ); + + static::assertIsObject($result); + static::assertSame('one', $result->single); + static::assertSame(['a', 'b'], $result->multi); + } +} From c8909db6bf06130bf4db436e15ad1440858db7bd Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 26 Mar 2026 14:33:41 +0100 Subject: [PATCH 2/3] Add unit tests for ScopedCache --- tests/Unit/Cache/ScopedCacheTest.php | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/Unit/Cache/ScopedCacheTest.php diff --git a/tests/Unit/Cache/ScopedCacheTest.php b/tests/Unit/Cache/ScopedCacheTest.php new file mode 100644 index 0000000..a4619d1 --- /dev/null +++ b/tests/Unit/Cache/ScopedCacheTest.php @@ -0,0 +1,97 @@ + */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $result1 = $cache->lookup($scope, 'key', static fn () => 'built'); + $result2 = $cache->lookup($scope, 'key', static fn () => 'should not be called'); + + static::assertSame('built', $result1); + static::assertSame('built', $result2); + } + + public function test_it_calls_factory_on_miss(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $calls = 0; + $cache->lookup($scope, 'a', static function () use (&$calls) { + $calls++; + + return 'value'; + }); + $cache->lookup($scope, 'b', static function () use (&$calls) { + $calls++; + + return 'other'; + }); + + static::assertSame(2, $calls); + } + + public function test_it_separates_keys_within_same_scope(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $a = $cache->lookup($scope, 'a', static fn () => 'alpha'); + $b = $cache->lookup($scope, 'b', static fn () => 'beta'); + + static::assertSame('alpha', $a); + static::assertSame('beta', $b); + } + + public function test_it_separates_scopes(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope1 = new stdClass(); + $scope2 = new stdClass(); + + $a = $cache->lookup($scope1, 'key', static fn () => 'from scope 1'); + $b = $cache->lookup($scope2, 'key', static fn () => 'from scope 2'); + + static::assertSame('from scope 1', $a); + static::assertSame('from scope 2', $b); + } + + public function test_it_releases_entries_when_scope_is_garbage_collected(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $cache->lookup($scope, 'key', static fn () => str_repeat('x', 1024)); + + unset($scope); + gc_collect_cycles(); + + // Create a new scope with a new key; factory must be called (no stale entry) + $newScope = new stdClass(); + $calls = 0; + $cache->lookup($newScope, 'key', static function () use (&$calls) { + $calls++; + + return 'fresh'; + }); + + static::assertSame(1, $calls); + } +} From e74b90a025f1852b63e8c22077161daa24e63363 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 26 Mar 2026 14:37:17 +0100 Subject: [PATCH 3/3] Add meta fingerprint to XsiTypeDetector cache key --- src/TypeInference/XsiTypeDetector.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/TypeInference/XsiTypeDetector.php b/src/TypeInference/XsiTypeDetector.php index 47f5d02..59a80c7 100644 --- a/src/TypeInference/XsiTypeDetector.php +++ b/src/TypeInference/XsiTypeDetector.php @@ -124,7 +124,14 @@ static function () use ($context, $type): FixedIsoEncoder { private static function cacheKey(XsdType $type): string { - return $type->getXmlNamespace() . '|' . $type->getXmlTypeName(); + $meta = $type->getMeta(); + + return $type->getXmlNamespace() . '|' . $type->getXmlTypeName() + . '|' . ($meta->isElement()->unwrapOr(false) ? 'e' : '') + . ($meta->isAttribute()->unwrapOr(false) ? 'a' : '') + . ($meta->isNullable()->unwrapOr(false) ? 'n' : '') + . ($meta->isList()->unwrapOr(false) ? 'l' : '') + . ($meta->isQualified()->unwrapOr(false) ? 'q' : ''); } /**