Skip to content

Performance: prime sibling field key lookups (hook-safe)#472

Draft
cbravobernal wants to merge 4 commits into
trunkfrom
perf/sibling-field-priming
Draft

Performance: prime sibling field key lookups (hook-safe)#472
cbravobernal wants to merge 4 commits into
trunkfrom
perf/sibling-field-priming

Conversation

@cbravobernal

@cbravobernal cbravobernal commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Split out from #466 for focused review, because it's the one performance change that touches the field-value load path and interacts with multilingual plugins.

What it does

Resolving a field key runs one query per field. When a field key lookup misses the cache and the field belongs to a field group, this resolves all of the group's field keys in bulk and caches them, so subsequent sibling lookups avoid further queries: get_field() over a 50-field template drops from ~101 SQL queries / ~17ms to a handful / ~3ms on cache-less hosts. Opt-out via the scf/prime_field_group_fields filter.

Priming works in two steps:

  1. The published sibling field keys are collected from acf_get_raw_fields(), reusing (and warming) the same cached bulk fetch used when the group is rendered (which now also batch-fetches evicted posts via _prime_post_caches() instead of one query each).
  2. All keys are resolved in a single global post_name__in query with the same semantics as the per-key lookup it replaces — same ordering (menu_order title), default publish status, not scoped to the group, and suppress_filters => false (both queries build on one shared args helper). Results are stored with a single batched wp_cache_add_multiple(), first (correctly ordered) post per key wins.

Priming only runs on the second key-lookup cache miss per field group in a request — warm persistent-cache requests stay query-free, single-field requests keep paying only their own query, and name-type lookups (which priming cannot serve) never pay for it. It is capped at 100 keys per group; larger groups fall back to per-key lookups for the remainder.

Also fixes a pre-existing gap surfaced in review: acf_trash_field() now flushes the field lookup cache (matching untrash/update/delete), so trashed fields stop resolving from persistent caches.

Why it needs its own review

The priming query runs with suppress_filters => false, matching the per-key lookup in acf_get_field_post() that it primes — so per-language/context query filters (WPML, Polylang, ACFML) still run and resolve the same posts they would have for the individual lookups. No hook is bypassed. Because the resolving query is global rather than group-scoped, a key that exists in more than one field group resolves to the same post the per-key query would have selected.

The cache it writes (acf_get_field_post:key:<key> → post ID, group secure-custom-fields) is byte-for-byte what the reader expects — lookup, priming, and acf_flush_field_cache() all lowercase the key to match the stored slug and the case-insensitive SQL comparison, so mixed-case requests hit primed entries and flushes stay coherent — built per sibling with acf_cache_key() so language-scoped cache keys behave as they do for individual lookups, and only publish posts are primed (matching the per-key get_posts default).

Suggested manual check before merge

On a WPML/Polylang site with two languages, confirm get_field() over a template resolves correctly in both languages (values, not just labels).

Verification

  • Rebased onto trunk (6.9.1).
  • composer test:php: OK (2845 tests, 21611 assertions). npm run test:unit: 951. composer test:phpstan: clean (one pre-existing unrelated error in bin/generate-field-schema.php).
  • Stamped @since SCF 6.9.2.

See #466 (superseded by this PR + the safe-bundle PR).

Use of AI Tools

Initial implementation by Claude Code (Claude Fable 5) under human direction; the hook-safe rework and split by Claude Code (Claude Opus 4.8); code-review findings (global key resolution, miss-only priming, key cap, scf/ naming) addressed by Claude Code (Claude Fable 5). All changes human-reviewed.

cbravobernal and others added 2 commits July 2, 2026 16:13
Resolving a field key runs one query per field. When a field belongs to a
field group, fetch all of the group's published fields in a single query
and cache their key lookups, so sibling lookups avoid further queries:
get_field() over a 50-field template drops from ~101 queries / ~17ms to
~5 / ~2.8ms on cache-less hosts.

The priming query runs with suppress_filters => false, matching the
per-key lookup in acf_get_field_post() that it primes, so per-language /
context query filters (WPML, Polylang, ACFML) still run and resolve the
same posts. Opt-out via the acf/prime_field_group_fields filter.

Split from the broader performance PR for focused review because it touches
the field-value load path and interacts with multilingual plugins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Prime only on a cache miss so warm persistent-cache requests stay
  query-free instead of paying an extra query per field group.
- Resolve sibling keys with a global post_name__in query (same ordering
  and filter semantics as the per-key lookup) so keys duplicated across
  field groups resolve to the same post as before.
- Source sibling keys from acf_get_raw_fields(), reusing its cached bulk
  fetch instead of running a near-duplicate group-scoped query.
- Memoize the parent before any bails so repeater/group sub-field
  lookups don't re-run the filter and parent checks per sibling.
- Cap priming at 100 keys so very large groups don't hydrate unbounded
  post sets; remaining keys fall back to per-key lookups.
- Use wp_cache_add() instead of wp_cache_get() + wp_cache_set().
- Rename to _scf_prime_sibling_field_posts() and scf/prime_field_group_fields
  per the scf_ naming convention for new functions and hooks.
- Correct @SInCE to 6.9.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cbravobernal
cbravobernal force-pushed the perf/sibling-field-priming branch from 13e8ad6 to 61bf808 Compare July 2, 2026 14:15
cbravobernal and others added 2 commits July 2, 2026 17:20
Field key/name lookups compare against post_name/post_excerpt, which is
case-insensitive under WordPress's default collations, and stored slugs
are always sanitized to lowercase. The lookup cache keyed entries by the
requested string, so a mixed-case request never hit entries primed under
the stored slug, and acf_flush_field_cache() (which keys by the stored
identity) could not invalidate them. Lowercasing the cache key at the
lookup, priming, and flush sites makes all three agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Flush the field lookup cache in acf_trash_field() (matching untrash/
  update/delete) so trashed fields stop resolving from persistent caches.
  Pre-existing gap that priming would have widened.
- Defer priming to the second cache miss per parent so single-field
  requests keep paying only their own query.
- Prime only on key-type lookups; name misses paid the priming cost
  without ever benefiting since only key entries are written.
- Batch the sibling cache writes with wp_cache_add_multiple().
- Skip trashed siblings when collecting keys so they don't consume the
  priming cap.
- Batch-fetch evicted posts in acf_get_raw_fields() via
  _prime_post_caches() instead of one query per field.
- Extract _scf_field_post_cache_key() and _scf_field_post_query_args()
  so the cache-key format and query semantics shared by lookup, priming,
  and flushing live in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant