fix(content-types): lazy-load field variables to eliminate cold-cache N+1 hydration#36126
fix(content-types): lazy-load field variables to eliminate cold-cache N+1 hydration#36126swicken wants to merge 2 commits into
Conversation
… N+1 DbFieldTransformer eagerly loaded every field's variables during transform and ran fromMap twice per field, producing ~298 redundant 'select ... from field_variable where field_id=?' queries on a single cold Blog _search (render:true). The render path never reads field variables, so these were entirely wasted. Load variables lazily on first access (memoized on the cached Field/ContentType) and transform each field once. Cold field_variable queries drop 298 -> 0; warm unchanged (0). No new cache; existing invalidation unchanged. Refs #36125
|
Claude finished @swicken's task in 6m 55s —— View job PR Review
Change is correct. The double-transform bug and the eager load removal are both clearly right. A few things worth a second look: 1.
|
|
RE: Claude review Validated all five against the code:
|
…canary Make the lazy-memoization dependency explicit with assertSame on the two find() results, so a future cache-provider change fails loudly instead of the query-count assertion silently going green. Refs #36125
Fixes #36125
Proposed Changes
DbFieldTransformer.fromMap()no longer force-callsnewField.fieldVariables()on every field. Variables now load lazily on first access toField#fieldVariables()and are memoized on the cached Field/ContentType instance. Consumers that actually need them (content-type save/copy/diff, field-variable REST, import/export & push-publish, indexing, unique-per-site/charLimit validation, AI auto-tag, and templates that explicitly readfieldVariablesMap()) still get identical data through the same method — the query just runs when the data is needed instead of for every field on every type load.DbFieldTransformer.asList()ranfromMap(map)twice per field (the first result was assigned to an unused local and discarded). Combined with the eager load, every field issued twofield_variablequeries, one entirely wasted. Now each field is transformed once.Why
On a cold cache, a single
POST /api/content/_searchfor Blogs withrender:trueon the demo (Costa Rica) starter fired ~298select ... from field_variable where field_id = ?queries. The render path never reads field variables (warm was already0), so the eager hydration — doubled by the duplicatefromMap— was pure waste. This is a hot, widely-used legacy path: every cold content hydration across the platform paid the burst.The change is strictly subtractive: it only removes queries (and the 2× transform). No new cache, no invalidation logic, no API/DB/ES-mapping changes — existing Field/ContentType cache invalidation is untouched. Rollback-safe.
Measurements (local OTel → Tempo rig, cold = first search after restart)
_search(render:true)field_variablequeriesfield_variable/ JDBCBeats the previously-rejected side-cache spike (which only reached 149 — it was masking the double-transform) and needs none of its cluster/commit/TTL machinery.
Checklist
FieldVariableLazyHydrationTest(asserts 0 eager loads on field load, one lazy load per field on access, warm reads served from cache); existingFieldFactoryImplTestregression suite passes (32 tests, 0 failures).Field#fieldVariables()remains@JsonIgnoreand@CloseDBIfOpened, so serialization and connection handling are unchanged.Additional Info
@Value.Lazyuses synchronized double-checked locking; on failure the memo bit isn't set, so there's no poisoned-empty-list state (it retries next access).@CloseDBIfOpenedmeans the lazy load works on any thread and closes the connection it opens.field_variableread fails, theDotStateExceptionnow surfaces at first access (e.g. render/serialization) rather than at content-type load — same exception type, narrower blast radius (only the consumer that needs that field's variables is affected, not every type load).Screenshots
N/A — backend change.