Files
intotheeast-com/docs/solutions/integration-issues/grav-in-place-header-edit-apcu-cache-stale.md
m038andClaude Opus 4.8 bb2b64bd78 docs(solutions): in-place header edit + APCu cache staleness (Part 3)
Document the trip publish-toggle cache-invalidation finding: an in-place
trip.md `published` edit under cache.check.method: folder + APCu driver stays
stale because the folder checksum is unchanged AND the web APCu store is
unreachable by a CLI clearcache — fixed with apcu_clear_cache() from the web
request. Cross-link the sibling grav-deleteall doc (the create/delete case) as
necessary-but-not-sufficient here, and add the Published/Draft trip status
concept to CONCEPTS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mpdu3Dt1iVoozHwAMyjrbn
2026-07-08 17:17:22 +02:00

8.6 KiB

title, date, category, module, problem_type, component, symptoms, root_cause, resolution_type, related_components, tags, severity
title date category module problem_type component symptoms root_cause resolution_type related_components tags severity
In-place frontmatter edit stays stale under Grav folder-check + APCu cache 2026-07-08 integration-issues entry-actions integration_issue plugin
After unpublishing a trip via the API, anonymous visitors still saw it on /trips, home, and nav
Playwright test TP2 failed: an unpublished trip stayed visible to logged-out users
A prior owner-authenticated GET poisoned the page-tree cache before the toggle, making staleness sticky
"bin/grav clearcache" from the CLI did not bust the stale index at all
"deleteAll()" and "pages->reset() + clearCache('standard')" alone both left the listing stale
incomplete_setup code_fix
cache-on-save
testing_framework
documentation
grav
apcu
cache-invalidation
folder-check
in-place-edit
publish-toggle
page-tree-cache
api-endpoint
high

In-place frontmatter edit stays stale under Grav folder-check + APCu cache

Problem

An owner-only endpoint POST /api/v1/trip/{slug}/publish toggles a trip's visibility by mutating trip.md's published: frontmatter in place — same folder, no folder create or delete — via a header mutation plus $page->save(). After saving it must invalidate Grav's page-tree cache so the /trips listing, the home render, and the nav all reflect the new visibility on the next load.

They don't. After the owner unpublishes a trip, anonymous visitors still see it in the /trips listing and it stays reachable. None of the usual cache-invalidation idioms fix it, and — critically — a CLI bin/grav clearcache cannot bust it at all.

