Summary
ZODB has had a batch-load hint for years: Connection.prefetch(objects_or_oids) calls storage.prefetch(oids); storages that implement the hook can fetch all not-yet-cached objects in one backend round-trip and warm their caches, so the per-object setstate loads that follow become cache hits. When a storage does not implement it, ZODB installs a no-op — calling it is safe everywhere, no feature flag needed (ZODB/Connection.py, prefetch).
Plone never calls it. As a result, the most common object-access patterns in core and ecosystem code are sequential N+1 loads: one storage round-trip per object, per relation target, per brain-getObject(). On fast local storage that is masked; on any networked storage (RelStorage/pg, ZEO, zodb-pgjsonb) it is the dominant render cost — and under thread concurrency each round-trip additionally pays GIL re-acquire latency, which stretches ~1 ms round-trips to 20–160 ms wall (profiled: bluedynamics/zodb-pgjsonb#98).
This issue proposes research into where Plone core should prefetch, not a specific patch.
Real-world numbers (production, 15-site Plone 6.2, PostgreSQL-backed storage)
Batching the relation-target wake-ups in three of our tiles (a ~10-line helper, see below):
| pattern |
before |
after |
| "pins" tile: 44 relation targets |
44 sequential loads |
1 batch query |
| footer sponsor logos: 18 relation targets |
171 object fetches, ~260 ms cold per page render |
1 batch + residuals |
| imageset tiles: 6–13 relation targets per content page |
6–13 sequential loads |
1 batch query |
Per-span tracing (objects fetched vs. real queries) made these visible; the same instrumentation shows the classic shapes everywhere: objects/queries ≈ 1 = N+1, ≫ 1 = batched.
The wake-free helper pattern (relations)
For z3c.relationfield relations you do not even need to wake anything to learn the target oids — the intid utility's KeyReferenceToPersistent stores .oid:
def prefetch_relation_targets(relation_values, jar, intids=None):
if intids is None:
intids = getUtility(IIntIds)
refs = intids.refs
oids = []
for relation in relation_values or []:
to_id = getattr(relation, "to_id", None)
if to_id is None:
continue
keyref = refs.get(to_id)
oid = getattr(keyref, "oid", None) if keyref is not None else None
if oid is not None:
oids.append(oid)
for start in range(0, len(oids), 500):
jar.prefetch(oids[start:start + 500])
return len(oids)
For catalog result sets the oid is directly derivable per brain in some catalog implementations; plone-pgcatalog already auto-prefetches the next 100 brains' objects on the first brain.getObject() of a result set — prior art that this works transparently.
Research questions
- Storage support matrix. Which storages implement
IStorage.prefetch today? (RelStorage documents cache prefetch; ZEO/FileStorage presumably no-op; zodb-pgjsonb ≥ 1.15 implements it.) A no-op is harmless, but the matrix determines who benefits.
- Highest-value core call sites. Candidates:
plone.app.contentlisting / ZCatalog result sets: prefetch a window of getObject() targets (mirror plone-pgcatalog's result-set behaviour storage-agnostically).
- Relation fields (
z3c.relationfield accessors, plone.app.relationfield widgets/views, related-items viewlet): the wake-free helper above.
plone.restapi serializers (relations, blocks resolveuid, batched listings).
- Folder listings / navigation building.
- API shape. A
plone.base/plone.api helper (prefetch(objects_or_relations_or_brains))? Automatic-with-window in contentlisting? Both?
- Semantics to document.
prefetch warms the storage cache, not the ZODB pickle cache — setstate still runs per object, but hits the cache; the win is round-trips. Only prefetch what will actually be rendered (batching/pagination).
Context / prior art
Summary
ZODB has had a batch-load hint for years:
Connection.prefetch(objects_or_oids)callsstorage.prefetch(oids); storages that implement the hook can fetch all not-yet-cached objects in one backend round-trip and warm their caches, so the per-objectsetstateloads that follow become cache hits. When a storage does not implement it, ZODB installs a no-op — calling it is safe everywhere, no feature flag needed (ZODB/Connection.py,prefetch).Plone never calls it. As a result, the most common object-access patterns in core and ecosystem code are sequential N+1 loads: one storage round-trip per object, per relation target, per brain-
getObject(). On fast local storage that is masked; on any networked storage (RelStorage/pg, ZEO, zodb-pgjsonb) it is the dominant render cost — and under thread concurrency each round-trip additionally pays GIL re-acquire latency, which stretches ~1 ms round-trips to 20–160 ms wall (profiled: bluedynamics/zodb-pgjsonb#98).This issue proposes research into where Plone core should prefetch, not a specific patch.
Real-world numbers (production, 15-site Plone 6.2, PostgreSQL-backed storage)
Batching the relation-target wake-ups in three of our tiles (a ~10-line helper, see below):
Per-span tracing (objects fetched vs. real queries) made these visible; the same instrumentation shows the classic shapes everywhere:
objects/queries ≈ 1= N+1,≫ 1= batched.The wake-free helper pattern (relations)
For
z3c.relationfieldrelations you do not even need to wake anything to learn the target oids — the intid utility'sKeyReferenceToPersistentstores.oid:For catalog result sets the oid is directly derivable per brain in some catalog implementations; plone-pgcatalog already auto-prefetches the next 100 brains' objects on the first
brain.getObject()of a result set — prior art that this works transparently.Research questions
IStorage.prefetchtoday? (RelStorage documents cache prefetch; ZEO/FileStorage presumably no-op; zodb-pgjsonb ≥ 1.15 implements it.) A no-op is harmless, but the matrix determines who benefits.plone.app.contentlisting/ ZCatalog result sets: prefetch a window ofgetObject()targets (mirror plone-pgcatalog's result-set behaviour storage-agnostically).z3c.relationfieldaccessors,plone.app.relationfieldwidgets/views, related-items viewlet): the wake-free helper above.plone.restapiserializers (relations, blocksresolveuid, batched listings).plone.base/plone.apihelper (prefetch(objects_or_relations_or_brains))? Automatic-with-window in contentlisting? Both?prefetchwarms the storage cache, not the ZODB pickle cache —setstatestill runs per object, but hits the cache; the win is round-trips. Only prefetch what will actually be rendered (batching/pagination).Context / prior art
load_multiplewindow on firstgetObject())