Skip to content

[13.x] Bound error page query listener to prevent memory bloat in Octane#59309

Merged
taylorotwell merged 2 commits intolaravel:13.xfrom
JoshSalway:fix/listener-memory-leak
Mar 23, 2026
Merged

[13.x] Bound error page query listener to prevent memory bloat in Octane#59309
taylorotwell merged 2 commits intolaravel:13.xfrom
JoshSalway:fix/listener-memory-leak

Conversation

@JoshSalway
Copy link
Contributor

@JoshSalway JoshSalway commented Mar 21, 2026

Summary

Fixes #56652

The exception renderer's Listener stores unbounded query data (full SQL strings and all bindings) that its consumer — the debug error page — can never meaningfully display. For bulk INSERT statements, a single stored query can exceed 500KB of SQL text with 18,000+ bindings. With 100 queries stored, this causes Octane workers to allocate 140MB+ of memory that PHP's allocator never returns to the OS.

Scope & Safety

This change only modifies Listener::onQueryExecuted() in the exception renderer. It does not affect:

  • Production — The Listener is only registered when APP_DEBUG=true (FoundationServiceProvider:74). When debug is off, this code never runs.
  • Nightwatch, Telescope, Debugbar — These have their own event listeners that read directly from the QueryExecuted event. They never touch the Renderer Listener.
  • DB::listen() / DB::getQueryLog() — Separate systems on the Connection class, unmodified.
  • Normal queries — Any query under 2KB (99.9% of queries) hits a fast path via strlen check and passes through completely untouched.

Root Cause

Three compounding issues in Listener::onQueryExecuted():

  1. No size limit on stored SQL — A bulk INSERT with 6,000 rows generates ~500KB of SQL text. The error page renders this in a max-h-32 overflow-hidden container where only a few hundred characters are visible.

  2. No limit on stored bindings — The same INSERT stores 18,000 bindings. The error page's applicationQueries() substitutes these via per-binding preg_replace('/?/', ...) on the full SQL string — O(n²) complexity that would freeze the browser long before it renders.

  3. Off-by-one in query cap=== 101 stores 101 entries but the template says "only the first 100 queries are displayed".

The memory IS freed between Octane requests (memory_get_usage(false) drops back to baseline), but PHP's memory allocator holds the OS pages, inflating worker RSS permanently. The remaining ~22MB idle after the fix is PHP's allocator retaining pages from the bulk array operations in userland code — this is standard PHP behavior unrelated to the Listener.

The Fix

Three targeted changes to Listener::onQueryExecuted(), no other files modified:

  • Truncate SQL to 2KB when it exceeds that length (mb_strcut for UTF-8 safety). Normal queries pass through untouched. The error page's CSS overflow already handles visual truncation.
  • Trim bindings to match placeholders in the (possibly truncated) SQL. Uses substr_count($sql, '?') so bindings always correspond to the ? placeholders — no dangling unsubstituted placeholders on the error page.
  • Fix off-by-one>= 100 instead of === 101 to match the template's "first 100" message.

Measured Results

With Octane

Tested on Laravel 13 + Octane (FrankenPHP), single worker, APP_DEBUG=true, inserting 630,000 rows (105 batches × 6,000 rows) per request:

Metric Before After Change
Peak allocated (request 1) 148 MB 26 MB -82%
Idle allocated (between requests) 144 MB 22 MB -85%
Peak allocated (request 2) 156 MB 26 MB -83%
Actually used memory (idle) 6.5 MB 6.5 MB No change
Queries stored 101 100 Off-by-one fixed

For Octane workers running in local development, this prevents a single bulk insert request from permanently inflating the worker's memory footprint by 120MB+.

Without Octane

Without Octane (Herd, php artisan serve, PHP-FPM), each request is a fresh process so memory is reclaimed when the process exits. However, the fix still reduces peak memory during a request:

Metric Before After Change
Peak memory during request 74 MB 10 MB -86%

This means fewer Allowed memory size exhausted errors for applications with conservative memory_limit settings.

Testing & Edge Cases

Unit tests (8 tests, 23 assertions)

vendor/bin/phpunit tests/Foundation/Exceptions/Renderer/ListenerTest.php
Test What it verifies
test_queries_returns_expected_shape Existing test — normal query stored correctly (unchanged)
test_listener_caps_at_100_queries 150 queries fired → only first 100 stored
test_large_sql_is_truncated 5000-byte SQL → stored as ≤2000 bytes
test_bindings_match_placeholder_count 500 bindings with truncated SQL → binding count matches ? count in truncated SQL
test_excess_bindings_trimmed 1 placeholder, 1000 bindings → 1 binding stored
test_short_sql_and_bindings_not_modified Normal SELECT with 1 binding → stored exactly as-is
test_query_with_no_bindings_unchanged SELECT count(*) → stored exactly
test_normal_query_skips_truncation SELECT with 3 bindings → all stored, fast path taken

Edge cases considered

Scenario Behavior
Normal SELECT/UPDATE/DELETE (< 2KB) Untouched — fast path, zero overhead
Bulk INSERT with 6,000 rows (> 2KB) SQL truncated, bindings matched to ? count
Query with 0 bindings Stored as-is, no processing
Multi-byte UTF-8 in SQL mb_strcut ensures clean truncation at character boundary
? inside string literals in SQL substr_count may overcount — but applicationQueries() has the same pre-existing behavior
Error page rendering after truncation Bindings correctly substituted, no dangling ? placeholders
100+ queries in a request Capped at 100 (was 101 — off-by-one fixed)
APP_DEBUG=false Listener never registered, this code never runs
Non-Octane environments Memory reclaimed on process exit, but peak is still reduced

Visually verified

The debug error page was inspected in Chrome for:

  • Normal queries → full SQL with all bindings substituted ✓
  • Bulk INSERT after truncation → SQL cuts off cleanly, bindings match ✓
  • Mix of normal + bulk → each renders correctly ✓
  • 120 queries → "1-10 of 100" with pagination ✓
  • Special characters (apostrophes, SQL injection strings) → rendered safely ✓

How to Reproduce & Test

Setup

composer create-project laravel/laravel memory-test
cd memory-test
composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan migrate

Add test route to routes/web.php

Route::get('/import', function () {
    ini_set('memory_limit', '512M');
    $start = memory_get_usage(true) / (1024 * 1024);

    \App\Models\User::truncate();
    for ($i = 0; $i < 105; $i++) {
        $users = \Illuminate\Support\Collection::times(6000)
            ->map(fn($u) => [
                'name' => str_repeat('x', 90),
                'email' => "long-email-{$i}-{$u}@example.org",
                'password' => 'secret',
            ])->all();
        \App\Models\User::query()->insert($users);
    }

    return [
        'start_mb' => round($start, 1),
        'end_mb' => round(memory_get_usage(true) / (1024 * 1024), 1),
        'peak_mb' => round(memory_get_peak_usage(true) / (1024 * 1024), 1),
    ];
});

Route::get('/mem', fn() => [
    'allocated_mb' => round(memory_get_usage(true) / (1024 * 1024), 1),
    'used_mb' => round(memory_get_usage(false) / (1024 * 1024), 1),
]);

Run

php artisan octane:start --workers=1
# In another terminal:
curl http://127.0.0.1:8000/mem      # Baseline: ~10MB allocated
curl http://127.0.0.1:8000/import   # Bulk insert
curl http://127.0.0.1:8000/mem      # Before fix: ~144MB / After: ~22MB

Unit tests

vendor/bin/phpunit tests/Foundation/Exceptions/Renderer/ListenerTest.php

Known Limitations

  • SQL longer than 2KB appears truncated on the debug error page. The page already uses CSS overflow to clip long queries, so the visual impact is minimal.
  • Bindings beyond those matching ? placeholders in the truncated SQL are not stored. This is preferable to the current behavior where applicationQueries() would take minutes to render 18,000 bindings via O(n²) preg_replace.
  • This does not reduce memory_get_usage(true) between requests — that's PHP's allocator holding OS pages, which is expected and standard for any long-lived PHP process. It reduces the peak allocation so fewer pages are claimed in the first place.

Prior Art

Three previous PRs attempted to fix this issue:

This PR takes a narrower approach: the Listener should not store data its consumer cannot display. The error page cannot render 500KB SQL strings (CSS overflow clips them) and cannot efficiently substitute 18,000 bindings (O(n²) preg_replace). Bounding the stored data to what's displayable is a design correction, not a workaround.

@github-actions
Copy link

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.

…kers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@taylorotwell taylorotwell merged commit b3fe63c into laravel:13.x Mar 23, 2026
52 checks passed
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 in Octane on large DB insertions

2 participants