# Admin Dual-Layer Cache Design

Audience: AI coding agents first.

## Outcome

Make eligible International Press Zone admin reads paint browser snapshots immediately and receive plugin snapshots without rerunning expensive controller SQL. Serve soft-stale plugin snapshots immediately; refresh them asynchronously. Preserve authorization, isolation, invalidation, and live-data exclusions.

## Existing Baseline

Reuse these shipped seams. NEVER create parallel cache infrastructure.

- `admin/src/utils/DataStore.js`: scoped memory/localStorage stale-while-revalidate.
- `includes/API/RestResponseCache.php`: permission-safe REST short circuit after route permission callback.
- `includes/Performance/AdminDataPolicy.php`: canonical browser/server cache policy.
- `includes/Performance/CacheInvalidation.php`: generation-token invalidation.
- `includes/Performance/ObjectCache.php`: Redis, Memcached, transient fallback.

Current fresh plugin hits already bypass controller callbacks. Current expiry is hard-only: a miss or expired 60-second entry blocks on controller SQL. Transient fallback can perform a cheap SQL lookup; this design promises avoidance of expensive translation queries, not literal zero SQL without persistent object cache.

## Scope

Apply soft/hard plugin freshness to policy-approved GET routes, prioritizing:

- `/international-press-zone/v1/translations/content`
- `/international-press-zone/v1/translations/content/all`
- `/international-press-zone/v1/system-translate/strings`
- policy-approved string domain/theme/plugin lists used by translation pages

Keep these live and nonpersistent:

- Active jobs and job polling.
- Progress state.
- Licensing and entitlements.
- Checkout and payment sessions.
- Migration live state.
- Any response carrying unsafe authentication, redirect, or cookie headers.

Do not broaden cache eligibility as incidental work.

## Approaches Considered

### Approach 1: Soft/Hard Snapshots + Deferred Server Refresh

Return fresh or soft-stale plugin snapshots. A soft-stale hit acquires a per-key lease and schedules one internal refresh. Hard expiry falls back to a synchronous live read.

| Dimension | Assessment |
|---|---|
| Robustness | Correct under cron delay, cache failure, mutation invalidation, and concurrent requests. |
| Long-term | Extends existing policy, envelope, invalidation, and backend seams. |
| Scalability | One refresh per key; warm result benefits later clients. |
| Performance | Fresh/stale hits avoid expensive controller SQL; first-ever/hard-expired reads remain correct. |
| Reversibility | Two-way door; disabling soft freshness restores existing cache-aside behavior. |
| Infra cost | No new service; Redis/Memcached improve performance but are optional. |

**Weakness:** WP-Cron refresh can be delayed when cron is disabled or no runner exists; hard expiry therefore remains the correctness fallback.

### Approach 2: Client-Driven Refresh Only

Return plugin stale snapshots and require the active browser to make a cache-bypass refresh request.

| Dimension | Assessment |
|---|---|
| Robustness | Depends on an open browser and duplicate client/server protocol state. |
| Long-term | Couples plugin freshness to admin JavaScript lifecycle. |
| Scalability | Multiple clients can duplicate expensive refreshes unless server leases still exist. |
| Performance | Current browser paints quickly, but no cross-client/background warming guarantee. |
| Reversibility | Two-way door. |
| Infra cost | No new service. |

**Weakness:** Does not satisfy plugin-owned cache-then-lazy-refresh semantics.

### Approach 3: Refresh Inline on Soft Expiry

Treat soft expiry as a synchronous miss and replace the snapshot before responding.

| Dimension | Assessment |
|---|---|
| Robustness | Simple and correct. |
| Long-term | Smallest implementation. |
| Scalability | Concurrent expiry causes stampedes without leases. |
| Performance | Reintroduces the exact route-blocking SQL latency being removed. |
| Reversibility | Two-way door. |
| Infra cost | No new service. |

**Weakness:** Fails the immediate-response requirement.

**Recommended: Approach 1.** It is the only approach that makes plugin snapshots independently useful, warms later clients, and preserves correctness when background execution is delayed. It deepens existing cache seams instead of adding another cache.

