Skip to content

Fix memory leak: stamp $raw in the endpoint layer instead of a Valinor converter#8

Merged
loevgaard merged 1 commit into
1.xfrom
fix-raw-stamper-memory-leak
Jun 12, 2026
Merged

Fix memory leak: stamp $raw in the endpoint layer instead of a Valinor converter#8
loevgaard merged 1 commit into
1.xfrom
fix-raw-stamper-memory-leak

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Fixes #7

Problem

Long-running processes (e.g. Symfony Messenger workers) leaked ~70KB per mapped object, eventually hitting the PHP memory_limit. Root cause is upstream CuyZ/Valinor#800: with a converter registered, ValueConverterNodeBuilder::unstack() calls Reflection::function() per converted value node, and Closure::fromCallable() on a non-\Closure callable (our invokable RawStamper) creates a fresh closure each call — so the memoized ReflectionFunctions accumulate forever in a static cache GC cannot touch.

One nuance found during verification: the leak requires a non-Closure callable. Closure::fromCallable() on an existing \Closure returns the same instance, so plain closure converters get a stable cache key and don't leak (they still pay per-node reflection overhead).

Fix

  • Remove ->registerConverter(new RawStamper()) from Client::configureMapperBuilder() and delete src/Mapper/RawStamper.php.
  • Stamp $raw in ResourceEndpoint::mapResource() — the single funnel behind getOne()/createOne()/updateOne()/getPage()/paginate() — via a new recursive walker (src/Response/RawStamper.php).
  • The walker preserves the converter's polymorphic behavior exactly: the top-level Resource gets the full decoded body; every nested Resource gets its slice, matched by property name and list position — including through non-Resource DTOs (Order::$customer, Collection<T> items, Line::$product, the recursive Customer → CustomerContact → Customer graph). All pre-existing $raw tests pass unchanged, and Payload::fromResponse() on embedded resources keeps working.
  • Bonus: $raw stamping is now builder-independent, so it works even when a consumer passes a bare MapperBuilder. The do-not-register-converters constraint is documented on configureMapperBuilder(), in the README production section, and in CLAUDE.md.
  • The PHPStan purity-suppressor include stays (the issue suggested it could be dropped with the converter): PHPStan ignores @param docblocks on closures entirely, so the remaining pure-in-practice registrations (Payload null-stripper, Identifier::fromReference()) can't be proven pure. The comment in phpstan.neon.dist now explains the real reason.

Tests

  • tests/Client/MapperMemoryLeakTest.php — regression guard: 50 collection pages must grow memory_get_usage() by < 1MB. Memory-based rather than reflecting into Valinor's private static cache, so it survives Valinor refactors and catches any reintroduced per-mapping growth. Verified to discriminate: temporarily re-adding an invokable no-op converter fails it with ~33MB growth.
  • tests/Response/RawStamperTest.php — 10 direct unit tests covering every walker branch (nested resources, descent through non-Resource DTOs, recursive graph, missing/non-array/misaligned slices, literal raw key). 100% MSI on the walker.

Verification

composer phpunit (244 tests), composer analyse (PHPStan level max), composer check-style, vendor/bin/rector --dry-run, and vendor/bin/infection (MSI +19pp over threshold) all pass.

…alinor converter

Long-running processes leaked ~70KB per mapped object: with a non-Closure
converter registered (the invokable RawStamper), Valinor's converter pipeline
caches a fresh ReflectionFunction + closure per converted value node in a
static array GC cannot touch (CuyZ/Valinor#800).

Remove the converter and stamp Resource::$raw in
ResourceEndpoint::mapResource() via a recursive walker
(src/Response/RawStamper.php) that preserves the polymorphic behavior
exactly: the top-level Resource gets the full decoded body; nested Resources
(Order::$customer, Line::$product, Collection items) get their slice, matched
by property name and list position.

- $raw stamping is now builder-independent — it works even with a bare
  MapperBuilder
- Add a memory regression test (50 pages must grow memory < 1MB; re-adding an
  invokable no-op converter makes it fail with ~33MB growth)
- Add direct unit tests for the walker (100% MSI)
- Document the do-not-register-converters constraint in
  Client::configureMapperBuilder(), README and CLAUDE.md

Fixes #7
@loevgaard loevgaard force-pushed the fix-raw-stamper-memory-leak branch from f5d3d7b to fec165d Compare June 12, 2026 07:04
@loevgaard loevgaard merged commit 4cf94b1 into 1.x Jun 12, 2026
7 checks passed
@loevgaard loevgaard deleted the fix-raw-stamper-memory-leak branch June 12, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak: ~70KB retained per mapped object when iterating large collections (RawStamper × Valinor's Reflection::function() cache)

1 participant