The failure is the interaction of two facts specific to this project's Grav 2.0.4 setup:

  • cache.check.method: folder derives the regular-pages index cache id from a folder-structure checksum. An in-place frontmatter edit leaves the folder structure identical, so the cache id is unchanged and the stale index is reused.
  • cache.driver: auto resolves to APCu (baked into the project's Docker image). APCu lives in the web server's shared memory, so any process outside that web worker — a CLI, a cron job, a docker exec — flushes a different memory segment and cannot reach it.

Symptoms

  • Owner unpublishes a trip → reload the /trips listing as an anonymous visitor → the trip is still present and still reachable.
  • The Playwright spec tests/ui/trip/trip-publish.spec.js (TP2) catches it: unpublish → reload as anon → trip still listed.
  • Staleness is sticky, and worst after a preceding owner-authenticated GET has populated the cache.
  • Running the test harness's docker exec <container> php bin/grav clearcache does not clear it — the trip stays visible.

What Didn't Work

The investigation chain, in order:

  1. $this->grav['cache']->deleteAll() alone (the first half of the sibling create/delete fix). Still stale.
  2. Cache::clearCache(), then $this->grav['pages']->reset() + $this->grav['cache']->clearCache('standard'). Still stale.
  3. CLI bin/grav clearcache (via docker exec, root, a separate PHP process). Could not bust it at all — this was the discriminator that pointed straight at APCu: a separate process owns a separate APCu segment.

Note the documented idiom deleteAll() + Cache::invalidateCache() — which touches system.yaml to bump config->checksum() and thus change the index key — is the correct fix for the create/delete case. It is not enough here: the deciding failure is that the cache store is APCu in web shared memory, unreachable by the CLI, so a key-bump alone leaves the poisoned store in play across the same web worker.

Prior create/delete work on this branch had already climbed most of an escalation ladder and stopped one rung short of this case (session history):

  • deleteAll() was found to clear only the Doctrine cache store, never rebuilding the compiled page-tree index — the original root cause for both the create (BUG-001) and delete flows.
  • touching the dailies/ folder mtime did not flip the stale lookup (suspected Docker bind-mount mtime not propagating), so the pure folder-mtime theory was dropped.
  • A "clear only cache/compiled/pages/" hypothesis was a red herring: there is no such directory — the regular-pages index lives in the Doctrine cache keyed by md5(json_encode(dirs) + folderHash + config->checksum() + lang) (Pages.php).
  • That work standardized on deleteAll() + Cache::invalidateCache() (i.e. touch(system.yaml) + opcache reset) as the canonical pattern — and it was sufficient there because folder-level create/delete advances folderHash. Those sessions never touched APCu at all; the in-place-edit + APCu escalation below is genuinely new.

Solution

Flush APCu from within the web request that performed the edit, in EntryActionsApiController::setTripPublished, right after $page->save():

$header = $page->header();
$header->published = $published;   // KTD1: mutate the HEADER, not $page->published($v) —
                                   // save() serializes from the header
$page->save();

$this->grav['cache']->deleteAll();
if (function_exists('apcu_clear_cache')) {
    apcu_clear_cache();            // flush the WEB server's APCu store directly —
                                   // a CLI clearcache cannot reach it
}
$this->grav['pages']->reset();     // drop the in-memory tree so the next request
                                   // rebuilds from disk
$this->grav['cache']->clearCache('standard');

Verified via curl against the running dev container: unpublish → anon listing count drops to 0; republish → back to 1.

Why This Works

  • apcu_clear_cache() runs inside the same PHP web process that owns the APCu segment, so it actually empties the store the frontend reads. This is the piece a CLI clearcache structurally cannot do.
  • deleteAll() + clearCache('standard') drop the Doctrine/compiled stores.
  • $this->grav['pages']->reset() forces a rebuild from disk on the next request, which re-reads the mutated published flag.

Because the mutation is in place, none of Grav's folder-checksum-based self-healing applies (a folder create/delete would change the checksum and self-heal — which is why new-post and delete flows never hit this). The invalidation must therefore be explicit and must target the web APCu. The earlier Cache::invalidateCache() fix leaned entirely on the config->checksum() term of the index key changing; that still leaves the poisoned APCu store live for the current web worker when the edit is in place.

Prevention

  • When an endpoint mutates page frontmatter in place (publish toggles, metadata edits) under cache.check.method: folder, do not rely on deleteAll() or on a folder-checksum bump. Explicitly flush APCu from the web request, guarded with function_exists('apcu_clear_cache').

  • Never invalidate web APCu from a CLI/cron/docker exec process — it hits a different memory segment. If a CLI must trigger invalidation, it has to go through a web request (curl the endpoint) or a shared driver (file/redis), not APCu.

  • Test-harness corollary: a Playwright helper that clears cache via docker exec ... bin/grav clearcache will not flush web APCu. Fixture folders still appear (folder create bumps the checksum), but in-place/config changes may read stale. Prefer driving the real web endpoint. Cache-mutating E2E specs must run serially (--workers=1); mutating global config (owner_username, active_trip) also collides with parallel readers. See tests/ui/trip/trip-publish.spec.js.

  • On the divergence from the house idiom: two independent code reviewers (reliability, maintainability) flagged that this 4-call sequence diverges from the codebase's documented deleteAll() + Cache::invalidateCache() idiom. The divergence is intentional and specific to in-place-edit + APCu. Pick by case:

    • create/delete → Cache::invalidateCache() (bumps the folder checksum / index key)
    • in-place edit under APCu → apcu_clear_cache() from the web request

    A future improvement is to fold both into one documented helper so future call sites have a single idiom to copy.

  • docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md — the sibling CREATE case: deleteAll() doesn't rebuild the index; fix = Cache::invalidateCache(). This doc is its in-place-edit + APCu counterpart — effectively "Part 3" of that page-tree-cache thread, adding the APCu shared-memory dimension the folder-touch fix did not cover.