## Freshness Contract

Add policy fields:

- `serverSoftTtl`: duration before asynchronous refresh becomes eligible.
- `serverHardTtl`: maximum age served when generation identity remains valid.

Translation list defaults:

- Soft TTL: 120 seconds.
- Hard TTL: 300 seconds.
- Pending descriptor TTL: 300 seconds.
- Worker execution lease: 120 seconds.

Keep hard TTL at 300 seconds until every writer to relevant posts, options, translation, string, language, and import tables is inventoried and proven to rotate generation only after successful commit. Raise it only in a later measured policy change with that proof. Known mutations rotate generation tokens; rotation changes the cache key and makes pre-mutation snapshots unreachable immediately. Unknown out-of-band writes remain bounded by hard TTL.

Policy merge contract:

- `soft = min(serverSoftTtl)` across every matching descriptor.
- `hard = min(serverHardTtl)` across every matching descriptor.
- Require `0 < soft < hard`; malformed values disable server caching for the request.
- Migrate every cacheable descriptor to explicit `serverSoftTtl` and `serverHardTtl` in the same change. Remove legacy `serverTtl`; no mixed or fallback interpretation is permitted.

Store snapshot envelopes until hard expiry. Envelope MUST carry creation/freshness timestamps, status, data, replay-safe headers, and schema identity. Never infer soft age from backend TTL.

Response observability headers:

- `X-IPZ-Cache: hit`
- `X-IPZ-Cache: stale`
- `X-IPZ-Cache: miss`
- `X-IPZ-Cache-Age: <whole seconds>` on hit/stale

Do not expose keys, user IDs, query values, or generation tokens.

## Cache Identity

Retain existing identity dimensions:

- Cache schema version.
- Plugin version.
- REST namespace and route.
- Blog/site.
- Locale.
- User unless policy explicitly allows site scope.
- Policy descriptor keys.
- Registered defaults plus normalized allowlisted query fields.
- Generation tokens for every invalidation tag.

Refresh MUST use the identical canonical key builder. Unknown query fields, nonce-bearing requests, non-GET methods, unencodable values, errors, non-success statuses, and unsafe headers bypass storage.

## Server Data Flow

### Fresh hit

1. WordPress runs the route permission callback.
2. `RestResponseCache` resolves policy and canonical key.
3. Replay snapshot immediately with `hit` and age headers.
4. Do not invoke route callback or schedule refresh.

### Soft-stale hit

1. WordPress runs the route permission callback.
2. Replay snapshot immediately with `stale` and age headers.
3. Attempt atomic pending-descriptor creation for the canonical key plus current generation.
4. The winner stores one bounded descriptor, schedules one WP-Cron single event, then MUST attempt WordPress's nonblocking `spawn_cron()` for that event.
5. Losers return stale without scheduling.
6. Background refresh is eligible only for bounded non-search list policies; search/detail requests keep ordinary fresh/hard caching but do not persist refresh descriptors.

### Refresh worker

Seam:

`RestResponseCache::refresh(string $descriptorId): void`

Behavior:

- Load descriptor from plugin cache; reject missing, expired, malformed, or schema/version-mismatched descriptors.
- Validate the current user still exists and belongs to the target blog.
- Restore blog, user, and locale in nested `finally` semantics.
- Reconstruct a request and dispatch through `rest_do_request()` so route matching, registered defaults, sanitization, permission callbacks, and response filters still run.
- Mark internal execution with a server-owned object marker, never a header or request parameter; bypass both automatic cache replay and `rest_request_after_callbacks` storage for that request.
- Determine rate-limited classification from canonical route policy, not stored response headers. Internal refresh MUST NOT consume external user allowance; later cache hits MUST still enforce the route's rate limit.
- Before expensive dispatch, abort when generation changed, snapshot became fresh, or atomic worker execution lease is unavailable.
- Recompute generation and canonical key after dispatch; discard a result when either changed.
- Only after that recheck, call the shared response-validation/envelope-write seam explicitly. Never let internal dispatch store earlier.
- Delete the completed descriptor only when it still identifies this generation. Let execution leases expire naturally; NEVER release them early and risk deleting a reacquired lease.
- Catch `Throwable`; restore all contexts on every exit.

