Skip to content

Deleted entries can be resurrected as empty stub files by a long-lived queue worker (Stache/Blink staleness + watcher=false) #15225

Description

@SteBram

Bug description

In a long-lived PHP process (a queue:work worker, or Octane), if Statamic's Stache/Blink
in-process caches have already been populated once (e.g. by any code calling
Entry::all()), and an entry is then deleted from a different process/request, the
already-running worker's in-memory view of that entry does not get invalidated — nothing
forces it to recheck the filesystem, because that recheck only happens when the Stache
file watcher is enabled, and STATAMIC_STACHE_WATCHER defaults to 'auto', which is
already false in production
(app()->isLocal() === false). This is the default
production configuration, not an opt-in edge case.

If that same stale worker later processes any job that touches Entry::all() again (a
scheduled re-warm, a queued content-processing job, etc.), it resolves the deleted entry
from its stale in-memory cache instead of getting null. Calling ->absoluteUrl() (or
similar) on that stale object appears to trigger a write of the in-memory object back to
disk — via ExistsAsFile::writeFile(), which writes unconditionally with no check that a
file currently exists for that entry. The result: a deleted entry reappears on disk as a
new, essentially empty file — a freshly generated random ID, with only id and
blueprint set (no slug, no title, no real content) — and it does not appear anywhere in
the Statamic Control Panel until php artisan statamic:stache:refresh is run, because the
write bypasses Entry::save()'s normal index-sync path.

This is a data-integrity bug: deleted content can reappear on disk unattended, hours or
days after deletion, with no user action other than normal queue processing.

Impact

  • Silent data resurrection: a deleted entry can reappear as a blank/incomplete file with a
    different ID than the one that was deleted, with no corresponding user action.
  • Invisible until stache:refresh: because the write bypasses Entry::save()'s index
    update, the CP shows the entry as correctly deleted until an explicit Stache rebuild,
    which then "discovers" the resurrected file and starts treating it as real content.
  • This is not a rare timing coincidence — it only requires (a) a queue worker that's been
    up for a while and has touched Entry::all() at least once, and (b)
    STATAMIC_STACHE_WATCHER resolving to false, which — per 'auto''s default — is true
    for essentially all production Statamic sites out of the box.

Suggested areas to investigate for a fix

  • ExistsAsFile::writeFile() (or its callers) could check that the entry is still present
    in the Stache's authoritative index / that the file's directory entry still exists
    before writing, particularly for an in-memory object that was never freshly re-read from
    disk in the current process.
  • Structures\Tree::entry()'s permanent Blink memoization could have a mechanism to be
    invalidated by delete events even when the watcher is disabled (e.g. explicitly
    listening for EntryDeleted and clearing the relevant Blink key, rather than relying
    solely on the file watcher).
  • More generally: consider whether any Stache-backed lookup should ever silently persist
    an in-memory object to disk as a side effect of a read-only-looking call like
    absoluteUrl().

How to reproduce

This reproduces with a single throwaway job class and Statamic's own Entry facade —
no custom package code.

  1. In a Statamic project with a pages (or any) collection, add one job class:

    // app/Jobs/StacheReproJob.php
    namespace App\Jobs;
    
    use Illuminate\Bus\Queueable;
    use Illuminate\Contracts\Queue\ShouldQueue;
    use Illuminate\Foundation\Bus\Dispatchable;
    use Statamic\Facades\Entry;
    
    class StacheReproJob implements ShouldQueue
    {
        use Dispatchable, Queueable;
    
        public function handle(): void
        {
            Entry::all()->each(fn ($e) => $e->absoluteUrl());
        }
    }
  2. Create a test entry, e.g. via php artisan tinker:

    $entry = \Statamic\Facades\Entry::make()->collection('pages')->locale('nl')->slug('repro')->data(['title' => 'Repro']);
    $entry->save();
    echo $entry->id(); // note this ID
  3. Start a queue worker with the watcher disabled:

    STATAMIC_STACHE_WATCHER=false php artisan queue:work
    
  4. Prime the worker while the entry still exists — dispatch the job once (from a
    separate tinker/HTTP call) and let the worker process it:

    App\Jobs\StacheReproJob::dispatch();
  5. Delete the entry, from a separate process (a fresh tinker invocation, or via
    the CP):

    \Statamic\Facades\Entry::find('<id-from-step-2>')->delete();

    Confirm the file is gone from content/collections/pages/nl/repro.md.

  6. Dispatch the same job again — it's picked up by the same, already-primed worker
    process:

    App\Jobs\StacheReproJob::dispatch();
  7. Check content/collections/pages/nl/repro.md. In our testing it reappeared, containing
    only:

    ---
    id: <a brand new, different UUID>
    blueprint: pages
    ---

    Note the new entry has a freshly generated ID — different from the one that was
    deleted — and no slug, title, or other data. It will not appear in the Statamic CP's
    entry listing until php artisan statamic:stache:refresh is run, at which point the
    Stache rescans the filesystem, finds this now-real file, and indexes it as a genuine
    (if empty) entry.

We also reproduced this with a real, structured, multi-site news collection using a
production-shaped queue job (a "static cache re-warmer" that lists every published entry
and warms its rendered output) — the same mechanism resurrected a real deleted article as
an incomplete stub. The minimal repro above strips that down to the smallest core-only
case.

Logs

Environment

- Laravel queue worker (`php artisan queue:work`), any queue driver that isn't `sync`
  (confirmed with `redis`)
- `STATAMIC_STACHE_WATCHER=false` (or simply `APP_ENV=production` / `app()->isLocal() ===
  false`, since `'auto'` already resolves to `false` there — see
  `config/statamic/stache.php`'s default and `Stache::isWatcherEnabled()`)


Environment
Application Name: --REDACTED--
Laravel Version: 12.67.0
PHP Version: 8.4.23
Composer Version: 2.10.1
Environment: local
Debug Mode: ENABLED
URL: local-project.test
Maintenance Mode: OFF
Timezone: Europe/Amsterdam
Locale: en

Cache
Config: NOT CACHED
Events: NOT CACHED
Routes: NOT CACHED
Views: CACHED

Drivers
Broadcasting: log
Cache: redis
Database: mysql
Logs: stack / single
Mail: smtp
Queue: redis
Session: file

Storage
public/storage: LINKED

Statamic
Addons: 23
Sites: 5 (Dutch, English, Bulgarian, Polish, Romanian)
Stache Watcher: Enabled
Static Caching: inertia
Version: 5.73.13 PRO

Statamic Addons
jacksleight/statamic-bard-texstyle: 3.6.0
rias/statamic-data-import: 1.5.0
rias/statamic-redirect: 3.14.3
steets/inertia-statamic: 4.4.2
steets/statamic-addon-browser-lang-redirect: 0.1.10
steets/statamic-addon-extend-forms: 0.3.1
steets/statamic-addon-full-sitemap: 0.2.11
steets/statamic-addon-google-address: 0.1.9
steets/statamic-addon-mapbox-address: 0.2.0
steets/statamic-addon-markdown-text-field: 0.1.9
steets/statamic-addon-meilisearch: 0.2.14
steets/statamic-addon-permissions: 0.1.4
steets/statamic-addon-recaptcha: 0.2.6
steets/statamic-addon-responsive-image-field: 0.2.1
steets/statamic-addon-search-index-transformers: 0.2.30
steets/statamic-addon-special-text-field: 0.1.3
steets/statamic-addon-steets-theme: 0.2.4
steets/statamic-addon-structured-data-tag: 0.1.11
steets/statamic-addon-submit-button-field: 0.1.6
steets/statamic-starter-kit: 0.1.19
thoughtco/statamic-cache-tracker: 1.1.0
twithers/icons-plus: 1.1.0
visuellverstehen/statamic-picturesque: 2.2.0

Installation

Starter Kit using via CLI

Additional details

I've encountered this in a custom project where we use a custom static cache (and a custom warmer for that static cache).
Based on dumps of the data encountered during the queue processes we were able to locate the issue in a stale stache, after encountering stale entries during the queue process the entries suddenly re-appeared as almost empty files in the file system.

We've circumvented the issue by triggering a stache:clear(); Blink::flush(); before warming the static cache in our own code, but it looks like this could happen in other cases too.

Therefore I asked Claude to isolate the exact problem to make it reproducable for regular Statamic projects.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions