GROOVY-12259: Make category/bulk call-site invalidation O(live SwitchPoint domains) instead of O(loaded classes) (includes GROOVY-12258) - #2786
Conversation
|
Does this replace #2786 ? |
SummaryThe three commits form a coherent progression:
I think the destination is the right one. The part I would like to discuss is commit 3’s orphan-reaper protocol: it is correct as far as I can follow, but it turns None of this is a claim that the present code is wrong. It is a suggestion that a different ownership boundary might delete a whole layer of mechanism. What I think is working well
1. Would it be possible to keep domain lifetime on
|
| Owner | What is collected | What I believe we need |
|---|---|---|
ClassInfo.pendingIndySwitchPoint |
a discarded script Class / ClassInfo |
the call sites die with the class; the registry entry should not linger |
IndyInvalidation.DOMAINS |
a soft/weak MetaClass collected while the Class is still live |
exact-class invalidation must still be able to find that domain |
Commit 3 treats both as “owner died, so invalidate the orphan from a global queue.” That is a conservative and understandable choice. I am not sure it is the smallest one, and I am not sure it fully closes the second case.
Exact-class retirement still goes through ClassInfo:
// IndyInvalidation
public static void collectLiveForClass(final Class<?> type, final List<SwitchPoint> out) {
ClassInfo.getClassInfo(type).collectLiveIndySwitchPoints(out);
}
// ClassInfo
public void collectLiveIndySwitchPoints(final List<SwitchPoint> out) {
IndyInvalidation.collectLiveForMetaClass(getMetaClassForClass(), out);
SwitchPoint pending = pendingIndySwitchPoint.detachLive();
if (pending != null) {
out.add(pending);
}
}After a soft MetaClass is collected, getMetaClassForClass() returns null and hasClassLevelMetaClass() is false, so the next install is treated as a first install. invalidateClass, incVersion, and a stock registry replace then have nothing to detach. GroovyObject sites typically pin the MetaClass via SAME_MC.bindTo(mc); an optimised POJO handle often does not. Those POJO sites can remain on a still-valid SwitchPoint that exact-class invalidation can no longer see.
The reaper runs only from getSwitchPoint() and drainLive(). A warmed-up process whose sites are already linked, whose MetaClass has been softly collected, and which is not using categories, calls neither. That is close to the “latent staleness window” described in the commit 3 message. The protocol will close the window once something else links or a category use runs; it will not close it from invalidateClass itself.
A direction you have already used, and that I would be grateful if you would consider extending:
ClassInfoalready keepspendingIndySwitchPointfor the pre-MC generation.- A class-level invalidator that outlives the
MetaClassobject the same way would letinvalidateClassfind the domain after a soft collection. - “Weak MC gone, installing a new one” could be treated as replace, not as first install.
- The live set could remain the commit 2 map (
SwitchPoint → SwitchPointInvalidator), without weak values. - Discarded-script cleanup could live on
ClassInfocollection.finalizeReference()already exists, though as far as I can see it is not invoked fromClassValue/globalClassSet. Wiring that, or accepting that a dead class’s pending entry sits until the next bulk drain, would avoid a third lifetime world on the cell.
If that is workable, OwnerRef, ORPHANS, reapOrphans(), the drain-time orphan branch, and clearOwnerRefForTesting could all go away, and exact-class invalidation would see the domain again. If it is not workable — for example if a class-level handle would pin something you have been careful not to pin — I would very much like to understand that constraint. I may simply have the reachability wrong.
2. getSwitchPoint() as the reaper pump
public SwitchPoint getSwitchPoint() {
// Allocation is the operation churn-heavy processes keep performing,
// so it doubles as the reaper pump; a no-op while the queue is empty.
reapOrphans();
for (;;) {
SwitchPoint sp = current.get();
if (sp != null) {
return sp;The comment describes allocation as the pump. The poll happens before the live-current hit, so every MOP link (classSwitchPointFor → getSwitchPoint()) pays a ReferenceQueue.poll(). That call is inexpensive when the queue is empty, but it is still a process-wide synchronised check. After a category use block — the path this series is making cheaper — every site re-links, and every re-link takes that lock. If the queue is not empty, the link also waits on one-at-a-time invalidateAlls.
I realise an empty poll is cheap, and that you called this out in the commit message (“Stable processes pay … an empty queue poll on the link path”). My hesitation is only whether that cost belongs on the link path at all, given that the series is otherwise moving work off the category / re-link path.
drainLive then does some of the same work twice, in two different styles:
static void drainLive(final List<SwitchPoint> out) {
reapOrphans();
LIVE.forEach((sp, ref) -> {
SwitchPointInvalidator inv = ref.get();
if (inv == null) {
if (LIVE.remove(sp, ref)) {
out.add(sp);
}
} else if (inv.detachIfCurrent(sp)) {
out.add(sp);
}
});
}reapOrphans() invalidates immediately. The forEach already claims ref.get() == null into out so that IndyInvalidation.retireAllLoadedDomains can use a single invalidateAll. Reaping first turns orphans that could have been batched into single invalidations, then hides them from the batch. After script churn plus use, the category path therefore does the slower thing first.
It also splits the contract of drainLive: some SwitchPoints are invalidated inside the method, others are only detached into out. retireAllLoadedDomains still reads as “drain, then invalidateBatch,” which is no longer the whole story.
If the orphan protocol remains, two modest adjustments would already make the control flow easier to follow:
- Do not call
reapOrphans()fromdrainLive(). TheforEachis enough, and it keeps oneinvalidateAll. - Do not poll on the
getSwitchPoint()hit path. If a pump is required when there is no drain,java.lang.ref.Cleanerregistered at domain creation would at least keep it off the link path.
3. Small leftovers from the walk that the registry replaced
These are minor and only worth a pass if you are already editing the comments.
retireAllLoadedDomainsno longer walks loaded domains. A name such asretireLiveDomainswould match what the method now does.ClassInfo.detachLiveIndySwitchPointstill says to prefercollectLiveIndySwitchPointsfor bulk paths. Bulk paths now go throughSwitchPointInvalidator.drainLive.hasLiveSwitchPoints()beforedrainLive()is a reasonable GROOVY-12258 fast path; an emptydrainLiveis already cheap. I do not feel strongly about keeping or folding it.
The public isAnySwitchPointAllocated() from 5013c5f is already gone in f549626, which seems right for an unreleased 6.0 surface.
4. Tests, if you pursue the lifetime change
The new unit tests (drain_claimsOrphanWhoseOwnerWasCollected, reaper_invalidatesOrphanedSwitchPointAfterOwnerGc) pin down the map / queue handshake clearly. If the design stays as it is, they are the right tests.
If you are willing to consider moving lifetime back onto ClassInfo, the behaviours I would most want a later reader to see are:
- After a soft
MetaClasscollection,invalidateClass/incVersionstill retires a still-installed guard. - A discarded script class does not leave
hasLiveSwitchPoints()true indefinitely. - A
useblock after script churn batch-invalidates; it does not single-invalidate viareapOrphans().
A smaller observation on the current tests, offered only as a consistency note:
reaper_invalidatesOrphanedSwitchPointAfterOwnerGcwaits onSystem.gc()for up to two seconds. That is reasonable if the design is “GC, then a laterget.” AClassInfo-owned domain would not need that test.drain_claimsOrphanWhoseOwnerWasCollectedends withinv.detachLive()because the owner is still alive andcurrentis stale.getSwitchPoint()will return that already-invalidated SwitchPoint (current != nullis sufficient). Production avoids that only because a true orphan’s owner is dead. The test hook therefore encodes “the owner is dead,” not “currentis a live registered SwitchPoint.” That may be acceptable; I mention it only because it is a slightly fragile invariant for a later editor.
A possible shape, if you find it useful
I would personally be happy to see:
- Commit 2’s registry, keyed by
SwitchPoint, register-before-publish, deregister on detach — that is the GROOVY-12259 fix. - A class-level domain handle on
ClassInfo(or aManagedReferencefinalize on the weakMetaClass) so exact-class invalidation survivesMetaClasscollection. - Explicit
ClassInfocleanup for discarded scripts, so the live set cannot grow without bound. - No
OwnerRef/ReferenceQueue/ link-path pump.
File size is not a concern (SwitchPointInvalidator is 345 lines). The question is only cohesion: three retirement modes (owner detach, drain detach, reaper invalidate) versus one live set plus domain ownership on ClassInfo.
I may have under-estimated a pinning or AOT constraint that makes the class-level handle unattractive. If so, I would be glad to be corrected. Thank you for the careful write-up in the commit 3 message — it made the intended invariant much easier to review.
…Point domains) with ClassInfo-owned class domains (closes GROOVY-12258) Process-wide invalidation (category enter/leave, custom MetaClass events, unattributed registry events) previously retired indy MOP SwitchPoint domains by walking every loaded ClassInfo — O(loaded classes), twice per use block — even in classic-only processes that never link an indy guard (GROOVY-12258; grails classic CategoryBench 2.4-3.8x slower on the dashboard since GROOVY-12191 landed). Bulk retirement now drains a process-wide registry of live SwitchPoints instead. Registration precedes publication, so an empty registry observation proves no guard chain holds a SwitchPoint the observer could have needed to retire, and the whole bulk path is skipped — classic-only processes pay nothing (the GROOVY-12258 guarantee, here via a check that re-arms once all domains retire). The registry is keyed by SwitchPoint (single-use lifecycle, so a removal can never ABA-clobber a successor entry) with strong values; drains claim entries via compare-and-detach so exactly one party retires each SwitchPoint. Domains are owned by ClassInfo, one per class, covering the pre-MetaClass link window and every installed MetaClass generation. Because the domain belongs to the class rather than the MetaClass object, exact-class invalidation (invalidateClass / incVersion / registry events) reaches installed guards even after a soft/weak MetaClass is collected — optimised POJO handles do not pin the MetaClass, so a per-MetaClass domain could become unreachable by class-level retirement while its guards stayed linked. First install, replace, clear, and per-instance changes all retire the same domain; retiring an unallocated generation is a no-op. Discarded-class cleanup rides the existing ManagedReference infrastructure: on first link the domain lazily anchors a weak reclaim reference to its ClassInfo, delivered by the shared weak-bundle ReferenceManager; the registry holds the invalidator strongly and the invalidator holds the anchor, so cleanup stays reachable exactly as long as there is something to clean. GroovyClassLoader.close() already retires domains deterministically via removeClass; the anchor covers loaders that are simply dropped (such classes are typically soft-reachable via CachedClass, so reclamation completes once soft references clear). Classic categoryInLoop: 365.9 -> 58.8 ms/op (~6x, ahead of the pre-GROOVY-12191 level, which still paid one global SwitchPoint invalidation per category enter/leave).
3010843 to
afd939d
Compare
|
Thanks Jochen and Daniel, the PR has been revised. AI description:
|
Yes, #2786 was the quick win fallback but doesn't cover all of the cases - it was just trying to address the drop in classic performance noted in the jmh classic graph after GROOVY-12191 optimised indy: |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #2786 +/- ##
==================================================
+ Coverage 70.1168% 70.1353% +0.0185%
- Complexity 35772 35799 +27
==================================================
Files 1561 1562 +1
Lines 132362 132387 +25
Branches 24331 24334 +3
==================================================
+ Hits 92808 92850 +42
+ Misses 31156 31139 -17
Partials 8398 8398
🚀 New features to boost your workflow:
|
✅ All tests passed ✅🏷️ Commit: afd939d Learn more about TestLens at testlens.app. |
No description provided.