### Hard miss

Run the normal route callback synchronously. Store an accepted response with soft/hard timestamps. If cache/lease/cron operations fail, return the correct live or stale response as appropriate; cache availability MUST NOT become API availability.

## Deferred Refresh Descriptor

Store descriptors under a deterministic HMAC/digest derived from canonical cache identity plus generation. WP-Cron arguments contain only that opaque ID. Before scheduling, check `wp_next_scheduled()` with the same hook and args; inspect the error-capable return from `wp_schedule_single_event(..., true)`.

Descriptor fields:

- Schema and plugin version.
- Canonical route.
- Normalized allowlisted query parameters.
- Blog ID, user ID, locale.
- Expected canonical cache-key digest.
- Creation and expiry timestamps.

Never place search strings or full query payloads in the WordPress cron option. Never persist nonces, cookies, authorization headers, request bodies, or secrets. With transient fallback, descriptor values live in `wp_options`. Async refresh eligibility is therefore exact:

- List routes named in Scope only; no detail route.
- Empty `search` only.
- `page` from 1 through 20.
- `per_page` from 1 through 100.
- Serialized descriptor maximum 8192 bytes.
- Maximum 32 pending descriptors per user/blog namespace, enforced without scans by 32 deterministic hash slots; slot collision skips scheduling and returns stale.
- Descriptor lifetime maximum 300 seconds.

Every descriptor owns its slot with the same token and releases it through atomic compare-and-delete.

## Atomic Dedupe

Extend `ObjectCache` with atomic add-if-absent and owner-safe compare-and-delete operations. Keep backend-specific mechanics hidden behind these seams.

- `ObjectCache::add(string $key, mixed $value, int $ttl): bool`
- `ObjectCache::compareAndDelete(string $key, string $ownerToken): bool`

Required semantics:

- Redis: set-if-absent with expiry; Lua compare-and-delete.
- Memcached: native add; CAS owner check followed by CAS replacement with a short-lived tombstone so stale owners cannot remove reacquired values.
- Transient fallback: atomic `add_option()` with autoload disabled and expiry-bearing value; conditional SQL compare/delete against the exact serialized owner value; expired entries reclaimed conditionally.
- Backend failure returns false and never throws into REST delivery.

Use atomic add separately for pending-descriptor slots and worker execution locking. Pending descriptors remain stable until completion or expiry and are removed only through compare-and-delete with their owner token. Worker execution leases are short and never manually released; TTL recovery prevents an old owner from deleting a newer lease.

## Browser Data Flow

Keep current scoped localStorage behavior:

1. Memory/localStorage snapshot paints immediately when valid under browser policy identity.
2. Browser starts ordinary REST revalidation.
3. Plugin returns fresh or soft-stale snapshot immediately.
4. DataStore reconciles only when payload changes and preserves focus, dirty input, selection, scroll, menus/details, selected rows, charts, and modal state.
5. When response metadata reports `X-IPZ-Cache: stale`, DataStore schedules ordinary non-bypass follow-up GETs for the same store key: dedupe across subscribers, abort on teardown/invalidation, retry after 750ms then 2000ms, and stop after two attempts or the first non-stale response.
6. A worker-completed follow-up reconciles the current page; later navigation/tab also receives the refreshed plugin snapshot.

The browser does not control server refresh leases, descriptors, or cache bypass. Stale metadata must be available through the shared API/DataStore response seam; pages MUST NOT implement route-specific polling.

## Invalidation

Keep generation-token invalidation as source of truth. Inventory every writer to relevant posts, options, translation, string, language, worker, CLI, importer, and direct custom-table paths. Every successful mutation rotates exact policy tags only after its database commit succeeds. Failed or rolled-back mutations and GET requests never rotate tokens. Hard TTL remains 300 seconds until this inventory has test coverage.

