Skip to content

[13.x] Fix Pipeline memory retention in long-lived workers#59415

Closed
JoshSalway wants to merge 1 commit intolaravel:13.xfrom
JoshSalway:fix/pipeline-memory-retention
Closed

[13.x] Fix Pipeline memory retention in long-lived workers#59415
JoshSalway wants to merge 1 commit intolaravel:13.xfrom
JoshSalway:fix/pipeline-memory-retention

Conversation

@JoshSalway
Copy link
Copy Markdown

@JoshSalway JoshSalway commented Mar 27, 2026

Summary

Fixes 🟢 #56395. Related to 🔴 #16783, 🔴 #25999.

Pipeline retains references to $passable and $pipes after then() completes. In long-lived processes (Octane, queue workers, dispatchSync/dispatchNow), this prevents garbage collection of potentially large objects until the Pipeline instance itself is collected.

Changes

Adds cleanup in the finally block of Pipeline::then():

$this->passable = null;
$this->pipes = [];

This runs after the finally callback (which still receives $passable), so existing behavior is preserved. No breaking changes — passable and pipes are only used during pipeline execution. After then() returns, they serve no purpose.

Benchmarks (PHP 8.5.1)

Tested with common job types. Pipeline instance is kept alive after each job completes (simulating Octane/queue workers). Measured with memory_get_usage(true).

Without fix:

Use case Before During After Freed
Email notification 4 MiB 4 MiB 4 MiB -
PDF report (large, embedded images) 4 MiB 28 MiB 28 MiB -
Image processing (thumbnail) 4 MiB 24 MiB 24 MiB -
API sync (10K records) 4 MiB 28 MiB 28 MiB -

With fix:

Use case Before During After Freed
Email notification 4 MiB 4 MiB 4 MiB -
PDF report (large, embedded images) 4 MiB 28 MiB 4 MiB 24 MiB
Image processing (thumbnail) 4 MiB 24 MiB 4 MiB 20 MiB
API sync (10K records) 4 MiB 28 MiB 28 MiB -

Without the fix, "After" matches "During" because Pipeline retains $passable and $pipes after then() completes. With the fix, memory drops back to baseline as references are released in the finally block.

API sync (structured arrays) settles at 28 MiB rather than 4 MiB baseline due to PHP's page allocator retaining freed pages for reuse. The data itself is fully released — this is not a cumulative leak.


Simulated sequential heavy jobs (single reused Pipeline, like a queue worker):

CSV import (100K rows, ~80 MiB), PDF report (50 pages, ~24 MiB), CSV import (80K rows, ~60 MiB).

Without fix:

Job Before start Peak After complete Freed Result
CSV import (100K rows) 4 MiB 86 MiB 86 MiB 0 MiB Memory retained
PDF report (50 pages) 86 MiB 28 MiB 28 MiB 0 MiB Memory released¹
CSV import (80K rows) 28 MiB 66 MiB 66 MiB 0 MiB Memory retained

With fix:

Job Before start Peak After complete Freed Result
CSV import (100K rows) 4 MiB 86 MiB 4 MiB 82 MiB Memory released
PDF report (50 pages) 4 MiB 28 MiB 4 MiB 24 MiB Memory released
CSV import (80K rows) 4 MiB 66 MiB 4 MiB 62 MiB Memory released

¹ Without the fix, the PDF report "releases" memory only because the previous CSV job's larger payload was overwritten by the smaller PDF payload — memory drops from 86 → 28 MiB but that's just the new passable replacing the old one. The 28 MiB is still retained.

Impact

  • HTTP requests: Every request creates two Pipeline instances (global middleware in Kernel and route middleware in Router). Without cleanup, the request object and all middleware instances are retained until the next request overwrites them. With cleanup, they're eligible for GC immediately after the response is sent. This matters most in long-lived processes like Octane where the process persists between requests.
  • Bus Dispatcher: dispatchSync/dispatchNow route through a single Pipeline instance stored in the Dispatcher constructor — it's reused across every dispatch. Without cleanup, the previous command's data is retained until the next dispatch overwrites it.
  • Queue workers: CallQueuedHandler creates a new Pipeline per job, then dispatches through the Bus Dispatcher's reused Pipeline via dispatchNow(). Without cleanup, ghost memory from previous jobs can push workers over memory_limit.
  • No breaking changes: passable and pipes are only used during pipeline execution. After then() returns, they serve no purpose.

Test plan

6 tests added to PipelineTest.php:

  • testPipelineClearsReferencesAfterThen — passable and pipes are null/empty after then()
  • testPipelineClearsReferencesAfterThenReturn — same for thenReturn()
  • testPipelineClearsReferencesAfterException — cleanup happens even when pipeline throws
  • testPipelineFinallyReceivesPassableBeforeCleanup — finally callback gets passable before it's nulled
  • testPipelineAllowsGarbageCollectionAfterThen — WeakReference confirms object is GC'd
  • testPipelineWorksCorrectlyWhenReused — pipeline can be reused after cleanup

Companion fix: #59329 handles the other side — when OOM does happen, an optimistic exception counter ensures the job fails gracefully instead of retrying forever.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

Thanks for submitting a PR!

Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@JoshSalway

This comment was marked as spam.

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.

2 participants