Skip to content

Optimize Deferred memory usage with incremental queue processing#1790

Merged
spawnia merged 11 commits intomasterfrom
optimize-deferred-memory-usage
Dec 3, 2025
Merged

Optimize Deferred memory usage with incremental queue processing#1790
spawnia merged 11 commits intomasterfrom
optimize-deferred-memory-usage

Conversation

@spawnia
Copy link
Copy Markdown
Collaborator

@spawnia spawnia commented Nov 6, 2025

Summary

This PR optimizes memory usage when using Deferred objects by implementing incremental queue processing. This reduces peak memory usage by ~70% when handling thousands of Deferred objects.

Problem

Issue #972 reported excessive memory usage with Deferred objects. Investigation revealed:

  • 4000 Deferred objects consumed ~54MB peak memory (6.75x baseline of 8MB)
  • This was NOT a memory leak - memory was properly released between requests
  • The issue was peak memory usage during execution

Root Cause

The static SplQueue in SyncPromise accumulated ALL closures before processing. With 4000 Deferred objects, thousands of closures were queued simultaneously, causing high memory usage.

Solution

Implemented incremental queue processing with a batch threshold:

  • Added QUEUE_BATCH_SIZE constant (500 items)
  • When queue exceeds threshold, immediately process a batch
  • This keeps queue size bounded and reduces peak memory

Results

Peak memory usage:

  • Before optimization: ~54MB (6.75x baseline)
  • After optimization: ~16MB (2x baseline)
  • Reduction: 70%

Testing

Added testDeferredMemoryEfficiency() test that:

  • ✅ PASSES with optimization (16MB < 25MB threshold)
  • ❌ FAILS without optimization (48MB > 25MB threshold)
  • Prevents future regressions
  • All 240 existing tests pass

Verification

Tested against original reproduction case from https://github.com/Danbka/php-graphql-deffred showing identical improvements.

Fixes #972

Fixes #972

## Problem

When using Deferred with large datasets (4000+ items), peak memory usage
was excessive:
- WITHOUT Deferred: 8MB peak memory
- WITH Deferred: 54MB peak memory
- **Overhead: 6.75x increase (46MB excess)**

This caused out-of-memory errors in production environments with
constrained memory limits.

## Investigation & Findings

Initial hypothesis was that closures were leaking memory through captured
references. However, testing showed:
1. Memory WAS being released after execution (no leak)
2. Peak memory during execution was the actual problem
3. The issue was architectural, not a reference cleanup problem

Root cause: The static `SplQueue` in `SyncPromise::getQueue()` accumulated
ALL 4000+ closures before `runQueue()` processed them. Each Deferred
object creates a closure that's queued immediately, resulting in thousands
of closures held in memory simultaneously.

## Solution

Implement **incremental queue processing** with a batch size threshold:
- When queue size exceeds 500 items, process a batch immediately
- This keeps the queue size bounded, reducing peak memory
- Processing happens during promise creation rather than only at the end
- No API changes required - fully backward compatible

## Results

Memory usage with 4000 Deferred objects:
- **Before:** 54MB peak (6.75x baseline)
- **After:** 16MB peak (2x baseline)
- **Improvement: 70% reduction in peak memory usage**

## Implementation

1. Added `QUEUE_BATCH_SIZE` constant (500 items)
2. Added `processBatch()` method to process queue incrementally
3. Added threshold checks after `enqueue()` operations to trigger batching
4. Added `testDeferredMemoryEfficiency()` with proper verification:
   - **PASSES** with optimization: 16MB < 25MB threshold ✓
   - **FAILS** without optimization: 48MB > 25MB threshold ✗

All existing tests pass (240 tests, 1196 assertions).

## Testing Methodology

We verified this fix using the original reproduction case from
https://github.com/Danbka/php-graphql-deffred:
- Tested with actual 4000-item dataset
- Measured peak memory with and without optimization
- Confirmed 70% memory reduction
- Ensured no performance regression

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@spawnia spawnia requested a review from ruudk November 6, 2025 10:22
Copy link
Copy Markdown
Collaborator

@ruudk ruudk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a hard time understanding it. But since you measured it and have it tested, I would say let's go!

@spawnia spawnia merged commit 3402c86 into master Dec 3, 2025
18 checks passed
@spawnia spawnia deleted the optimize-deferred-memory-usage branch December 3, 2025 07:34
Comment on lines +243 to +251
foreach (range(1, $batchSize) as $_) {
if ($queue->isEmpty()) {
break;
}

$task = $queue->dequeue();
$task();
unset($task);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having to create the temporary variable $_, this could be written as:

while ($batchSize-- && !$queue->isEmpty()) {
    /* ... */
}

@zimzat
Copy link
Copy Markdown
Contributor

zimzat commented Dec 3, 2025

The 500 limit is going to impact how downstream batching is handled. e.g. If I'm returning a list of 500 items and each item has two Deferred items on it then it's going to trigger the batch loading protocol to run four+ partial batches instead of just two full batches. The multiplicative nature of that would be worse for queries that return multiple lists of deferred batches (e.g. 2+ sets 100 items that have 6 defers would trigger 18 batches of 84, 84, and 32 items).

We may offer an option to adjust this value in the future.

I would find this useful; I'd probably set it to 1000 or 2000. 16MB of memory usage is really tiny in comparison to the amount of memory that the application itself is likely to hold for the data being returned.

Unless I'm making some incorrect assumptions on how those mechanisms work together, in which case this isn't an actual problem? 🤷

spawnia added a commit that referenced this pull request Dec 5, 2025
spawnia added a commit that referenced this pull request Dec 5, 2025
Resolves #1803

Follow up to #1790
@spawnia
Copy link
Copy Markdown
Collaborator Author

spawnia commented Dec 5, 2025

Thank you @zimzat and @shadowhand for your feedback. I am continuing work in #1805 and will take your comments into consideration.

@spawnia
Copy link
Copy Markdown
Collaborator Author

spawnia commented Dec 18, 2025

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.

Using Deferred produces memory leak?

4 participants