Refresh worker recomputes identity immediately before storing. If generation changed after scheduling, discard the refresh result rather than writing under obsolete identity.

## Failure Handling

- Cache read failure: run route live.
- Cache write failure: return live response.
- Pending-descriptor or worker-lease failure: return stale; do not execute duplicate refresh.
- Cron schedule failure: remove only the unchanged owned descriptor; stale remains valid to hard expiry.
- Refresh permission failure: discard result; descriptor expires or is removed safely.
- Refresh route/server error: retain existing stale snapshot to hard expiry.
- Hard expiry with failed live request: return the live error; never silently serve data beyond hard TTL.
- Missing/disabled cron runner: stale remains bounded; browser follow-up stays bounded; next hard miss rebuilds synchronously.
- Exclude `X-IPZ-Cache`, age, and rate-limit headers from stored envelope headers; recompute them for every replay.

## Testing Contract

### PHP unit

Extend isolated tests to prove:

- Fresh hit bypasses builder and emits hit/age headers.
- Soft-stale hit bypasses builder, emits stale/age headers, and schedules one deterministic descriptor/event.
- Hard-expired entry runs builder.
- Pending descriptor dedupe, 32-slot cardinality cap, owner-safe compare/delete, worker lease expiry, and backend failure behavior.
- Refresh descriptor excludes non-allowlisted/sensitive/search data and enforces cardinality/size bounds.
- Worker validates user/blog, restores nested context, dispatches through `rest_do_request()`, reruns permission, bypasses only its own cache read, preserves policy-based rate-limit classification, and stores accepted response.
- Generation rotation before worker dispatch or during builder discards obsolete refresh.
- Existing isolation, nonce/error/header rejection, rate limiting, and transient fallback tests remain green.
- Live routes never store, serve, schedule, or refresh.

### Admin unit

Prove localStorage paints before REST completion, stale response metadata starts the shared bounded follow-up, follow-ups dedupe/abort/cap retries, worker completion reconciles the current page, unchanged payloads do not replace DOM, and live descriptors never persist.

### Local integration

Instrument controller callback/expensive-query count on local WordPress:

- First request: controller executes.
- Fresh second request: controller does not execute.
- Soft-stale request: response returns before controller refresh; one worker later executes.
- Concurrent soft-stale requests: one worker executes.
- Successful mutation: next GET does not serve pre-mutation snapshot.

### Concurrency integration

Use real parallel processes against each configured backend that exists in the test environment. Prove transient acquisition, duplicate cron suppression, lease expiry/reacquisition, mutation-before-worker, mutation-during-builder, revoked users, multisite context restoration, disabled cron, and current-page follow-up. Redis/Memcached cases run when those services are configured and report an explicit skip otherwise; isolated PHP stubs alone are not concurrency proof.

### Browser

Run Chromium and Firefox only through `~/.claude/bin/e2e-remote` against local WordPress `http://100.126.128.50:8081`. Never target dev1. Verify translation routes paint localStorage data immediately, incur no layout shift, preserve interaction state, and later observe refreshed plugin data.

## Observability

Tests and diagnostics may inspect headers and bounded counters. Production code MUST NOT log payloads, query values, user IDs, or cache keys. No admin UI cache controls are required.

## Architecture Decisions

- Keep `RestResponseCache` as the orchestration boundary: deletion would scatter REST safety, identity, replay, and refresh behavior.
- Keep `AdminDataPolicy` canonical: browser/server freshness and exclusions must not drift.
- Deepen `ObjectCache` with atomic add and owner-safe conditional release; do not introduce a separate lock service with one adapter.
- Keep refresh descriptors internal to `RestResponseCache`; a standalone descriptor repository would be a shallow single-adapter boundary.
- Use WP-Cron because it is WordPress-native and optional-infrastructure-safe. Do not add Action Scheduler solely for this feature.
- Preserve hard-expiry synchronous fallback because background execution is not guaranteed on every WordPress installation.
