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

Hyshmily / hotkey / 28216525937

26 Jun 2026 04:08AM UTC coverage: 91.258% (-1.4%) from 92.707%
28216525937

push

github

Hyshmily
feat: add circuit breaker, null-value caching, and heartbeat refinements

- Introduce HotKeyCircuitBreaker with sliding-window failure detection
- Cache null/miss results with configurable TTL (null-value-ttl-seconds)
- Add per-Worker exponential backoff to heartbeat verifier (verify-max-backoff-ms)
- Support min-alive-workers config for cluster health threshold
- Migrate HotKeyEndpoint from Actuator @Endpoint to Spring MVC @RestController
- Add NumberFormatException handling in StateMachineEndpoint

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

1226 of 1419 branches covered (86.4%)

Branch coverage included in aggregate %.

181 of 229 new or added lines in 12 files covered. (79.04%)

1 existing line in 1 file now uncovered.

3503 of 3763 relevant lines covered (93.09%)

4.22 hits per line

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

87.62
/common/src/main/java/io/github/hyshmily/hotkey/autoconfigure/HotKeyAmqpAutoConfiguration.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.autoconfigure;
17

18
import static io.github.hyshmily.hotkey.constants.HotKeyConstants.ROUTING_KEY_HEARTBEAT;
19

20
import com.github.benmanes.caffeine.cache.Cache;
21
import io.github.hyshmily.hotkey.cache.CacheExpireManager;
22
import io.github.hyshmily.hotkey.reporting.BbrRateLimiter;
23
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
24
import io.github.hyshmily.hotkey.reporting.ReportPublisher;
25
import io.github.hyshmily.hotkey.rule.RuleMatcher;
26
import io.github.hyshmily.hotkey.sharding.RingManager;
27
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
28
import io.github.hyshmily.hotkey.sync.local.CacheSyncListener;
29
import io.github.hyshmily.hotkey.sync.local.CacheSyncProperties;
30
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
31
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatMessage;
32
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatVerifier;
33
import io.github.hyshmily.hotkey.sync.worker.WorkerListener;
34
import io.github.hyshmily.hotkey.sync.worker.WorkerListenerProperties;
35
import io.github.hyshmily.hotkey.util.InstanceIdGenerator;
36
import io.github.hyshmily.hotkey.util.SystemLoadMonitor;
37
import io.github.hyshmily.hotkey.util.ratelimit.SreRateLimiter;
38
import java.util.concurrent.ScheduledExecutorService;
39
import java.util.function.Function;
40
import org.springframework.amqp.core.*;
41
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
42
import org.springframework.amqp.rabbit.core.RabbitTemplate;
43
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
44
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
45
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
46
import org.springframework.amqp.support.converter.MessageConverter;
47
import org.springframework.beans.factory.ObjectProvider;
48
import org.springframework.beans.factory.annotation.Qualifier;
49
import org.springframework.boot.autoconfigure.AutoConfiguration;
50
import org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration;
51
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
52
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
53
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
54
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
55
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
56
import org.springframework.boot.context.properties.EnableConfigurationProperties;
57
import org.springframework.context.annotation.Bean;
58
import org.springframework.context.annotation.Configuration;
59
import org.springframework.data.redis.core.RedisTemplate;
60
import org.springframework.data.redis.core.StringRedisTemplate;
61

62
/**
63
 * Unified AMQP auto-configuration for HotKey messaging: app-to-Worker reporting,
64
 * instance-to-instance cache sync, and Worker decision listening.
65
 *
66
 * <p>Conditionally activates when {@link RabbitTemplate} is on the classpath.
67
 * Sub-groups for cache sync and Worker listener additionally require Redis.
68
 *
69
 * <p><b>Report</b> ({@code hotkey.report.enabled}, default {@code true}):
70
 * app instance aggregates access counts and sends them to the Worker via
71
 * {@link DirectExchange}. No Redis dependency.
72
 *
73
 * <p><b>Cache Sync</b> ({@code hotkey.sync.enabled=true}):
74
 * instance-to-instance INVALIDATE / REFRESH broadcasts via {@link FanoutExchange}.
75
 * Requires Redis for version tracking.
76
 *
77
 * <p><b>Worker Listener</b> ({@code hotkey.worker-listener.enabled=true}):
78
 * receives HOT/COOL decisions from the Worker via {@link FanoutExchange}.
79
 * Requires Redis.
80
 */
