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

Hyshmily / hotkey / 28726642910

05 Jul 2026 02:12AM UTC coverage: 90.4% (+0.3%) from 90.132%
28726642910

push

github

Hyshmily
add : add timescheduled Expire

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

1274 of 1474 branches covered (86.43%)

Branch coverage included in aggregate %.

32 of 45 new or added lines in 3 files covered. (71.11%)

3651 of 3974 relevant lines covered (91.87%)

4.18 hits per line

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

85.9
/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.Internal;
22
import io.github.hyshmily.hotkey.autoconfigure.HotKeyProperties;
23
import io.github.hyshmily.hotkey.cache.annotationsupporter.HotKeyCacheContext;
24
import io.github.hyshmily.hotkey.util.window.RollingWindow;
25
import java.lang.reflect.InvocationTargetException;
26
import java.lang.reflect.Method;
27
import java.util.Map;
28
import java.util.concurrent.ConcurrentHashMap;
29
import java.util.concurrent.TimeUnit;
30
import java.util.concurrent.atomic.LongAdder;
31
import lombok.extern.slf4j.Slf4j;
32
import org.aspectj.lang.ProceedingJoinPoint;
33
import org.aspectj.lang.annotation.Around;
34
import org.aspectj.lang.annotation.Aspect;
35
import org.aspectj.lang.reflect.MethodSignature;
36
import org.springframework.cache.annotation.CacheEvict;
37
import org.springframework.cache.annotation.CachePut;
38
import org.springframework.cache.annotation.Cacheable;
39
import org.springframework.cache.interceptor.SimpleKey;
40
import org.springframework.context.expression.MethodBasedEvaluationContext;
41
import org.springframework.core.DefaultParameterNameDiscoverer;
42
import org.springframework.core.Ordered;
43
import org.springframework.core.ParameterNameDiscoverer;
44
import org.springframework.core.annotation.Order;
45
import org.springframework.expression.EvaluationContext;
46
import org.springframework.expression.Expression;
47
import org.springframework.expression.spel.standard.SpelExpressionParser;
48

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

82
  /** The HotKey facade for cache introspection. */
83
  private final HotKey hotKey;
84

85
  /** Configuration properties (for key separator). */
86
  private final HotKeyProperties properties;
87

88
  /** SpEL expression parser, shared across all evaluations. */
89
  private final SpelExpressionParser parser = new SpelExpressionParser();
5✔
90

91
  /** Discoverer for resolving method parameter names at runtime. */
92
  private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
5✔
93

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

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

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

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

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

120
  /**
121
   * Per-key concurrent thread counters for {@link InterceptTrigger#CONCURRENT_THREADS} interception.
122
   * Incremented before method execution, decremented in {@code finally}.
123
   */
124
  private final ConcurrentHashMap<String, LongAdder> concurrentCounters = new ConcurrentHashMap<>();
5✔
125

126
  /**
127
   * Creates a new {@code HotKeyCacheExtensionAspect}.
128
   *
129
   * @param hotKey     the HotKey facade
130
   * @param properties configuration properties
131
   */
132
  public HotKeyCacheExtensionAspect(HotKey hotKey, HotKeyProperties properties) {
2✔
133
    this.hotKey = hotKey;
3✔
134
    this.properties = properties;
3✔
135
  }
1✔
136

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

156
    HotKeyPreload preload = resolvePreloadAnnotation(method);
4✔
157
    HotKeyCacheTTL ttl = method.getAnnotation(HotKeyCacheTTL.class);
5✔
158
    Intercept intercept = method.getAnnotation(Intercept.class);
5✔
159
    Fallback fallback = method.getAnnotation(Fallback.class);
5✔
160
    NullCaching nullCaching = method.getAnnotation(NullCaching.class);
5✔
161
    Broadcast broadcast = method.getAnnotation(Broadcast.class);
5✔
162

163
    // @HotKeyPreload: inflate detection counts after @Intercept (so first call loads normally)
164
    // but before context setup (so pjp.proceed() → CacheInterceptor sees inflated counts)
165
    if (preload != null) {
2✔
166
      handlePreload(preload, pjp, cacheName);
5✔
167
    }
168

169
    // @Intercept: skip method when key is a local hot key if necessary
170
    boolean needsDecrement = false;
