[ 
https://issues.apache.org/jira/browse/GROOVY-12344?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Paul King updated GROOVY-12344:
-------------------------------
    Description: 
{{TemplateServlet}} caches compiled templates in a {{WeakHashMap}} that is read 
and written from {{service()}} with no synchronization:

{code:java}
private final Map<String, TemplateCacheEntry> cache;
...
this.cache = new WeakHashMap<String, TemplateCacheEntry>();
{code}

There are two independent defects here. The caching one is the more 
consequential.

h3. 1. The cache almost never hits

The key is {{file.getAbsolutePath()}} (or {{url.toString()}}), a {{String}} 
local to {{getTemplate}}. {{WeakHashMap}} holds its _keys_ weakly, so an entry 
survives only while something outside the map strongly references that key. 
Nothing does: {{TemplateCacheEntry}} stores only {{lastModified}}, {{length}}, 
a {{Date}} and the {{Template}} — deliberately not the {{File}} — so the key 
becomes unreachable the moment the request returns.

Every entry is therefore collectible as soon as it is created, and the cache 
empties at the next GC regardless of how hot a template is:

{noformat}
key identity reused across requests? false   (equal: true)
cache size right after request:  1
cache size after GC:             0
next request hits cache?         false
{noformat}

Note when reproducing this: build the path at runtime. Deriving it from a 
source literal makes the entry appear to survive, because the literal is 
interned and permanently reachable from the constant pool. A servlet's path 
always comes from the request URI.

h3. 2. Concurrent requests can corrupt the map

{{HashMap}} received the JDK 8 rewrite that ended the well-known resize 
infinite-loop. {{WeakHashMap}} did not — as of JDK 17 its {{transfer()}} still 
head-inserts:

{code:java}
int i = indexFor(e.hash, dest.length);
e.next = dest[i];
dest[i] = e;
{code}

That is the list reversal which lets two concurrent resizers build a cycle, 
after which {{get()}} spins forever. The mechanism is present in the shipped 
JDK; a spin has not been forced under test, as it is timing dependent.

{{TemplateCacheEntry.hit}} is likewise a non-atomic {{long}} incremented from 
concurrent requests, so hit counts in verbose mode can be wrong.

h3. Proposed fix

Use {{ConcurrentHashMap}}. Keep the plain {{get}}/{{put}} pair rather than 
{{computeIfAbsent}}: template compilation is slow and would hold a bin lock for 
its duration, and two threads racing to compile the same template is harmless 
and rare.

Strong references mean templates are now retained for the servlet's lifetime, 
which is the point — the key space is bounded by templates that actually exist, 
since {{service()}} sends a 404 before {{getTemplate}} is reached in both the 
{{File}} branch ({{exists()}}/{{canRead()}}) and the {{URL}} branch 
({{getResource(name) == null}}). {{getScriptUri}} is overridable, but an 
override still has to name a resolvable resource, so a subclass cannot make the 
key space unbounded.

Staleness handling is unaffected: {{TemplateCacheEntry.validate()}} continues 
to check {{lastModified}} and {{length}}, so an edited template is still picked 
up.

h3. Throughput

Read throughput, 40 cached templates, JDK 17:

||Map||1 thread||4 threads||8 threads||
|{{WeakHashMap}} (as shipped)|53.7 M gets/s|corrupts|corrupts|
|{{synchronizedMap(WeakHashMap)}}|55.0 M gets/s|16.4 M gets/s|18.2 M gets/s|
|{{ConcurrentHashMap}}|70.9 M gets/s|284.0 M gets/s|584.8 M gets/s|

{{ConcurrentHashMap}} is ahead even single-threaded, because 
{{WeakHashMap.get}} polls a {{ReferenceQueue}} on every read and dereferences a 
{{WeakReference}} per probe. Wrapping the existing map in {{synchronizedMap}} 
would fix the corruption but leaves a cache that does not cache, and goes 
backwards under contention.

h3. Compatibility

No API change. {{cache}} is a {{private final}} field whose declared type stays 
{{Map}}, {{TemplateCacheEntry}} is a {{private static}} class, and the two 
helpers are {{private}} — only the constructor body changes, so the change is 
source and binary compatible and existing compiled subclasses link unchanged. 
The only operations on the map are {{get}} and {{put}}, so no iteration or view 
semantics are observable.

{{ConcurrentHashMap}} rejects null keys and values, which cannot arise here: 
{{service()}} validates the file and the URL before {{getTemplate}} is called, 
and {{TemplateCacheEntry}}'s constructor throws on a null template.

One residual difference: {{HttpServlet}} is {{Serializable}} and {{cache}} is 
not transient, while {{TemplateCacheEntry}} and {{Template}} are not 
serializable. Today the cache is usually empty, so serializing a 
{{TemplateServlet}} can succeed by chance; with a populated cache it would 
consistently throw {{NotSerializableException}}. Containers passivate sessions 
rather than servlet instances, so this is theoretical, and {{transient}} is not 
a free fix given the field is {{final}} and deserialization skips the 
constructor.

The cache was introduced as a {{WeakHashMap}} in 2005 (GROOVY-814), in a 
revision still carrying {{// Java5}} comments beside the raw-typed field. No 
rationale was recorded.


> Replace TemplateServlet backend cache class
> -------------------------------------------
>
>                 Key: GROOVY-12344
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12344
>             Project: Groovy
>          Issue Type: Improvement
>            Reporter: Paul King
>            Priority: Major
>
> {{TemplateServlet}} caches compiled templates in a {{WeakHashMap}} that is 
> read and written from {{service()}} with no synchronization:
> {code:java}
> private final Map<String, TemplateCacheEntry> cache;
> ...
> this.cache = new WeakHashMap<String, TemplateCacheEntry>();
> {code}
> There are two independent defects here. The caching one is the more 
> consequential.
> h3. 1. The cache almost never hits
> The key is {{file.getAbsolutePath()}} (or {{url.toString()}}), a {{String}} 
> local to {{getTemplate}}. {{WeakHashMap}} holds its _keys_ weakly, so an 
> entry survives only while something outside the map strongly references that 
> key. Nothing does: {{TemplateCacheEntry}} stores only {{lastModified}}, 
> {{length}}, a {{Date}} and the {{Template}} — deliberately not the {{File}} — 
> so the key becomes unreachable the moment the request returns.
> Every entry is therefore collectible as soon as it is created, and the cache 
> empties at the next GC regardless of how hot a template is:
> {noformat}
> key identity reused across requests? false   (equal: true)
> cache size right after request:  1
> cache size after GC:             0
> next request hits cache?         false
> {noformat}
> Note when reproducing this: build the path at runtime. Deriving it from a 
> source literal makes the entry appear to survive, because the literal is 
> interned and permanently reachable from the constant pool. A servlet's path 
> always comes from the request URI.
> h3. 2. Concurrent requests can corrupt the map
> {{HashMap}} received the JDK 8 rewrite that ended the well-known resize 
> infinite-loop. {{WeakHashMap}} did not — as of JDK 17 its {{transfer()}} 
> still head-inserts:
> {code:java}
> int i = indexFor(e.hash, dest.length);
> e.next = dest[i];
> dest[i] = e;
> {code}
> That is the list reversal which lets two concurrent resizers build a cycle, 
> after which {{get()}} spins forever. The mechanism is present in the shipped 
> JDK; a spin has not been forced under test, as it is timing dependent.
> {{TemplateCacheEntry.hit}} is likewise a non-atomic {{long}} incremented from 
> concurrent requests, so hit counts in verbose mode can be wrong.
> h3. Proposed fix
> Use {{ConcurrentHashMap}}. Keep the plain {{get}}/{{put}} pair rather than 
> {{computeIfAbsent}}: template compilation is slow and would hold a bin lock 
> for its duration, and two threads racing to compile the same template is 
> harmless and rare.
> Strong references mean templates are now retained for the servlet's lifetime, 
> which is the point — the key space is bounded by templates that actually 
> exist, since {{service()}} sends a 404 before {{getTemplate}} is reached in 
> both the {{File}} branch ({{exists()}}/{{canRead()}}) and the {{URL}} branch 
> ({{getResource(name) == null}}). {{getScriptUri}} is overridable, but an 
> override still has to name a resolvable resource, so a subclass cannot make 
> the key space unbounded.
> Staleness handling is unaffected: {{TemplateCacheEntry.validate()}} continues 
> to check {{lastModified}} and {{length}}, so an edited template is still 
> picked up.
> h3. Throughput
> Read throughput, 40 cached templates, JDK 17:
> ||Map||1 thread||4 threads||8 threads||
> |{{WeakHashMap}} (as shipped)|53.7 M gets/s|corrupts|corrupts|
> |{{synchronizedMap(WeakHashMap)}}|55.0 M gets/s|16.4 M gets/s|18.2 M gets/s|
> |{{ConcurrentHashMap}}|70.9 M gets/s|284.0 M gets/s|584.8 M gets/s|
> {{ConcurrentHashMap}} is ahead even single-threaded, because 
> {{WeakHashMap.get}} polls a {{ReferenceQueue}} on every read and dereferences 
> a {{WeakReference}} per probe. Wrapping the existing map in 
> {{synchronizedMap}} would fix the corruption but leaves a cache that does not 
> cache, and goes backwards under contention.
> h3. Compatibility
> No API change. {{cache}} is a {{private final}} field whose declared type 
> stays {{Map}}, {{TemplateCacheEntry}} is a {{private static}} class, and the 
> two helpers are {{private}} — only the constructor body changes, so the 
> change is source and binary compatible and existing compiled subclasses link 
> unchanged. The only operations on the map are {{get}} and {{put}}, so no 
> iteration or view semantics are observable.
> {{ConcurrentHashMap}} rejects null keys and values, which cannot arise here: 
> {{service()}} validates the file and the URL before {{getTemplate}} is 
> called, and {{TemplateCacheEntry}}'s constructor throws on a null template.
> One residual difference: {{HttpServlet}} is {{Serializable}} and {{cache}} is 
> not transient, while {{TemplateCacheEntry}} and {{Template}} are not 
> serializable. Today the cache is usually empty, so serializing a 
> {{TemplateServlet}} can succeed by chance; with a populated cache it would 
> consistently throw {{NotSerializableException}}. Containers passivate 
> sessions rather than servlet instances, so this is theoretical, and 
> {{transient}} is not a free fix given the field is {{final}} and 
> deserialization skips the constructor.
> The cache was introduced as a {{WeakHashMap}} in 2005 (GROOVY-814), in a 
> revision still carrying {{// Java5}} comments beside the raw-typed field. No 
> rationale was recorded.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to