daniellansun commented on PR #2786:
URL: https://github.com/apache/groovy/pull/2786#issuecomment-5307523007

   ## Summary
   
   The three commits form a coherent progression:
   
   1. Skip the all-`ClassInfo` walk when this process has never allocated a 
SwitchPoint.
   2. Replace that walk with a process-wide registry of live SwitchPoints, so 
category enter/leave is O(live domains).
   3. Stop that registry from retaining entries after a domain is discarded 
without an explicit detach.
   
   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 `SwitchPointInvalidator` from a small lazy cell into a process-wide GC 
protocol, and it pumps that protocol from `getSwitchPoint()`, which is the MOP 
link path. I wonder whether the same leak- and staleness-freedom can be 
obtained by giving `ClassInfo` a class-level domain that outlives the 
`MetaClass` object — analogous to the pending domain it already keeps — and 
leaving the live set as the straightforward map introduced in commit 2.
   
   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
   
   - **Register-before-publish.** An empty registry observation really does 
mean there was nothing a bulk path needed to retire. That is a clean 
replacement for the monotonic flag in `5013c5f`.
   - **Keying the registry by `SwitchPoint`, not by invalidator.** A single-use 
SwitchPoint cannot ABA-clobber a successor the way an invalidator-keyed 
`remove` could. The comment that explains this is worth keeping, wherever the 
map ends up.
   - **Two-argument `remove` for a single claimant.** Drain and reaper cannot 
both retire the same orphan. That handshake is easy to follow.
   - **Replacing `isAnySwitchPointAllocated` with `hasLiveSwitchPoints()`.** 
The flag never re-armed, so a process that linked once would pay the walk 
forever. The registry check is the better model.
   - **The tests around concurrent get / detach / drain** 
(`registryInvariant_underConcurrentGetDetachAndDrain`, the CAS-loss leak bound) 
give a reader something concrete to trust.
   
   ---
   
   ## 1. Would it be possible to keep domain lifetime on `ClassInfo`?
   
   `SwitchPointInvalidator` previously did one job: allocate a SwitchPoint 
lazily, detach it, invalidate it. After commit 3 it also owns:
   
   - a process-wide `ConcurrentHashMap<SwitchPoint, OwnerRef>`
   - a `ReferenceQueue` and a `WeakReference` subclass
   - opportunistic reaping
   - bulk drain, including a second “owner already dead” mode
   - a test hook (`clearOwnerRefForTesting`) that deliberately breaks the 
weak-ref invariant
   
   The comments have to restate the same JVM fact several times — 
`guardWithTest` keeps only the SwitchPoint’s internal invoker, not the 
SwitchPoint object — which I took as a sign that the lifetime model is no 
longer obvious from the types.
   
   The two production owners are not actually the same kind of thing:
   
   | 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`:
   
   ```java
   // 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:
   
   - `ClassInfo` already keeps `pendingIndySwitchPoint` for the pre-MC 
generation.
   - A class-level invalidator that outlives the `MetaClass` *object* the same 
way would let `invalidateClass` find 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 `ClassInfo` collection. 
`finalizeReference()` already exists, though as far as I can see it is not 
invoked from `ClassValue` / `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
   
   ```java
   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 `invalidateAll`s.
   
   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:
   
   ```java
   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:
   
   1. Do not call `reapOrphans()` from `drainLive()`. The `forEach` is enough, 
and it keeps one `invalidateAll`.
   2. Do not poll on the `getSwitchPoint()` hit path. If a pump is required 
when there is no drain, `java.lang.ref.Cleaner` registered 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.
   
   - `retireAllLoadedDomains` no longer walks loaded domains. A name such as 
`retireLiveDomains` would match what the method now does.
   - `ClassInfo.detachLiveIndySwitchPoint` still says to prefer 
`collectLiveIndySwitchPoints` for bulk paths. Bulk paths now go through 
`SwitchPointInvalidator.drainLive`.
   - `hasLiveSwitchPoints()` before `drainLive()` is a reasonable GROOVY-12258 
fast path; an empty `drainLive` is 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 `MetaClass` collection, `invalidateClass` / `incVersion` 
still retires a still-installed guard.
   - A discarded script class does not leave `hasLiveSwitchPoints()` true 
indefinitely.
   - A `use` block after script churn batch-invalidates; it does not 
single-invalidate via `reapOrphans()`.
   
   A smaller observation on the current tests, offered only as a consistency 
note:
   
   - `reaper_invalidatesOrphanedSwitchPointAfterOwnerGc` waits on `System.gc()` 
for up to two seconds. That is reasonable if the design is “GC, then a later 
`get`.” A `ClassInfo`-owned domain would not need that test.
   - `drain_claimsOrphanWhoseOwnerWasCollected` ends with `inv.detachLive()` 
because the owner is still alive and `current` is stale. `getSwitchPoint()` 
will return that already-invalidated SwitchPoint (`current != null` is 
sufficient). Production avoids that only because a true orphan’s owner is dead. 
The test hook therefore encodes “the owner is dead,” not “`current` is 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:
   
   1. Commit 2’s registry, keyed by `SwitchPoint`, register-before-publish, 
deregister on detach — that *is* the GROOVY-12259 fix.
   2. A class-level domain handle on `ClassInfo` (or a `ManagedReference` 
finalize on the weak `MetaClass`) so exact-class invalidation survives 
`MetaClass` collection.
   3. Explicit `ClassInfo` cleanup for discarded scripts, so the live set 
cannot grow without bound.
   4. 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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to