• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Hyshmily / hotkey / 28081952945

24 Jun 2026 07:16AM UTC coverage: 92.745% (+0.1%) from 92.602%
28081952945

push

github

Hyshmily
feat: add HotKeyPreload annotation, Intercept QPS-trigger mode

Signed-off-by: Hyshmily <cxm8607@outlook.com>

1199 of 1347 branches covered (89.01%)

Branch coverage included in aggregate %.

50 of 52 new or added lines in 2 files covered. (96.15%)

1 existing line in 1 file now uncovered.

3416 of 3629 relevant lines covered (94.13%)

4.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.89
/common/src/main/java/io/github/hyshmily/hotkey/annotation/HotKeyCacheExtensionAspect.java
1
/*
2
 * Copyright 2026 Hyshmily. All Rights Reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package io.github.hyshmily.hotkey.annotation;
17

18
import com.github.benmanes.caffeine.cache.Cache;
19
import com.github.benmanes.caffeine.cache.Caffeine;
20
import io.github.hyshmily.hotkey.HotKey;
21
import io.github.hyshmily.hotkey.autoconfigure.HotKeyProperties;
22
import io.github.hyshmily.hotkey.cache.annotationsupporter.HotKeyCacheContext;
23
import io.github.hyshmily.hotkey.util.window.RollingWindow;
24
import java.lang.reflect.InvocationTargetException;
25
import java.lang.reflect.Method;
26
import java.util.Map;
27
import java.util.concurrent.ConcurrentHashMap;
28
import java.util.concurrent.TimeUnit;
29
import lombok.extern.slf4j.Slf4j;
30
import org.aspectj.lang.ProceedingJoinPoint;
31
import org.aspectj.lang.annotation.Around;
32
import org.aspectj.lang.annotation.Aspect;
33
import org.aspectj.lang.reflect.MethodSignature;
34
import org.springframework.cache.annotation.CacheEvict;
35
import org.springframework.cache.annotation.CachePut;
36
import org.springframework.cache.annotation.Cacheable;
37
import org.springframework.cache.interceptor.SimpleKey;
38
import org.springframework.context.expression.MethodBasedEvaluationContext;
39
import org.springframework.core.DefaultParameterNameDiscoverer;
40
import org.springframework.core.Ordered;
41
import org.springframework.core.ParameterNameDiscoverer;
42
import org.springframework.core.annotation.Order;
43
import org.springframework.expression.EvaluationContext;
44
import org.springframework.expression.Expression;
45
import org.springframework.expression.spel.standard.SpelExpressionParser;
46

47
/**
48
 * Companion AOP aspect for Spring {@link Cacheable @Cacheable},
49
 * {@link CachePut @CachePut}, and {@link CacheEvict @CacheEvict} methods.
50
 *
51
 * <p>Runs at {@link Ordered#HIGHEST_PRECEDENCE} — before Spring's own
52
 * {@code CacheInterceptor} — to set up thread-bound TTL overrides,
53
 * null-caching mode, broadcast suppression, and apply
54
 * {@link Intercept @Intercept} / {@link Fallback @Fallback} semantics.
55
 *
56
 * <p>For each {@code @Cacheable} invocation:
57
 * <ol>
58
 *   <li>Resolves the cache key from SpEL</li>
59
 *   <li>If {@code @Intercept} is present and the key is a local hot key,
60
 *       skips the method and returns the cached value or fallback</li>
61
 *   <li>Applies TTL from {@code @HotKeyCacheTTL}, null-caching from
62
 *       {@code @NullCaching}, and broadcast suppression from
63
 *       {@link Broadcast @Broadcast} into {@link HotKeyCacheContext}</li>
64
 *   <li>Proceeds to the Spring {@code CacheInterceptor}</li>
65
 *   <li>Restores the context snapshot in a {@code finally} block</li>
66
 *   <li>If the method throws and {@code @Fallback} is present, returns the fallback</li>
67
 * </ol>
68
 *
69
 * <p>For {@code @CachePut} and {@code @CacheEvict}, the aspect only reads
70
 * {@link Broadcast @Broadcast} to set the broadcast suppression flag. All other
71
 * companion annotations ({@code @HotKeyCacheTTL}, {@code @Intercept},
72
 * {@code @Fallback}, {@code @NullCaching}) are ignored on these methods.
73
 */