81
@AutoConfiguration(after = { RedisAutoConfiguration.class, RabbitAutoConfiguration.class })
82
@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate")
83
@EnableConfigurationProperties({ HotKeyProperties.class, CacheSyncProperties.class, WorkerListenerProperties.class })
84
public class HotKeyAmqpAutoConfiguration {
3✔
85

86
  /**
87
   * Inner configuration for app-to-Worker report routing via DirectExchange.
88
   * Creates the exchange, publisher, ring manager (optional), reporter, and report scheduler.
89
   * Active by default when a {@link RabbitTemplate} bean is present.
90
   */
91
  @Configuration
92
  @ConditionalOnBean(RabbitTemplate.class)
93
  @ConditionalOnProperty(prefix = "hotkey.report", name = "enabled", havingValue = "true", matchIfMissing = true)
94
  static class ReportConfiguration {
3✔
95

96
    /**
97
     * Declare the DirectExchange for report routing (app → Worker).
98
     * Routing keys ({@code report.<appName>.<nodeId>}) ensure each key's
99
     * messages land on the correct worker queue.
100
     *
101
     * @param properties the HotKey configuration properties
102
     * @return a durable, non-auto-delete {@link DirectExchange}
103
     */
104
    @Bean
105
    public DirectExchange hotkeyReportExchange(HotKeyProperties properties) {
106
      return new DirectExchange(properties.getReportExchange(), true, false);
8✔
107
    }
108

109
    /**
110
     * Create the {@link MessageConverter} for serializing report messages to JSON.
111
     * <p>
112
     * Uses Jackson JSON serialization (not Java serialization) for efficiency and cross-version
113
     * compatibility.
114
     *
115
     * @return a new {@link Jackson2JsonMessageConverter} instance
116
     */
117
    @Bean
118
    @ConditionalOnMissingBean(MessageConverter.class)
119
    public MessageConverter reportMessageConverter() {
120
      return new Jackson2JsonMessageConverter();
4✔
121
    }
122

123
    /**
124
     * Create the {@link ReportPublisher} for sending batched access-count reports to the Worker.
125
     *
126
     * @param rabbitTemplate the RabbitMQ template for publishing messages
127
     * @param properties     the HotKey configuration properties
128
     * @return a new {@link ReportPublisher} instance
129
     */
130
    @Bean
131
    @ConditionalOnMissingBean
132
    public ReportPublisher reportPublisher(RabbitTemplate rabbitTemplate, HotKeyProperties properties) {
133
      return new ReportPublisher(rabbitTemplate, properties.getReportExchange(), properties.getAppName());
9✔
134
    }
135

136
    /**
137
     * Create the {@link RingManager} for consistent-hashing report routing.
138
     *
139
     * @param properties the HotKey configuration properties
140
     * @return a new {@link RingManager} instance
141
     */
142
    @Bean
143
    @ConditionalOnMissingBean
144
    public RingManager ringManager(HotKeyProperties properties) {
145
      return new RingManager(properties.getConsistentHashing().getVirtualNodes());
7✔
146
    }
147

148
    /**
149
     * Create the system CPU monitor with EMA smoothing.
150
     * <p>
151
     * Uses the JDK platform MXBean ({@link com.sun.management.OperatingSystemMXBean})
152
     * which is already used by the Worker-side heartbeat producer. The monitor
153
     * starts sampling on creation and stops on context close.
154
     *
155
     * @param properties the HotKey configuration properties
156
     * @return a new {@link SystemLoadMonitor} instance
157
     */
158
    @Bean(initMethod = "start", destroyMethod = "stop")
159
    @ConditionalOnMissingBean
160
    public SystemLoadMonitor hotKeyCpuMonitor(
161
      HotKeyProperties properties,
162
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler
163
    ) {
164
      HotKeyProperties.ReporterLimiter cfg = properties.getReporter();
3✔
165
      return new SystemLoadMonitor(hotKeyScheduler, cfg.getCpuPollIntervalMs(), cfg.getCpuDecay());
9✔
166
    }
167

168
    /**
169
     * Create the BBR adaptive rate limiter for the report publisher.
170
     * <p>
171
     * Uses the CPU monitor and the configured BBR parameters. When disabled
172
     * (or when the CPU monitor itself hasn't been fully initialized yet),
173
     * the limiter falls back to a permissive mode.
174
     *
175
     * @param cpuMonitor the system CPU load monitor
176
     * @param properties the HotKey configuration properties
177
     * @return a new {@link BbrRateLimiter} instance
178
     */
179
    @Bean
180
    @ConditionalOnMissingBean
181
    @ConditionalOnProperty(
182
      prefix = "hotkey.local.reporter",
183
      name = "enabled",
184
      havingValue = "true",
185
      matchIfMissing = true
186
    )
187
    public BbrRateLimiter hotKeyBbrRateLimiter(SystemLoadMonitor cpuMonitor, HotKeyProperties properties) {
188
      HotKeyProperties.ReporterLimiter cfg = properties.getReporter();
3✔
189
      return new BbrRateLimiter(
5✔
190
        cpuMonitor,
191
        cfg.getCpuThreshold(),
2✔
192
        cfg.getBbrWindowMs(),
2✔
193
        cfg.getBbrWindowBuckets(),
2✔
194
        cfg.getBbrCooldownMs()
2✔
195
      );
196
    }
197

198
    /**
199
     * Create the {@link HotKeyReporter} that aggregates per-key counts and flushes them
200
     * at the configured interval.
201
     *
202
     * @param reportPublisher       the report publisher for sending batches
203
     * @param properties            the HotKey configuration properties
204
     * @param ringManager           the consistent-hash ring manager
205
     * @param healthViewProvider    optional provider for the cluster health view
206
     * @param bbrRateLimiterProvider optional provider for the BBR rate limiter
207
     * @return a new {@link HotKeyReporter} instance
208
     */
209
    @Bean(initMethod = "start", destroyMethod = "stop")
210
    @ConditionalOnMissingBean
211
    public HotKeyReporter hotKeyReporter(
212
      ReportPublisher reportPublisher,
213
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler,
214
      HotKeyProperties properties,
215
      RingManager ringManager,
216
      ObjectProvider<ClusterHealthView> healthViewProvider,
217
      ObjectProvider<BbrRateLimiter> bbrRateLimiterProvider
218
    ) {
219
      HotKeyReporter reporter = new HotKeyReporter(
5✔
220
        reportPublisher,
221
        hotKeyScheduler,
222
        properties.getReportIntervalMs(),
2✔
223
        properties.getAppName(),
2✔
224
        properties.getQueueCapacity(),
2✔
225
        properties.getQueueOfferTimeoutMs(),
2✔
226
        properties.effectiveConsumerCount(),
5✔
227
        ringManager,
228
        healthViewProvider.getIfAvailable(() ->
4✔
229
          new ClusterHealthView(
×
230
            properties.getExpectedWorkerCount(),
×
231
            properties.getHeartbeat().getTimeoutMs(),
×
232
            properties.getHeartbeat().getDegradeAfterFailures()
×
233
          )
234
        )
235
      );
236
      bbrRateLimiterProvider.ifAvailable(reporter::setBbrRateLimiter);
7✔
237
      return reporter;
2✔
238
    }
239
  }
240

241
  /**
242
   * Inner configuration for instance-to-instance cache synchronization.
243
   * Creates a FanoutExchange, per-instance queue with TTL, binding, publisher,
244
   * Redis loader, sync listener, and a dedicated scheduled executor.
245
   * Requires Redis and {@code hotkey.sync.enabled=true}.
246
   */
247
  @Configuration
248
  @ConditionalOnClass(name = "org.springframework.data.redis.core.RedisTemplate")
249
  @ConditionalOnProperty(prefix = "hotkey.sync", name = "enabled", havingValue = "true")
250
  @lombok.extern.slf4j.Slf4j
4✔
251
  static class SyncConfiguration {
3✔
252

253
    /**
254
     * Create the FanoutExchange for broadcasting INVALIDATE/REFRESH messages
255
     * to all app instances.
256
     *
257
     * @param properties the cache sync configuration properties
258
     * @return a durable, non-auto-delete {@link FanoutExchange}
259
     */
260
    @Bean
261
    @ConditionalOnClass(name = "org.springframework.amqp.core.FanoutExchange")
262
    public FanoutExchange hotkeySyncExchange(CacheSyncProperties properties) {
263
      return new FanoutExchange(properties.getExchangeName(), true, false);
8✔
264
    }
265

266
    /**
267
     * Create the per-instance sync queue with a 60-second message TTL and 24-hour idle expiry.
268
     *
269
     * @param properties the cache sync configuration properties
270
     * @return a durable {@link Queue} with {@code x-message-ttl} of 60 seconds
271
     *         and {@code x-expires} of 24 hours
272
     */
273
    @Bean
274
    @ConditionalOnClass(name = "org.springframework.amqp.core.Queue")
275
    public Queue hotkeySyncQueue(CacheSyncProperties properties) {
276
      return QueueBuilder.durable(properties.getQueueName())
6✔
277
        .withArgument("x-message-ttl", 60_000)
4✔
278
        .withArgument("x-expires", 86_400_000)
2✔
279
        .build();
1✔
280
    }
281

282
    /**
283
     * Bind the per-instance queue to the sync exchange.
284
     *
285
     * @param hotkeySyncQueue    the per-instance sync queue
286
     * @param hotkeySyncExchange the sync FanoutExchange
287
     * @return a {@link Binding} connecting the queue to the exchange
288
     */
289
    @Bean
290
    public Binding hotkeySyncBinding(Queue hotkeySyncQueue, FanoutExchange hotkeySyncExchange) {
291
      return BindingBuilder.bind(hotkeySyncQueue).to(hotkeySyncExchange);
5✔
292
    }
293

294
    /**
295
     * Create the cache sync publisher for sending INVALIDATE/REFRESH messages.
296
     *
297
     * @param rabbitTemplate the RabbitMQ template for publishing messages
298
     * @param properties     the cache sync configuration properties
299
     * @return a new {@link CacheSyncPublisher} instance
300
     */
301
    @Bean
302
    @ConditionalOnMissingBean
303
    public CacheSyncPublisher cacheSyncPublisher(RabbitTemplate rabbitTemplate, CacheSyncProperties properties) {
304
      return new CacheSyncPublisher(rabbitTemplate, properties);
6✔
305
    }
306

307
    /**
308
     * Default Redis loader used by the sync listener to refresh cache entries via {@code GET}.
309
     *
310
     * @param stringRedisTemplate the String-based Redis template for reading values
311
     * @return a {@link Function} that reads a key from Redis and returns its value
312
     */
313
    @Bean
314
    @ConditionalOnMissingBean(name = "hotKeyRedisLoader")
315
    public Function<String, Object> hotKeyRedisLoader(StringRedisTemplate stringRedisTemplate) {
316
      return key -> stringRedisTemplate.opsForValue().get(key);
3✔
317
    }
318

319
    /**
320
     * Create the sync listener that handles incoming INVALIDATE/REFRESH messages from peers.
321
     *
322
     * @param hotLocalCache       the L1 Caffeine cache
323
     * @param hotKeyRedisLoader   the function for loading values from Redis
324
     * @param properties          the cache sync configuration properties
325
     * @param expireManager       the cache expiry manager
326
     * @param ruleMatcher         the rule matcher for key matching
327
     * @return a new {@link CacheSyncListener} instance
328
     */
329
    @Bean
330
    @ConditionalOnMissingBean
331
    public CacheSyncListener cacheSyncListener(
332
      Cache<String, Object> hotLocalCache,
333
      Function<String, Object> hotKeyRedisLoader,
334
      CacheSyncProperties properties,
335
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler,
336
      CacheExpireManager expireManager,
337
      RuleMatcher ruleMatcher
338
    ) {
339
      return new CacheSyncListener(
10✔
340
        hotLocalCache,
341
        hotKeyRedisLoader,
342
        properties,
343
        hotKeyScheduler,
344
        expireManager,
345
        ruleMatcher
346
      );
347
    }
348

349
    /**
350
     * Create the AMQP message listener container that drives the sync listener.
351
     *
352
     * @param connectionFactory  the RabbitMQ connection factory
353
     * @param cacheSyncListener  the sync message handler
354
     * @param properties         the cache sync configuration properties
355
     * @return a configured {@link SimpleMessageListenerContainer}
356
     */
357
    @Bean
358
    @ConditionalOnBean(ConnectionFactory.class)
359
    public SimpleMessageListenerContainer syncListenerContainer(
360
      ConnectionFactory connectionFactory,
361
      CacheSyncListener cacheSyncListener,
362
      CacheSyncProperties properties
363
    ) {
364
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
365
      container.setQueueNames(properties.getQueueName());
9✔
366
      container.setAutoStartup(properties.isAutoStartup());
4✔
367
      container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
3✔
368
      container.setConcurrentConsumers(properties.getConcurrentConsumers());
4✔
369
      container.setPrefetchCount(properties.getPrefetchCount());
4✔
370
      container.setErrorHandler(t -> log.warn("Sync listener uncaught exception (message will be requeued by container)", t));
3✔
371
      container.setMessageListener(
4✔
372
        (ChannelAwareMessageListener) (msg, channel) -> cacheSyncListener.handleSyncMessage(channel, msg)
×
373
      );
374
      return container;
2✔
375
    }
376
  }
377

378
  /**
379
   * Inner configuration for receiving Worker HOT/COOL decisions.
380
   * Creates a FanoutExchange, per-instance queue with TTL, binding, worker listener,
381
   * listener container, and a dedicated scheduled executor.
382
   * Requires Redis and {@code hotkey.worker-listener.enabled=true}.
383
   */
384
  @Configuration
385
  @ConditionalOnClass(name = "org.springframework.data.redis.core.RedisTemplate")
386
  @ConditionalOnBean(RedisTemplate.class)
387
  @ConditionalOnProperty(prefix = "hotkey.worker-listener", name = "enabled", havingValue = "true")
388
  @lombok.extern.slf4j.Slf4j
4✔
389
  static class WorkerListenerConfiguration {
3✔
390

391
    /**
392
     * Create the FanoutExchange for broadcasting Worker HOT/COOL decisions
393
     * to all app instances.
394
     *
395
     * @param properties the Worker listener configuration properties
396
     * @return a durable, non-auto-delete {@link FanoutExchange}
397
     */
398
    @Bean
399
    @ConditionalOnMissingBean(name = "hotkeyWorkerExchange")
400
    public FanoutExchange hotkeyWorkerExchange(WorkerListenerProperties properties) {
401
      return new FanoutExchange(properties.getExchangeName(), true, false);
8✔
402
    }
403

404
    /**
405
     * Create the per-instance Worker listener queue with a 60-second message TTL and 24-hour idle expiry.
406
     *
407
     * @param properties the Worker listener configuration properties
408
     * @return a durable {@link Queue} with {@code x-message-ttl} of 60 seconds
409
     *         and {@code x-expires} of 24 hours
410
     */
411
    @Bean
412
    @ConditionalOnMissingBean(name = "hotkeyWorkerQueue")
413
    public Queue hotkeyWorkerQueue(WorkerListenerProperties properties) {
414
      return QueueBuilder.durable(properties.getQueueName())
6✔
415
        .withArgument("x-message-ttl", 60_000)
4✔
416
        .withArgument("x-expires", 86_400_000)
2✔
417
        .build();
1✔
418
    }
419

420
    /**
421
     * Bind the per-instance queue to the Worker exchange.
422
     *
423
     * @param hotkeyWorkerQueue    the per-instance Worker listener queue
424
     * @param hotkeyWorkerExchange the Worker FanoutExchange
425
     * @return a {@link Binding} connecting the queue to the exchange
426
     */
427
    @Bean
428
    public Binding hotkeyWorkerBinding(Queue hotkeyWorkerQueue, FanoutExchange hotkeyWorkerExchange) {
429
      return BindingBuilder.bind(hotkeyWorkerQueue).to(hotkeyWorkerExchange);
5✔
430
    }
431

432
    /**
433
     * Create the TopicExchange for Worker heartbeat broadcasts.
434
     *
435
     * @param properties the HotKey configuration properties
436
     * @return a durable, non-auto-delete {@link TopicExchange}
437
     */
438
    @Bean
439
    @ConditionalOnMissingBean(name = "hotkeyHeartbeatExchange")
440
    public TopicExchange hotkeyHeartbeatExchange(HotKeyProperties properties) {
441
      return new TopicExchange(properties.getHeartbeat().getExchangeName(), true, false);
9✔
442
    }
443

444
    /**
445
     * Create the per-instance non-durable heartbeat queue that auto-deletes on disconnect.
446
     *
447
     * @return a non-durable, auto-delete {@link Queue}
448
     */
449
    @Bean
450
    public Queue hotkeyHeartbeatQueue() {
451
      return QueueBuilder.nonDurable("hotkey.heartbeat:" + InstanceIdGenerator.get()).autoDelete().build();
6✔
452
    }
453

454
    /**
455
     * Bind the per-instance heartbeat queue to the heartbeat exchange with routing key {@code heartbeat.*}.
456
     *
457
     * @param hotkeyHeartbeatQueue    the per-instance heartbeat queue
458
     * @param hotkeyHeartbeatExchange the heartbeat TopicExchange
459
     * @return a {@link Binding} connecting the queue to the exchange
460
     */
461
    @Bean
462
    public Binding hotkeyHeartbeatBinding(Queue hotkeyHeartbeatQueue, TopicExchange hotkeyHeartbeatExchange) {
463
      return BindingBuilder.bind(hotkeyHeartbeatQueue).to(hotkeyHeartbeatExchange).with(ROUTING_KEY_HEARTBEAT + "*");
7✔
464
    }
465

466
    /**
467
     * Create the {@link ClusterHealthView} for tracking Worker cluster health.
468
     *
469
     * @param ringManager the consistent-hash ring manager
470
     * @param properties  the HotKey configuration properties
471
     * @return a new {@link ClusterHealthView} instance
472
     */
473
    @Bean
474
    @ConditionalOnMissingBean
475
    public ClusterHealthView clusterHealthView(RingManager ringManager, HotKeyProperties properties) {
476
      ClusterHealthView view = new ClusterHealthView(
3✔
477
        properties.getExpectedWorkerCount(),
2✔
478
        properties.getHeartbeat().getTimeoutMs(),
4✔
479
        properties.getHeartbeat().getDegradeAfterFailures()
4✔
480
      );
481
      view.setMinAliveWorkers(properties.getHeartbeat().getMinAliveWorkers());
5✔
482
      ringManager.setOnRingReconciled(aliveCount -> {
3✔
483
        // knownWorkerCount is managed by the user via
484
        // hotkey.local.expected-worker-count. When <=0 (dynamic mode),
485
        // isClusterHealthy() uses "any alive" logic so no adjustment needed.
UNCOV
486
      });
×
487
      return view;
2✔
488
    }
489

490
    /**
491
     * Create the SRE adaptive rate limiter for WorkerListener HOT-path throttling.
492
     * <p>
493
     * Disabled when {@code hotkey.worker-listener.sre.enabled=false}.
494
     *
495
     * @param properties the Worker listener configuration properties
496
     * @return a new {@link SreRateLimiter} instance
497
     */
498
    @Bean
499
    @ConditionalOnMissingBean
500
    @ConditionalOnProperty(
501
      prefix = "hotkey.worker-listener.sre",
502
      name = "enabled",
503
      havingValue = "true",
504
      matchIfMissing = true
505
    )
506
    public SreRateLimiter hotKeySreRateLimiter(WorkerListenerProperties properties) {
507
      WorkerListenerProperties.Sre sreConfig = properties.getSre();
3✔
508
      return new SreRateLimiter(
4✔
509
        sreConfig.getWindowMs(),
2✔
510
        sreConfig.getBuckets(),
3✔
511
        1.0 / sreConfig.getSuccessThreshold(),
3✔
512
        sreConfig.getMinSamples()
2✔
513
      );
514
    }
515

516
    /**
517
     * Create the listener that processes HOT/COOL decisions broadcast by the Worker.
518
     *
519
     * @param hotLocalCache          the L1 Caffeine cache
520
     * @param hotKeyRedisLoader      the function for loading values from Redis
521
     * @param properties             the Worker listener configuration properties
522
     * @param expireManager          the cache expiry manager
523
     * @param sreRateLimiterProvider optional provider for the SRE rate limiter
524
     * @return a new {@link WorkerListener} instance
525
     */
526
    @Bean
527
    @ConditionalOnMissingBean
528
    public WorkerListener workerListener(
529
      Cache<String, Object> hotLocalCache,
530
      Function<String, Object> hotKeyRedisLoader,
531
      WorkerListenerProperties properties,
532
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler,
533
      CacheExpireManager expireManager,
534
      ObjectProvider<SreRateLimiter> sreRateLimiterProvider
535
    ) {
536
      return new WorkerListener(
9✔
537
        hotLocalCache,
538
        hotKeyRedisLoader,
539
        properties,
540
        hotKeyScheduler,
541
        expireManager,
542
        sreRateLimiterProvider.getIfAvailable()
3✔
543
      );
544
    }
545

546
    /**
547
     * Create the AMQP message listener container that processes Worker heartbeat messages.
548
     *
549
     * @param connectionFactory   the RabbitMQ connection factory
550
     * @param healthView          the cluster health view to update on heartbeat reception
551
     * @param hotkeyHeartbeatQueue the heartbeat queue
552
     * @return a configured {@link SimpleMessageListenerContainer}
553
     */
554
    @Bean
555
    @ConditionalOnMissingBean(name = "hotkeyHeartbeatContainer")
556
    public SimpleMessageListenerContainer heartbeatContainer(
557
      ConnectionFactory connectionFactory,
558
      ClusterHealthView healthView,
559
      Queue hotkeyHeartbeatQueue
560
    ) {
561
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
562
      container.setQueueNames(hotkeyHeartbeatQueue.getName());
9✔
563
      container.setAcknowledgeMode(AcknowledgeMode.NONE);
3✔
564
      container.setConcurrentConsumers(1);
3✔
565
      container.setPrefetchCount(100);
3✔
566
      container.setErrorHandler(t -> log.warn("Heartbeat listener uncaught exception (message discarded)", t));
3✔
567
      container.setMessageListener(msg -> {
4✔
568
        WorkerHeartbeatMessage hb = WorkerHeartbeatMessage.from(msg);
×
569
        if (hb != null) {
×
570
          healthView.onHeartbeat(hb);
×
571
        }
572
      });
×
573
      return container;
2✔
574
    }
575

576
    /**
577
     * Create the AMQP message listener container that processes Worker HOT/COOL decisions
578
     * via the {@link WorkerListener}.
579
     *
580
     * <p>The container uses {@link AcknowledgeMode#MANUAL} because the
581
     * {@link WorkerListener#handleWorkerMessage} performs its own ack/nack
582
     * (ack-before-update pattern, see ADR-0004).
583
     *
584
     * @param connectionFactory the RabbitMQ connection factory
585
     * @param hotkeyWorkerQueue the per-instance Worker listener queue
586
     * @param workerListener    the Worker decision listener
587
     * @param properties        the Worker listener configuration properties
588
     * @return a configured {@link SimpleMessageListenerContainer}
589
     */
590
    @Bean
591
    @ConditionalOnMissingBean(name = "workerListenerContainer")
592
    public SimpleMessageListenerContainer workerListenerContainer(
593
      ConnectionFactory connectionFactory,
594
      Queue hotkeyWorkerQueue,
595
      WorkerListener workerListener,
596
      WorkerListenerProperties properties
597
    ) {
598
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
599
      container.setQueueNames(hotkeyWorkerQueue.getName());
9✔
600
      container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
3✔
601
      container.setMessageListener(
4✔
602
        (ChannelAwareMessageListener) (msg, channel) -> workerListener.handleWorkerMessage(channel, msg)
×
603
      );
604
      container.setConcurrentConsumers(properties.getConcurrentConsumers());
4✔
605
      container.setPrefetchCount(properties.getPrefetchCount());
4✔
606
      container.setErrorHandler(t -> log.warn("Worker listener uncaught exception (message will be requeued by container)", t));
3✔
607
      container.setAutoStartup(properties.isAutoStartup());
4✔
608
      return container;
2✔
609
    }
610

611
    /**
612
     * Create the {@link WorkerHeartbeatVerifier} that periodically PINGs Workers
613
     * to verify they are alive.
614
     *
615
     * @param rabbitTemplate the RabbitMQ template for sending PING messages
616
     * @param healthView     the cluster health view to update on verification results
617
     * @param properties     the HotKey configuration properties
618
     * @return a new {@link WorkerHeartbeatVerifier} instance
619
     */
620
    @Bean(initMethod = "start", destroyMethod = "stop")
621
    @ConditionalOnMissingBean
622
    public WorkerHeartbeatVerifier workerHeartbeatVerifier(
623
      RabbitTemplate rabbitTemplate,
624
      ClusterHealthView healthView,
625
      HotKeyProperties properties,
626
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler
627
    ) {
628
      return new WorkerHeartbeatVerifier(
6✔
629
        rabbitTemplate,
630
        healthView,
631
        properties.getInstanceId(),
2✔
632
        properties.getHeartbeat().getVerifyIntervalMs(),
3✔
633
        properties.getHeartbeat().getPingTimeoutMs(),
3✔
634
        properties.getHeartbeat().getDegradeAfterFailures(),
3✔
635
        properties.getHeartbeat().getVerifyMaxBackoffMs(),
4✔
636
        hotKeyScheduler
637
      );
638
    }
639
  }
640
}
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