diff --git a/CONCEPTS.md b/CONCEPTS.md index 59a422b..9333058 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -14,6 +14,11 @@ A single journey the blog is organised around — the top-level content entity. ### Active Trip The one Trip currently featured — set in site config and read by the home page and the post form. Switching the Active Trip is a deliberate, multi-file change; if the post form's target and the featured Trip fall out of sync, new posts land under the wrong Trip. +### Published / Draft +A Trip's visibility state. A **Published** Trip is listed publicly and reachable by anyone; a **Draft** Trip is hidden from anonymous visitors in the public trip list, while the signed-in owner still sees it (marked "Draft") and can flip it back. The owner toggles this per Trip from the trip list. + +Unpublishing the **Active Trip** additionally drops it from the public home page, which falls back to its between-trips landing. The toggle is owner-only; a Draft is a visibility control, not privacy — a Draft Trip's Entries, Stories, and media stay reachable by direct link. + ### Entry A single dated journal post within a Trip — the atomic unit of the day-to-day travel log. *Avoid:* daily, journal post diff --git a/docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md b/docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md index 53c7766..860b9e6 100644 --- a/docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md +++ b/docs/solutions/integration-issues/grav-deleteall-doesnt-invalidate-page-tree-index.md @@ -99,3 +99,15 @@ config, intentionally not committed as `true`), so both specs **skip loudly** with a reason rather than fail misleadingly. They validate whenever the site is in travelling mode. This is a known gap in this environment, not a silent hole — provisioning `travelling: true` in a dedicated test config would close it. + +## Related — Part 3: in-place edits + APCu + +The `Cache::invalidateCache()` fix above completes `deleteAll()` for the +**create/delete** case, because a new or removed child folder advances +`folderHash` and the `system.yaml` touch bumps `config->checksum()`. It is +**necessary but not sufficient** for an **in-place frontmatter edit** (e.g. a +trip publish toggle) under `cache.driver: auto` (APCu): the folder structure is +unchanged, and APCu lives in web-server shared memory that a CLI `bin/grav +clearcache` cannot reach. That case additionally requires `apcu_clear_cache()` +called from the web request. See +[`grav-in-place-header-edit-apcu-cache-stale.md`](grav-in-place-header-edit-apcu-cache-stale.md). diff --git a/docs/solutions/integration-issues/grav-in-place-header-edit-apcu-cache-stale.md b/docs/solutions/integration-issues/grav-in-place-header-edit-apcu-cache-stale.md new file mode 100644 index 0000000..6c02e8e --- /dev/null +++ b/docs/solutions/integration-issues/grav-in-place-header-edit-apcu-cache-stale.md @@ -0,0 +1,112 @@ +--- +title: "In-place frontmatter edit stays stale under Grav folder-check + APCu cache" +date: 2026-07-08 +category: integration-issues +module: entry-actions +problem_type: integration_issue +component: plugin +symptoms: + - "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" +root_cause: incomplete_setup +resolution_type: code_fix +related_components: + - cache-on-save + - testing_framework + - documentation +tags: + - grav + - apcu + - cache-invalidation + - folder-check + - in-place-edit + - publish-toggle + - page-tree-cache + - api-endpoint +severity: 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 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. +- `touch`ing 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()`: + +```php +$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. + +## Related Issues + +- `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.