2✔
171
    if (intercept != null) {
2✔
172
      String interceptFallback = intercept.fallback();
3✔
173

174
      switch (intercept.trigger()) {
6!
175
        case FORCE -> {
176
          return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
7✔
177
        }
178
        case IS_LOCAL_HOT -> {
179
          if (hotKey.isLocalHotKey(prefixedKey)) {
5✔
180
            return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
7✔
181
          }
182
        }
183
        case QPS -> {
184
          int qpsThreshold = intercept.QPS();
3✔
185

186
          if (qpsThreshold > 0) {
2!
187
            RollingWindow window = qpsWindows.get(prefixedKey, k -> new RollingWindow(10, 1000));
13✔
188
            window.add(1);
3✔
189
            if (window.sum() > qpsThreshold) {
6✔
190
              return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
7✔
191
            }
192
          }
193
        }
1✔
194
        case CONCURRENT_THREADS -> {
NEW
195
          int maxThreads = intercept.concurrentThreads();
×
NEW
196
          if (maxThreads > 0) {
×
NEW
197
            LongAdder counter = concurrentCounters.computeIfAbsent(prefixedKey, k -> new LongAdder());
×
NEW
198
            if (counter.sum() >= maxThreads) {
×
NEW
199
              return resolveInterceptFallback(pjp, fallback, interceptFallback, prefixedKey);
×
200
            }
NEW
201
            counter.increment();
×
NEW
202
            needsDecrement = true;
×
203
          }
204
        }
205
      }
206
    }
207

208
    // Save-restore context to prevent leakage across nested @Cacheable calls
209
    HotKeyCacheContext.ContextValues prev = HotKeyCacheContext.get().snapshot();
3✔
210
    try {
211
      long hardTtlMs = (ttl != null && ttl.hardTtlMs() > 0) ? ttl.hardTtlMs() : 0L;
12!
212
      long softTtlMs = (ttl != null && ttl.softTtlMs() > 0) ? ttl.softTtlMs() : 0L;
9!
213
      boolean allowNull = nullCaching != null && nullCaching.value();
9!
214
      boolean skipBroadcast = broadcast != null && !broadcast.value();
9!
215

216
      HotKeyCacheContext.get().apply(hardTtlMs, softTtlMs, allowNull, skipBroadcast);
6✔
217
      return pjp.proceed();
5✔
218
    } catch (Throwable e) {
1✔
219
      if (fallback != null) {
2✔
220
        log.warn("[HotKeyCacheExtension] fallback triggered for key={}, reason={}", prefixedKey, e.getMessage());
6✔
221
        return resolveFallback(pjp, fallback);
7✔
222
      }
223
      throw e;
2✔
224
    } finally {
225
      if (needsDecrement) {
2!
NEW
226
        LongAdder counter = concurrentCounters.get(prefixedKey);
×
NEW
227
        if (counter != null) {
×
NEW
228
          counter.decrement();
×
229
        }
230
      }
231
      HotKeyCacheContext.get().restore(prev);
3✔
232
    }
233
  }
234

235
  /**
236
   * Handle {@link HotKeyPreload @HotKeyPreload} by inflating HeavyKeeper counts
237
   * for static and/or dynamic cache keys.
238
   *
239
   * <p>Each key is inflated at most once, tracked by the bounded Caffeine cache
240
   * {@link #registeredPreloadKeys} (100k max, 1-hour TTL). This prevents
241
   * unbounded growth from dynamic key expressions while ensuring automatic
242
   * re-inflation if a key needs to be preloaded again after a long idle period.
243
   *
244
   * <p>Static keys ({@link HotKeyPreload#keys}) are inflated immediately.
245
   * Dynamic keys ({@link HotKeyPreload#keyExpr}) are evaluated via SpEL against
246
   * the current method parameters.
247
   *
248
   * @param preload   the annotation instance
249
   * @param pjp       the join point providing method arguments and target
250
   * @param cacheName the resolved cache name from {@code @Cacheable}
251
   */
252
  private void handlePreload(HotKeyPreload preload, ProceedingJoinPoint pjp, String cacheName) {
253
    String separator = properties.getSpringCache().getKeySeparator();
5✔
254
    int preloadCount = preload.count() > 0 ? preload.count() : Integer.MAX_VALUE;
5!
255

256
    // Static keys — register once per key
257
    for (String staticKey : preload.keys()) {
17✔
258
      String fullKey = cacheName + separator + staticKey;
5✔
259
      if (registeredPreloadKeys.getIfPresent(fullKey) == null) {
5✔
260
        hotKey.notifyLocalDetectorDirect(fullKey, preloadCount);
5✔
261
        registeredPreloadKeys.put(fullKey, Boolean.TRUE);
5✔
262
      }
263
    }
264

265
    // Dynamic key via SpEL — register once per unique evaluated key
266
    String keyExpr = preload.keyExpr();
3✔
267
    if (!keyExpr.isEmpty()) {
3✔
268
      try {
269
        Expression expression = expressionCache.computeIfAbsent("preload_" + keyExpr, k ->
10✔
270
          parser.parseExpression(keyExpr)
5✔
271
        );
272
        Object value = expression.getValue(buildEvaluationContext(pjp));
6✔
273

274
        if (value != null) {
2!
275
          String fullKey = cacheName + separator + value;
6✔
276
          if (registeredPreloadKeys.getIfPresent(fullKey) == null) {
5!
277
            hotKey.notifyLocalDetectorDirect(fullKey, preloadCount);
5✔
278
            registeredPreloadKeys.put(fullKey, Boolean.TRUE);
5✔
279
          }
280
        }
281
      } catch (Exception e) {
×
282
        log.warn("Failed to evaluate @HotKeyPreload keyExpr '{}': {}", keyExpr, e.toString());
×
283
      }
1✔
284
    }
285
  }
1✔
286

287
  /**
288
   * Intercepts {@link CachePut @CachePut} methods to set the broadcast suppression
289
   * flag from {@link Broadcast @Broadcast} before the method reaches Spring's
290
   * {@code CacheInterceptor}.
291
   *
292
   * <p>Only the {@code @Broadcast} annotation is read here. Other companion
293
   * annotations ({@code @HotKeyCacheTTL}, {@code @Intercept}, {@code @Fallback},
294
   * {@code @NullCaching}) are ignored on {@code @CachePut} methods.
295
   *
296
   * @param pjp      the join point for the intercepted method
297
   * @param cachePut the {@code @CachePut} annotation on the intercepted method
298
   * @return the method return value
299
   */
300
  @SuppressWarnings("unused")
301
  @Around("@annotation(cachePut)")
302
  public Object aroundCachePut(ProceedingJoinPoint pjp, CachePut cachePut) throws Throwable {
303
    return setBroadcast(pjp);
4✔
304
  }
305

306
  /**
307
   * Intercepts {@link CacheEvict @CacheEvict} methods to set the broadcast
308
   * suppression flag from {@link Broadcast @Broadcast} before the method reaches
309
   * Spring's {@code CacheInterceptor}.
310
   *
311
   * <p>Only the {@code @Broadcast} annotation is read here. Other companion
312
   * annotations ({@code @HotKeyCacheTTL}, {@code @Intercept}, {@code @Fallback},
313
   * {@code @NullCaching}) are ignored on {@code @CacheEvict} methods.
314
   *
315
   * @param pjp       the join point for the intercepted method
316
   * @param cacheEvict the {@code @CacheEvict} annotation on the intercepted method
317
   * @return the method return value
318
   */
319
  @SuppressWarnings("unused")
320
  @Around("@annotation(cacheEvict)")
321
  public Object aroundCacheEvict(ProceedingJoinPoint pjp, CacheEvict cacheEvict) throws Throwable {
322
    return setBroadcast(pjp);
4✔
323
  }
324

325
  private Object setBroadcast(ProceedingJoinPoint pjp) throws Throwable {
326
    Method method = resolveMethod(pjp);
4✔
327
    Broadcast broadcast = method.getAnnotation(Broadcast.class);
5✔
328
    boolean skipBroadcast = broadcast != null && !broadcast.value();
9!
329

330
    HotKeyCacheContext.ContextValues prev = HotKeyCacheContext.get().snapshot();
3✔
331
    try {
332
      HotKeyCacheContext.get().apply(0, 0, false, skipBroadcast);
6✔
333
      return pjp.proceed();
5✔
334
    } finally {
335
      HotKeyCacheContext.get().restore(prev);
3✔
336
    }
337
  }
338

339
  /**
340
   * Resolves the first available cache name from the {@link Cacheable @Cacheable} annotation.
341
   *
342
   * @param cacheable the annotation instance
343
   * @return the cache name, or {@code "hotkey"} if none is specified
344
   */
345
  private String resolveCacheName(Cacheable cacheable) {
346
    String[] names = cacheable.cacheNames();
3✔
347
    if (names.length > 0) {
3✔
348
      return names[0];
4✔
349
    }
350
    String[] value = cacheable.value();
3✔
351
    if (value.length > 0) {
3✔
352
      return value[0];
4✔
353
    }
354
    return "hotkey";
2✔
355
  }
356

357
  /**
358
   * Evaluates the cache key SpEL expression against method parameter variables.
359
   *
360
   * @param pjp        the join point providing method arguments
361
   * @param expression the SpEL expression to evaluate
362
   * @return the evaluated cache key string, or all arguments as string if expression is empty
363
   */
364
  private String resolveKey(ProceedingJoinPoint pjp, String expression) {
365
    if (expression.isEmpty()) {
3✔
366
      Object[] args = pjp.getArgs();
3✔
367
      if (args.length == 0) {
3✔
368
        return "empty";
2✔
369
      }
370
      if (args.length == 1 && args[0] != null && !args[0].getClass().isArray()) {
14✔
371
        return args[0].toString();
5✔
372
      }
373
      return new SimpleKey(args).toString();
6✔
374
    }
375
    return getExpression(expression).getValue(buildEvaluationContext(pjp), String.class);
10✔
376
  }
377

378
  /**
379
   * Resolves the concrete {@link Method} for the join point, unwrapping interface
380
   * methods to the implementation class when necessary.
381
   *
382
   * @param pjp the join point whose method to resolve
383
   * @return the resolved concrete method
384
   */
385
  private Method resolveMethod(ProceedingJoinPoint pjp) {
386
    MethodSignature sig = (MethodSignature) pjp.getSignature();
4✔
387
    Method method = sig.getMethod();
3✔
388
    if (method.getDeclaringClass().isInterface()) {
4✔
389
      try {
390
        method = pjp.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
9✔
391
      } catch (NoSuchMethodException ignored) {
1✔
392
        // Keep the interface method
393
      }
1✔
394
    }
395
    return method;
2✔
396
  }
397

398
  /**
399
   * Builds an {@link EvaluationContext} with method parameters registered
400
   * as context variables via {@link MethodBasedEvaluationContext}.
401
   *
402
   * @param pjp the join point providing method arguments and target
403
   * @return a configured evaluation context with method parameters as variables
404
   */
405
  private EvaluationContext buildEvaluationContext(ProceedingJoinPoint pjp) {
406
    return new MethodBasedEvaluationContext(
4✔
407
      pjp.getTarget(),
3✔
408
      resolveMethod(pjp),
2✔
409
      pjp.getArgs(),
4✔
410
      parameterNameDiscoverer
411
    );
412
  }
413

414
  /**
415
   * Returns a cached {@link Expression} for the given expression string, parsing it
416
   * on first access.
417
   *
418
   * @param expressionString the SpEL expression string to retrieve or parse
419
   * @return the cached or freshly parsed expression
420
   */
421
  private Expression getExpression(String expressionString) {
422
    return expressionCache.computeIfAbsent(expressionString, parser::parseExpression);
12✔
423
  }
424

425
  /**
426
   * Resolve the intercept fallback chain when the method call is intercepted.
427
   *
428
   * <p>Priority order:
429
   * <ol>
430
   *   <li>{@link Intercept#fallback()} — SpEL expression evaluated against
431
   *       method parameters</li>
432
   *   <li>{@link Fallback @Fallback} — naming-convention method or SpEL expression</li>
433
   *   <li>{@link io.github.hyshmily.hotkey.HotKey#peek(String)} — stale cached
434
   *       value if available, otherwise {@code null}</li>
435
   * </ol>
436
   *
437
   * @param pjp               the join point providing method arguments
438
   * @param fallback          the method-level {@code @Fallback} annotation
439
   *                          (may be {@code null})
440
   * @param interceptFallback the fallback SpEL from {@code @Intercept.fallback()}
441
   *                          (may be blank)
442
   * @param prefixedKey       the fully-qualified cache key (cache name + separator + key)
443
   * @return the resolved fallback value, or {@code null} if no fallback is available
444
   *         and the cache does not contain the key
445
   * @throws Throwable if the SpEL evaluation or fallback method invocation fails
446
   */
447
  private Object resolveInterceptFallback(
448
    ProceedingJoinPoint pjp,
449
    Fallback fallback,
450
    String interceptFallback,
451
    String prefixedKey
452
  ) throws Throwable {
453
    if (!interceptFallback.isBlank()) {
3✔
454
      return getExpression(interceptFallback).getValue(buildEvaluationContext(pjp));
8✔
455
    }
456
    if (fallback != null) {
2✔
457
      return resolveFallback(pjp, fallback);
5✔
458
    }
459
    return hotKey.peek(prefixedKey).orElse(null);
7✔
460
  }
461

462
  /**
463
   * Resolves the fallback value from a {@link Fallback @Fallback} annotation.
464
   * <p>If the annotated SpEL expression is non-empty it is evaluated first;
465
   * otherwise the naming-convention method ({@code {methodName}Fallback}) is invoked.
466
   *
467
   * @param pjp      the join point providing method arguments
468
   * @param fallback the fallback annotation
469
   * @return the resolved fallback value
470
   */
471
  private Object resolveFallback(ProceedingJoinPoint pjp, Fallback fallback) throws Throwable {
472
    if (!fallback.value().isEmpty()) {
4✔
473
      return getExpression(fallback.value()).getValue(buildEvaluationContext(pjp));
9✔
474
    }
475
    return invokeFallbackMethod(pjp);
4✔
476
  }
477

478
  /**
479
   * Invokes the naming-convention fallback method on the target bean with
480
   * the original method arguments.
481
   *
482
   * <p>The fallback method is expected to follow the naming convention
483
   * {@code {originalMethodName}Fallback} with the same parameter types
484
   * as the original method. The resolved method is cached in
485
   * {@link #fallbackMethodCache} for subsequent invocations.
486
   *
487
   * <p>If no matching fallback method exists anywhere in the class
488
   * hierarchy, {@code null} is returned silently (no error is logged).
489
   *
490
   * @param pjp the join point providing the original method's arguments,
491
   *            target object, and signature metadata
492
   * @return the return value of the fallback method, or {@code null} if
493
   *         no fallback method was found
494
   * @throws Throwable the unwrapped cause if the fallback method throws
495
   */
496
  private Object invokeFallbackMethod(ProceedingJoinPoint pjp) throws Throwable {
497
    MethodSignature sig = (MethodSignature) pjp.getSignature();
4✔
498
    Method originalMethod = sig.getMethod();
3✔
499
    Object target = pjp.getTarget();
3✔
500
    Object[] args = pjp.getArgs();
3✔
501

502
    Method fallbackMethod = fallbackMethodCache.computeIfAbsent(originalMethod, m ->
9✔
503
      findFallbackMethod(target.getClass(), m.getName() + "Fallback", m.getParameterTypes())
10✔
504
    );
505
    if (fallbackMethod == null) {
2✔
506
      return null;
2✔
507
    }
508
    try {
509
      return fallbackMethod.invoke(target, args);
5✔
510
    } catch (InvocationTargetException e) {
1✔
511
      throw e.getCause();
3✔
512
    }
513
  }
514

515
  /**
516
   * Recursively searches the class hierarchy (superclasses, not interfaces)
517
   * for a method matching the given name and parameter types.
518
   *
519
   * <p>The search starts at the provided class and walks up the inheritance
520
   * chain via {@link Class#getSuperclass()}, stopping at {@link Object}.
521
   * This ensures that fallback methods defined in abstract base classes
522
   * or superclasses are discovered.
523
   *
524
   * <p>Interface default methods are <em>not</em> searched, as the
525
   * annotation-based contract is that the fallback method resides in the
526
   * concrete bean's class hierarchy.
527
   *
528
   * @param clazz      the class to start the search from (typically the
529
   *                   target object's runtime class)
530
   * @param name       the name of the fallback method to find (e.g.,
531
   *                   {@code "findUserFallback"})
532
   * @param paramTypes the parameter types of the method to find (must
533
   *                   exactly match the original method's parameter types)
534
   * @return the matching {@link Method}, or {@code null} if no method
535
   *         with the given name and parameter types exists in the
536
   *         hierarchy
537
   */
538
  private Method findFallbackMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
539
    try {
540
      return clazz.getMethod(name, paramTypes);
5✔
541
    } catch (NoSuchMethodException e) {
1✔
542
      Class<?> superclass = clazz.getSuperclass();
3✔
543
      if (superclass != null && superclass != Object.class) {
5!
544
        return findFallbackMethod(superclass, name, paramTypes);
×
545
      }
546
      return null;
2✔
547
    }
548
  }
549

550
  /**
551
   * Resolve {@link HotKeyPreload @HotKeyPreload} from the given method,
552
   * using a local cache to avoid repeated reflection lookups.
553
   *
554
   * @param method the candidate method
555
   * @return the {@code @HotKeyPreload} annotation if present, or {@code null}
556
   */
557
  private HotKeyPreload resolvePreloadAnnotation(Method method) {
558
    return preloadCache.computeIfAbsent(method, m -> m.getAnnotation(HotKeyPreload.class));
12✔
559
  }
560
}
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