74
@Aspect
75
@Order(Ordered.HIGHEST_PRECEDENCE)
76
@Slf4j
4✔
77
public class HotKeyCacheExtensionAspect {
78

79
  /** The HotKey facade for cache introspection. */
80
  private final HotKey hotKey;
81

82
  /** Configuration properties (for key separator). */
83
  private final HotKeyProperties properties;
84

85
  /** SpEL expression parser, shared across all evaluations. */
86
  private final SpelExpressionParser parser = new SpelExpressionParser();
5✔
87

88
  /** Discoverer for resolving method parameter names at runtime. */
89
  private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
5✔
90

91
  /** Cache of parsed SpEL expressions keyed by expression string. */
92
  private final Map<String, Expression> expressionCache = new ConcurrentHashMap<>();
5✔
93

94
  /** Cache of resolved naming-convention fallback methods, keyed by original Method. */
95
  private final Map<Method, Method> fallbackMethodCache = new ConcurrentHashMap<>();
5✔
96

97
  /**
98
   * Tracks which keys have already been preload-registered to avoid redundant
99
   * {@link HotKey#notifyLocalDetectorDirect(String, int)} calls.
100
   * Bounded at 100k entries with 1-hour TTL to prevent unbounded growth.
101
   */
102
  private final Cache<String, Boolean> registeredPreloadKeys = Caffeine.newBuilder()
3✔
103
    .maximumSize(100_000)
3✔
104
    .expireAfterWrite(1, TimeUnit.HOURS)
1✔
105
    .build();
2✔
106

107
  /** Cache of resolved {@link HotKeyPreload} annotations keyed by Method. */
108
  private final Map<Method, HotKeyPreload> preloadCache = new ConcurrentHashMap<>();
5✔
109

110
  /**
111
   * Per-key QPS sliding windows for {@link InterceptTrigger#QPS} interception.
112
   * Each key gets a 10-bucket / 1-second window.
113
   * Bounded at 100k entries; idle windows are evicted by Caffeine.
114
   */
115
  private final Cache<String, RollingWindow> qpsWindows = Caffeine.newBuilder().maximumSize(100_000).build();
6✔
116

117
  /**
118
   * Creates a new {@code HotKeyCacheExtensionAspect}.
119
   *
120
   * @param hotKey     the HotKey facade
121
   * @param properties configuration properties
122
   */
123
  public HotKeyCacheExtensionAspect(HotKey hotKey, HotKeyProperties properties) {
2✔
124
    this.hotKey = hotKey;
3✔
125
    this.properties = properties;
3✔
126
  }
1✔
127

128
  /**
129
   * Intercepts {@link Cacheable @Cacheable} methods to apply TTL overrides, intercept
130
   * logic, and fallback handling before the method reaches Spring's {@code CacheInterceptor}.
131
   *
132
   * <p>If {@code @Intercept} is present and the key is a local hot key, the method is
133
   * skipped entirely. If a {@code @Fallback} is provided, it is returned instead.
134
   * If the method throws, the exception is caught and the fallback is returned.
135
   *
136
   * @param pjp       the join point for the intercepted method
137
   * @param cacheable the {@code @Cacheable} annotation on the intercepted method
138
   * @return the cached or method return value
139
   */
140
  @Around("@annotation(cacheable)")
141
  public Object aroundCacheable(ProceedingJoinPoint pjp, Cacheable cacheable) throws Throwable {
142
    Method method = resolveMethod(pjp);
4✔
143
    String cacheName = resolveCacheName(cacheable);
4✔
144
    String key = resolveKey(pjp, cacheable.key());
6✔
145
    String prefixedKey = cacheName + properties.getSpringCache().getKeySeparator() + key;
8✔
146

147
    HotKeyPreload preload = resolvePreloadAnnotation(method);
4✔
148
    HotKeyCacheTTL ttl = method.getAnnotation(HotKeyCacheTTL.class);
5✔
149
    Intercept intercept = method.getAnnotation(Intercept.class);
5✔
150
    Fallback fallback = method.getAnnotation(Fallback.class);
5✔
151
    NullCaching nullCaching = method.getAnnotation(NullCaching.class);
5✔
152
    Broadcast broadcast = method.getAnnotation(Broadcast.class);
5✔
153

154
    // @HotKeyPreload: inflate detection counts after @Intercept (so first call loads normally)
155
    // but before context setup (so pjp.proceed() → CacheInterceptor sees inflated counts)
156
    if (preload != null) {
2✔
157
      handlePreload(preload, pjp, cacheName);
5✔
158
    }
159

160
    // @Intercept: skip method when key is a local hot key if necessary
161
    if (intercept != null) {
2✔
162
      String interceptFallback = intercept.fallback();
3✔
163

164
      switch (intercept.trigger()) {
6!
165
        case FORCE -> {
166
          return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
7✔
167
        }
168
        case IS_LOCAL_HOT -> {
169
          if (hotKey.isLocalHotKey(prefixedKey)) {
5✔
170
            return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
7✔
171
          }
172
        }
173
        case QPS -> {
174
          int qpsThreshold = intercept.QPS();
3✔
175

176
          if (qpsThreshold > 0) {
2!
177
            RollingWindow window = qpsWindows.get(prefixedKey, k -> new RollingWindow(10, 1000));
13✔
178
            window.add(1);
3✔
179
            if (window.sum() > qpsThreshold) {
6✔
180
              return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
7✔
181
            }
182
          }
183
        }
184
      }
185
    }
186

187
    // Save-restore context to prevent leakage across nested @Cacheable calls
188
    HotKeyCacheContext.ContextValues prev = HotKeyCacheContext.get().snapshot();
3✔
189
    try {
190
      long hardTtlMs = (ttl != null && ttl.hardTtlMs() > 0) ? ttl.hardTtlMs() : 0L;
12!
191
      long softTtlMs = (ttl != null && ttl.softTtlMs() > 0) ? ttl.softTtlMs() : 0L;
9!
192
      boolean allowNull = nullCaching != null && nullCaching.value();
9!
193
      boolean skipBroadcast = broadcast != null && !broadcast.value();
9!
194

195
      HotKeyCacheContext.get().apply(hardTtlMs, softTtlMs, allowNull, skipBroadcast);
6✔
196
      return pjp.proceed();
5✔
197
    } catch (Throwable e) {
1✔
198
      if (fallback != null) {
2✔
199
        log.warn("[HotKeyCacheExtension] fallback triggered for key={}, reason={}", prefixedKey, e.getMessage());
6✔
200
        return resolveFallback(pjp, fallback);
7✔
201
      }
202
      throw e;
2✔
203
    } finally {
204
      HotKeyCacheContext.get().restore(prev);
3✔
205
    }
206
  }
207

208
  /**
209
   * Handle {@link HotKeyPreload @HotKeyPreload} by inflating HeavyKeeper counts
210
   * for static and/or dynamic cache keys.
211
   *
212
   * <p>Each key is inflated at most once, tracked by the bounded Caffeine cache
213
   * {@link #registeredPreloadKeys} (100k max, 1-hour TTL). This prevents
214
   * unbounded growth from dynamic key expressions while ensuring automatic
215
   * re-inflation if a key needs to be preloaded again after a long idle period.
216
   *
217
   * <p>Static keys ({@link HotKeyPreload#keys}) are inflated immediately.
218
   * Dynamic keys ({@link HotKeyPreload#keyExpr}) are evaluated via SpEL against
219
   * the current method parameters.
220
   *
221
   * @param preload   the annotation instance
222
   * @param pjp       the join point providing method arguments and target
223
   * @param cacheName the resolved cache name from {@code @Cacheable}
224
   */
225
  private void handlePreload(HotKeyPreload preload, ProceedingJoinPoint pjp, String cacheName) {
226
    String separator = properties.getSpringCache().getKeySeparator();
5✔
227
    int preloadCount = preload.count() > 0 ? preload.count() : Integer.MAX_VALUE;
5!
228

229
    // Static keys — register once per key
230
    for (String staticKey : preload.keys()) {
17✔
231
      String fullKey = cacheName + separator + staticKey;
5✔
232
      if (registeredPreloadKeys.getIfPresent(fullKey) == null) {
5✔
233
        hotKey.notifyLocalDetectorDirect(fullKey, preloadCount);
5✔
234
        registeredPreloadKeys.put(fullKey, Boolean.TRUE);
5✔
235
      }
236
    }
237

238
    // Dynamic key via SpEL — register once per unique evaluated key
239
    String keyExpr = preload.keyExpr();
3✔
240
    if (!keyExpr.isEmpty()) {
3✔
241
      try {
242
        Expression expression = expressionCache.computeIfAbsent("preload_" + keyExpr, k ->
10✔
243
          parser.parseExpression(keyExpr)
5✔
244
        );
245
        Object value = expression.getValue(buildEvaluationContext(pjp));
6✔
246

247
        if (value != null) {
2!
248
          String fullKey = cacheName + separator + value;
6✔
249
          if (registeredPreloadKeys.getIfPresent(fullKey) == null) {
5!
250
            hotKey.notifyLocalDetectorDirect(fullKey, preloadCount);
5✔
251
            registeredPreloadKeys.put(fullKey, Boolean.TRUE);
5✔
252
          }
253
        }
NEW
254
      } catch (Exception e) {
×
NEW
255
        log.warn("Failed to evaluate @HotKeyPreload keyExpr '{}': {}", keyExpr, e.toString());
×
256
      }
1✔
257
    }
258
  }
1✔
259

260
  /**
261
   * Intercepts {@link CachePut @CachePut} methods to set the broadcast suppression
262
   * flag from {@link Broadcast @Broadcast} before the method reaches Spring's
263
   * {@code CacheInterceptor}.
264
   *
265
   * <p>Only the {@code @Broadcast} annotation is read here. Other companion
266
   * annotations ({@code @HotKeyCacheTTL}, {@code @Intercept}, {@code @Fallback},
267
   * {@code @NullCaching}) are ignored on {@code @CachePut} methods.
268
   *
269
   * @param pjp      the join point for the intercepted method
270
   * @param cachePut the {@code @CachePut} annotation on the intercepted method
271
   * @return the method return value
272
   */
273
  @SuppressWarnings("unused")
274
  @Around("@annotation(cachePut)")
275
  public Object aroundCachePut(ProceedingJoinPoint pjp, CachePut cachePut) throws Throwable {
276
    return setBroadcast(pjp);
4✔
277
  }
278

279
  /**
280
   * Intercepts {@link CacheEvict @CacheEvict} methods to set the broadcast
281
   * suppression flag from {@link Broadcast @Broadcast} before the method reaches
282
   * Spring's {@code CacheInterceptor}.
283
   *
284
   * <p>Only the {@code @Broadcast} annotation is read here. Other companion
285
   * annotations ({@code @HotKeyCacheTTL}, {@code @Intercept}, {@code @Fallback},
286
   * {@code @NullCaching}) are ignored on {@code @CacheEvict} methods.
287
   *
288
   * @param pjp       the join point for the intercepted method
289
   * @param cacheEvict the {@code @CacheEvict} annotation on the intercepted method
290
   * @return the method return value
291
   */
292
  @SuppressWarnings("unused")
293
  @Around("@annotation(cacheEvict)")
294
  public Object aroundCacheEvict(ProceedingJoinPoint pjp, CacheEvict cacheEvict) throws Throwable {
295
    return setBroadcast(pjp);
4✔
296
  }
297

298
  private Object setBroadcast(ProceedingJoinPoint pjp) throws Throwable {
299
    Method method = resolveMethod(pjp);
4✔
300
    Broadcast broadcast = method.getAnnotation(Broadcast.class);
5✔
301
    boolean skipBroadcast = broadcast != null && !broadcast.value();
9!
302

303
    HotKeyCacheContext.ContextValues prev = HotKeyCacheContext.get().snapshot();
3✔
304
    try {
305
      HotKeyCacheContext.get().apply(0, 0, false, skipBroadcast);
6✔
306
      return pjp.proceed();
5✔
307
    } finally {
308
      HotKeyCacheContext.get().restore(prev);
3✔
309
    }
310
  }
311

312
  /**
313
   * Resolves the first available cache name from the {@link Cacheable @Cacheable} annotation.
314
   *
315
   * @param cacheable the annotation instance
316
   * @return the cache name, or {@code "hotkey"} if none is specified
317
   */
318
  private String resolveCacheName(Cacheable cacheable) {
319
    String[] names = cacheable.cacheNames();
3✔
320
    if (names.length > 0) {
3✔
321
      return names[0];
4✔
322
    }
323
    String[] value = cacheable.value();
3✔
324
    if (value.length > 0) {
3✔
325
      return value[0];
4✔
326
    }
327
    return "hotkey";
2✔
328
  }
329

330
  /**
331
   * Evaluates the cache key SpEL expression against method parameter variables.
332
   *
333
   * @param pjp        the join point providing method arguments
334
   * @param expression the SpEL expression to evaluate
335
   * @return the evaluated cache key string, or all arguments as string if expression is empty
336
   */
337
  private String resolveKey(ProceedingJoinPoint pjp, String expression) {
338
    if (expression.isEmpty()) {
3✔
339
      Object[] args = pjp.getArgs();
3✔
340
      if (args.length == 0) {
3✔
341
        return "empty";
2✔
342
      }
343
      if (args.length == 1 && args[0] != null && !args[0].getClass().isArray()) {
14✔
344
        return args[0].toString();
5✔
345
      }
346
      return new SimpleKey(args).toString();
6✔
347
    }
348
    return getExpression(expression).getValue(buildEvaluationContext(pjp), String.class);
10✔
349
  }
350

351
  /**
352
   * Resolves the concrete {@link Method} for the join point, unwrapping interface
353
   * methods to the implementation class when necessary.
354
   *
355
   * @param pjp the join point whose method to resolve
356
   * @return the resolved concrete method
357
   */
358
  private Method resolveMethod(ProceedingJoinPoint pjp) {
359
    MethodSignature sig = (MethodSignature) pjp.getSignature();
4✔
360
    Method method = sig.getMethod();
3✔
361
    if (method.getDeclaringClass().isInterface()) {
4✔
362
      try {
363
        method = pjp.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
9✔
364
      } catch (NoSuchMethodException ignored) {
1✔
365
        // Keep the interface method
366
      }
1✔
367
    }
368
    return method;
2✔
369
  }
370

371
  /**
372
   * Builds an {@link EvaluationContext} with method parameters registered
373
   * as context variables via {@link MethodBasedEvaluationContext}.
374
   *
375
   * @param pjp the join point providing method arguments and target
376
   * @return a configured evaluation context with method parameters as variables
377
   */
378
  private EvaluationContext buildEvaluationContext(ProceedingJoinPoint pjp) {
379
    return new MethodBasedEvaluationContext(
4✔
380
      pjp.getTarget(),
3✔
381
      resolveMethod(pjp),
2✔
382
      pjp.getArgs(),
4✔
383
      parameterNameDiscoverer
384
    );
385
  }
386

387
  /**
388
   * Returns a cached {@link Expression} for the given expression string, parsing it
389
   * on first access.
390
   *
391
   * @param expressionString the SpEL expression string to retrieve or parse
392
   * @return the cached or freshly parsed expression
393
   */
394
  private Expression getExpression(String expressionString) {
395
    return expressionCache.computeIfAbsent(expressionString, parser::parseExpression);
12✔
396
  }
397

398
  /**
399
   * Resolve the intercept fallback chain when the method call is intercepted.
400
   *
401
   * <p>Priority order:
402
   * <ol>
403
   *   <li>{@link Intercept#fallback()} — SpEL expression evaluated against
404
   *       method parameters</li>
405
   *   <li>{@link Fallback @Fallback} — naming-convention method or SpEL expression</li>
406
   *   <li>{@link io.github.hyshmily.hotkey.HotKey#peek(String)} — stale cached
407
   *       value if available, otherwise {@code null}</li>
408
   * </ol>
409
   *
410
   * @param pjp               the join point providing method arguments
411
   * @param fallback          the method-level {@code @Fallback} annotation
412
   *                          (may be {@code null})
413
   * @param interceptFallback the fallback SpEL from {@code @Intercept.fallback()}
414
   *                          (may be blank)
415
   * @param prefixedKey       the fully-qualified cache key (cache name + separator + key)
416
   * @return the resolved fallback value, or {@code null} if no fallback is available
417
   *         and the cache does not contain the key
418
   * @throws Throwable if the SpEL evaluation or fallback method invocation fails
419
   */
420
  private Object resolveInterceptFallback(
421
    ProceedingJoinPoint pjp,
422
    Fallback fallback,
423
    String interceptFallback,
424
    String prefixedKey
425
  ) throws Throwable {
426
    if (!interceptFallback.isBlank()) {
3✔
427
      return getExpression(interceptFallback).getValue(buildEvaluationContext(pjp));
8✔
428
    }
429
    if (fallback != null) {
2✔
430
      return resolveFallback(pjp, fallback);
5✔
431
    }
432
    return hotKey.peek(prefixedKey).orElse(null);
7✔
433
  }
434

435
  /**
436
   * Resolves the fallback value from a {@link Fallback @Fallback} annotation.
437
   * <p>If the annotated SpEL expression is non-empty it is evaluated first;
438
   * otherwise the naming-convention method ({@code {methodName}Fallback}) is invoked.
439
   *
440
   * @param pjp      the join point providing method arguments
441
   * @param fallback the fallback annotation
442
   * @return the resolved fallback value
443
   */
444
  private Object resolveFallback(ProceedingJoinPoint pjp, Fallback fallback) throws Throwable {
445
    if (!fallback.value().isEmpty()) {
4✔
446
      return getExpression(fallback.value()).getValue(buildEvaluationContext(pjp));
9✔
447
    }
448
    return invokeFallbackMethod(pjp);
4✔
449
  }
450

451
  /**
452
   * Invokes the naming-convention fallback method on the target bean with
453
   * the original method arguments.
454
   *
455
   * <p>The fallback method is expected to follow the naming convention
456
   * {@code {originalMethodName}Fallback} with the same parameter types
457
   * as the original method. The resolved method is cached in
458
   * {@link #fallbackMethodCache} for subsequent invocations.
459
   *
460
   * <p>If no matching fallback method exists anywhere in the class
461
   * hierarchy, {@code null} is returned silently (no error is logged).
462
   *
463
   * @param pjp the join point providing the original method's arguments,
464
   *            target object, and signature metadata
465
   * @return the return value of the fallback method, or {@code null} if
466
   *         no fallback method was found
467
   * @throws Throwable the unwrapped cause if the fallback method throws
468
   */
469
  private Object invokeFallbackMethod(ProceedingJoinPoint pjp) throws Throwable {
470
    MethodSignature sig = (MethodSignature) pjp.getSignature();
4✔
471
    Method originalMethod = sig.getMethod();
3✔
472
    Object target = pjp.getTarget();
3✔
473
    Object[] args = pjp.getArgs();
3✔
474

475
    Method fallbackMethod = fallbackMethodCache.computeIfAbsent(originalMethod, m ->
9✔
476
      findFallbackMethod(target.getClass(), m.getName() + "Fallback", m.getParameterTypes())
10✔
477
    );
478
    if (fallbackMethod == null) {
2✔
479
      return null;
2✔
480
    }
481
    try {
482
      return fallbackMethod.invoke(target, args);
5✔
483
    } catch (InvocationTargetException e) {
1✔
484
      throw e.getCause();
3✔
485
    }
486
  }
487

488
  /**
489
   * Recursively searches the class hierarchy (superclasses, not interfaces)
490
   * for a method matching the given name and parameter types.
491
   *
492
   * <p>The search starts at the provided class and walks up the inheritance
493
   * chain via {@link Class#getSuperclass()}, stopping at {@link Object}.
494
   * This ensures that fallback methods defined in abstract base classes
495
   * or superclasses are discovered.
496
   *
497
   * <p>Interface default methods are <em>not</em> searched, as the
498
   * annotation-based contract is that the fallback method resides in the
499
   * concrete bean's class hierarchy.
500
   *
501
   * @param clazz      the class to start the search from (typically the
502
   *                   target object's runtime class)
503
   * @param name       the name of the fallback method to find (e.g.,
504
   *                   {@code "findUserFallback"})
505
   * @param paramTypes the parameter types of the method to find (must
506
   *                   exactly match the original method's parameter types)
507
   * @return the matching {@link Method}, or {@code null} if no method
508
   *         with the given name and parameter types exists in the
509
   *         hierarchy
510
   */
511
  private Method findFallbackMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
512
    try {
513
      return clazz.getMethod(name, paramTypes);
5✔
514
    } catch (NoSuchMethodException e) {
1✔
515
      Class<?> superclass = clazz.getSuperclass();
3✔
516
      if (superclass != null && superclass != Object.class) {
5!
517
        return findFallbackMethod(superclass, name, paramTypes);
×
518
      }
519
      return null;
2✔
520
    }
521
  }
522

523
  /**
524
   * Resolve {@link HotKeyPreload @HotKeyPreload} from the given method,
525
   * using a local cache to avoid repeated reflection lookups.
526
   *
527
   * @param method the candidate method
528
   * @return the {@code @HotKeyPreload} annotation if present, or {@code null}
529
   */
530
  private HotKeyPreload resolvePreloadAnnotation(Method method) {
531
    return preloadCache.computeIfAbsent(method, m -> m.getAnnotation(HotKeyPreload.class));
12✔
532
  }
533
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc