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

Hyshmily / hotkey / 28637103998

03 Jul 2026 03:48AM UTC coverage: 90.399% (-0.2%) from 90.63%
28637103998

push

github

Hyshmily
fix: add packaging to gitignore, fix flaky jitter test, update docs references

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

1264 of 1461 branches covered (86.52%)

Branch coverage included in aggregate %.

3604 of 3924 relevant lines covered (91.85%)

4.19 hits per line

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

82.73
/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.Internal;
22
import io.github.hyshmily.hotkey.cache.cachesupport.CacheExpireManager;
23
import io.github.hyshmily.hotkey.cache.loader.CacheLoader;
24
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
25
import io.github.hyshmily.hotkey.reporting.BbrRateLimiter;
26
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
27
import io.github.hyshmily.hotkey.reporting.ReportPublisher;
28
import io.github.hyshmily.hotkey.rule.RuleMatcher;
29
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
30
import io.github.hyshmily.hotkey.sharding.RingManager;
31
import io.github.hyshmily.hotkey.sync.local.CacheSyncListener;
32
import io.github.hyshmily.hotkey.sync.local.CacheSyncProperties;
33
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
34
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatMessage;
35
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatVerifier;
36
import io.github.hyshmily.hotkey.sync.worker.WorkerListener;
37
import io.github.hyshmily.hotkey.sync.worker.WorkerListenerProperties;
38
import io.github.hyshmily.hotkey.util.HotKeyThreadFactory;
39
import io.github.hyshmily.hotkey.util.InstanceIdGenerator;
40
import io.github.hyshmily.hotkey.util.SystemLoadMonitor;
41
import io.github.hyshmily.hotkey.util.ratelimit.SreRateLimiter;
42
import java.util.concurrent.Executors;
43
import java.util.concurrent.ScheduledExecutorService;
44
import org.springframework.amqp.core.*;
45
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
46
import org.springframework.amqp.rabbit.core.RabbitTemplate;
47
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
48
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
49
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
50
import org.springframework.amqp.support.converter.MessageConverter;
51
import org.springframework.beans.factory.ObjectProvider;
52
import org.springframework.beans.factory.annotation.Qualifier;
53
import org.springframework.boot.autoconfigure.AutoConfiguration;
54
import org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration;
55
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
56
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
57
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
58
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
59
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
60
import org.springframework.boot.context.properties.EnableConfigurationProperties;
61
import org.springframework.context.annotation.Bean;
62
import org.springframework.context.annotation.Configuration;
63
import org.springframework.data.redis.core.RedisTemplate;
64
import org.springframework.data.redis.core.StringRedisTemplate;
65

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

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

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

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

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

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

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

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

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

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

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

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

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

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

312
    /**
313
     * Dedicated scheduler for jitter-delayed cache-update tasks received from
314
     * peer instances. Isolated from {@code hotKeyScheduler} so that synchronous
315
     * Redis GETs performed by {@code handleRefresh} can never starve the
316
     * 50 ms reporter flush tick.
317
     *
318
     * <p>Pool size is {@code hotkey.sync.scheduler-pool-size} (default 4) but
319
     * should be at least {@code hotkey.sync.concurrent-consumers × 2}.
320
     *
321
     * @param properties the cache sync configuration properties
322
     * @return a daemon-thread scheduled executor named {@code hotkey-sync-sched-N}
323
     */
324
    @Bean(name = "hotKeySyncScheduler", destroyMethod = "shutdown")
325
    @ConditionalOnMissingBean(name = "hotKeySyncScheduler")
326
    public ScheduledExecutorService hotKeySyncScheduler(CacheSyncProperties properties) {
327
      int poolSize = Math.max(properties.getSchedulerPoolSize(), properties.getConcurrentConsumers() * 2);
×
328
      return Executors.newScheduledThreadPool(
×
329
        poolSize,
330
        new HotKeyThreadFactory(HotKeyConstants.THREAD_PREFIX_SCHEDULER + "-sync")
331
      );
332
    }
333

334
    /**
335
     * Default Redis loader used by the sync listener to refresh cache entries via {@code GET}.
336
     *
337
     * @param stringRedisTemplate the String-based Redis template for reading values
338
     * @return a {@link CacheLoader} that reads a key from Redis and returns its value
339
     */
340
    @Bean
341
    @ConditionalOnMissingBean(CacheLoader.class)
342
    public CacheLoader hotKeyRedisLoader(StringRedisTemplate stringRedisTemplate) {
343
      return new io.github.hyshmily.hotkey.cache.loader.RedisCacheLoader(stringRedisTemplate);
5✔
344
    }
345

346
    /**
347
     * Create the sync listener that handles incoming INVALIDATE/REFRESH messages from peers.
348
     *
349
     * @param hotLocalCache       the L1 Caffeine cache
350
     * @param hotKeyRedisLoader   the function for loading values from Redis
351
     * @param properties          the cache sync configuration properties
352
     * @param expireManager       the cache expiry manager
353
     * @param ruleMatcher         the rule matcher for key matching
354
     * @return a new {@link CacheSyncListener} instance
355
     */
356
    @Bean
357
    @ConditionalOnMissingBean
358
    public CacheSyncListener cacheSyncListener(
359
      Cache<String, Object> hotLocalCache,
360
      CacheLoader hotKeyRedisLoader,
361
      CacheSyncProperties properties,
362
      @Qualifier("hotKeySyncScheduler") ScheduledExecutorService syncScheduler,
363
      CacheExpireManager expireManager,
364
      RuleMatcher ruleMatcher
365
    ) {
366
      return new CacheSyncListener(
10✔
367
        hotLocalCache,
368
        hotKeyRedisLoader,
369
        properties,
370
        syncScheduler,
371
        expireManager,
372
        ruleMatcher
373
      );
374
    }
375

376
    /**
377
     * Create the AMQP message listener container that drives the sync listener.
378
     *
379
     * @param connectionFactory  the RabbitMQ connection factory
380
     * @param cacheSyncListener  the sync message handler
381
     * @param properties         the cache sync configuration properties
382
     * @return a configured {@link SimpleMessageListenerContainer}
383
     */
384
    @Bean
385
    @ConditionalOnBean(ConnectionFactory.class)
386
    public SimpleMessageListenerContainer syncListenerContainer(
387
      ConnectionFactory connectionFactory,
388
      CacheSyncListener cacheSyncListener,
389
      CacheSyncProperties properties
390
    ) {
391
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
392
      container.setQueueNames(properties.getQueueName());
9✔
393
      container.setAutoStartup(properties.isAutoStartup());
4✔
394
      container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
3✔
395
      container.setConcurrentConsumers(properties.getConcurrentConsumers());
4✔
396
      container.setPrefetchCount(properties.getPrefetchCount());
4✔
397
      container.setErrorHandler(t ->
3✔
398
        log.warn("Sync listener uncaught exception (message will be requeued by container)", t)
×
399
      );
400
      container.setMessageListener(
4✔
401
        (ChannelAwareMessageListener) (msg, channel) -> cacheSyncListener.handleSyncMessage(channel, msg)
×
402
      );
403
      return container;
2✔
404
    }
405
  }
406

407
  /**
408
   * Inner configuration for receiving Worker HOT/COOL decisions.
409
   * Creates a FanoutExchange, per-instance queue with TTL, binding, worker listener,
410
   * listener container, and a dedicated scheduled executor.
411
   * Requires Redis and {@code hotkey.worker-listener.enabled=true}.
412
   */
413
  @Configuration
414
  @ConditionalOnClass(name = "org.springframework.data.redis.core.RedisTemplate")
415
  @ConditionalOnBean(RedisTemplate.class)
416
  @ConditionalOnProperty(prefix = "hotkey.worker-listener", name = "enabled", havingValue = "true")
417
  @lombok.extern.slf4j.Slf4j
4✔
418
  static class WorkerListenerConfiguration {
3✔
419

420
    /**
421
     * Create the FanoutExchange for broadcasting Worker HOT/COOL decisions
422
     * to all app instances.
423
     *
424
     * @param properties the Worker listener configuration properties
425
     * @return a durable, non-auto-delete {@link FanoutExchange}
426
     */
427
    @Bean
428
    @ConditionalOnMissingBean(name = "hotkeyWorkerExchange")
429
    public FanoutExchange hotkeyWorkerExchange(WorkerListenerProperties properties) {
430
      return new FanoutExchange(properties.getExchangeName(), true, false);
8✔
431
    }
432

433
    /**
434
     * Create the per-instance Worker listener queue with a 60-second message TTL and 24-hour idle expiry.
435
     *
436
     * @param properties the Worker listener configuration properties
437
     * @return a durable {@link Queue} with {@code x-message-ttl} of 60 seconds
438
     *         and {@code x-expires} of 24 hours
439
     */
440
    @Bean
441
    @ConditionalOnMissingBean(name = "hotkeyWorkerQueue")
442
    public Queue hotkeyWorkerQueue(WorkerListenerProperties properties) {
443
      return QueueBuilder.durable(properties.getQueueName())
6✔
444
        .withArgument("x-message-ttl", 60_000)
4✔
445
        .withArgument("x-expires", 86_400_000)
2✔
446
        .build();
1✔
447
    }
448

449
    /**
450
     * Bind the per-instance queue to the Worker exchange.
451
     *
452
     * @param hotkeyWorkerQueue    the per-instance Worker listener queue
453
     * @param hotkeyWorkerExchange the Worker FanoutExchange
454
     * @return a {@link Binding} connecting the queue to the exchange
455
     */
456
    @Bean
457
    public Binding hotkeyWorkerBinding(Queue hotkeyWorkerQueue, FanoutExchange hotkeyWorkerExchange) {
458
      return BindingBuilder.bind(hotkeyWorkerQueue).to(hotkeyWorkerExchange);
5✔
459
    }
460

461
    /**
462
     * Create the TopicExchange for Worker heartbeat broadcasts.
463
     *
464
     * @param properties the HotKey configuration properties
465
     * @return a durable, non-auto-delete {@link TopicExchange}
466
     */
467
    @Bean
468
    @ConditionalOnMissingBean(name = "hotkeyHeartbeatExchange")
469
    public TopicExchange hotkeyHeartbeatExchange(HotKeyProperties properties) {
470
      return new TopicExchange(properties.getHeartbeat().getExchangeName(), true, false);
9✔
471
    }
472

473
    /**
474
     * Create the per-instance non-durable heartbeat queue that auto-deletes on disconnect.
475
     *
476
     * @return a non-durable, auto-delete {@link Queue}
477
     */
478
    @Bean
479
    public Queue hotkeyHeartbeatQueue() {
480
      return QueueBuilder.nonDurable("hotkey.heartbeat:" + InstanceIdGenerator.get()).autoDelete().build();
6✔
481
    }
482

483
    /**
484
     * Bind the per-instance heartbeat queue to the heartbeat exchange with routing key {@code heartbeat.*}.
485
     *
486
     * @param hotkeyHeartbeatQueue    the per-instance heartbeat queue
487
     * @param hotkeyHeartbeatExchange the heartbeat TopicExchange
488
     * @return a {@link Binding} connecting the queue to the exchange
489
     */
490
    @Bean
491
    public Binding hotkeyHeartbeatBinding(Queue hotkeyHeartbeatQueue, TopicExchange hotkeyHeartbeatExchange) {
492
      return BindingBuilder.bind(hotkeyHeartbeatQueue).to(hotkeyHeartbeatExchange).with(ROUTING_KEY_HEARTBEAT + "*");
7✔
493
    }
494

495
    /**
496
     * Create the {@link ClusterHealthView} for tracking Worker cluster health.
497
     *
498
     * @param ringManager the consistent-hash ring manager
499
     * @param properties  the HotKey configuration properties
500
     * @return a new {@link ClusterHealthView} instance
501
     */
502
    @Bean
503
    @ConditionalOnMissingBean
504
    public ClusterHealthView clusterHealthView(RingManager ringManager, HotKeyProperties properties) {
505
      ClusterHealthView view = new ClusterHealthView(
3✔
506
        properties.getExpectedWorkerCount(),
2✔
507
        properties.getHeartbeat().getTimeoutMs(),
4✔
508
        properties.getHeartbeat().getDegradeAfterFailures()
4✔
509
      );
510
      view.setMinAliveWorkers(properties.getHeartbeat().getMinAliveWorkers());
5✔
511
      ringManager.setOnRingReconciled(aliveCount -> {
3✔
512
        // knownWorkerCount is managed by the user via
513
        // hotkey.local.expected-worker-count. When <=0 (dynamic mode),
514
        // isClusterHealthy() uses "any alive" logic so no adjustment needed.
515
      });
×
516
      return view;
2✔
517
    }
518

519
    /**
520
     * Create the SRE adaptive rate limiter for WorkerListener HOT-path throttling.
521
     * <p>
522
     * Disabled when {@code hotkey.worker-listener.sre.enabled=false}.
523
     *
524
     * @param properties the Worker listener configuration properties
525
     * @return a new {@link SreRateLimiter} instance
526
     */
527
    @Bean
528
    @ConditionalOnMissingBean
529
    @ConditionalOnProperty(
530
      prefix = "hotkey.worker-listener.sre",
531
      name = "enabled",
532
      havingValue = "true",
533
      matchIfMissing = true
534
    )
535
    public SreRateLimiter hotKeySreRateLimiter(WorkerListenerProperties properties) {
536
      WorkerListenerProperties.Sre sreConfig = properties.getSre();
3✔
537
      return new SreRateLimiter(
4✔
538
        sreConfig.getWindowMs(),
2✔
539
        sreConfig.getBuckets(),
3✔
540
        1.0 / sreConfig.getSuccessThreshold(),
3✔
541
        sreConfig.getMinSamples()
2✔
542
      );
543
    }
544

545
    /**
546
     * Dedicated scheduler for jitter-delayed cache-update tasks received from
547
     * the Worker (HOT/COOL decisions). Isolated from {@code hotKeyScheduler}
548
     * so that synchronous Redis GETs performed by {@code handleHot} can never
549
     * starve the reporter flush tick.
550
     *
551
     * @param properties the Worker listener configuration properties
552
     * @return a daemon-thread scheduled executor named {@code hotkey-worker-sched-N}
553
     */
554
    @Bean(name = "hotKeyWorkerSchedScheduler", destroyMethod = "shutdown")
555
    @ConditionalOnMissingBean(name = "hotKeyWorkerSchedScheduler")
556
    public ScheduledExecutorService hotKeyWorkerSchedScheduler(WorkerListenerProperties properties) {
557
      int poolSize = Math.max(properties.getSchedulerPoolSize(), properties.getConcurrentConsumers() * 2);
×
558
      return Executors.newScheduledThreadPool(
×
559
        poolSize,
560
        new HotKeyThreadFactory(HotKeyConstants.THREAD_PREFIX_SCHEDULER + "-worker")
561
      );
562
    }
563

564
    /**
565
     * Create the listener that processes HOT/COOL decisions broadcast by the Worker.
566
     *
567
     * @param hotLocalCache          the L1 Caffeine cache
568
     * @param hotKeyRedisLoader      the function for loading values from Redis
569
     * @param properties             the Worker listener configuration properties
570
     * @param expireManager          the cache expiry manager
571
     * @param sreRateLimiterProvider optional provider for the SRE rate limiter
572
     * @return a new {@link WorkerListener} instance
573
     */
574
    @Bean
575
    @ConditionalOnMissingBean
576
    public WorkerListener workerListener(
577
      Cache<String, Object> hotLocalCache,
578
      CacheLoader hotKeyRedisLoader,
579
      WorkerListenerProperties properties,
580
      @Qualifier("hotKeyWorkerSchedScheduler") ScheduledExecutorService workerSchedScheduler,
581
      CacheExpireManager expireManager,
582
      ObjectProvider<SreRateLimiter> sreRateLimiterProvider
583
    ) {
584
      return new WorkerListener(
9✔
585
        hotLocalCache,
586
        hotKeyRedisLoader,
587
        properties,
588
        workerSchedScheduler,
589
        expireManager,
590
        sreRateLimiterProvider.getIfAvailable()
3✔
591
      );
592
    }
593

594
    /**
595
     * Create the AMQP message listener container that processes Worker heartbeat messages.
596
     *
597
     * @param connectionFactory   the RabbitMQ connection factory
598
     * @param healthView          the cluster health view to update on heartbeat reception
599
     * @param hotkeyHeartbeatQueue the heartbeat queue
600
     * @return a configured {@link SimpleMessageListenerContainer}
601
     */
602
    @Bean
603
    @ConditionalOnMissingBean(name = "hotkeyHeartbeatContainer")
604
    public SimpleMessageListenerContainer heartbeatContainer(
605
      ConnectionFactory connectionFactory,
606
      ClusterHealthView healthView,
607
      Queue hotkeyHeartbeatQueue
608
    ) {
609
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
610
      container.setQueueNames(hotkeyHeartbeatQueue.getName());
9✔
611
      container.setAcknowledgeMode(AcknowledgeMode.NONE);
3✔
612
      container.setConcurrentConsumers(1);
3✔
613
      container.setPrefetchCount(100);
3✔
614
      container.setErrorHandler(t -> log.warn("Heartbeat listener uncaught exception (message discarded)", t));
3✔
615
      container.setMessageListener(msg -> {
4✔
616
        WorkerHeartbeatMessage hb = WorkerHeartbeatMessage.from(msg);
×
617
        if (hb != null) {
×
618
          healthView.onHeartbeat(hb);
×
619
        }
620
      });
×
621
      return container;
2✔
622
    }
623

624
    /**
625
     * Create the AMQP message listener container that processes Worker HOT/COOL decisions
626
     * via the {@link WorkerListener}.
627
     *
628
     * <p>The container uses {@link AcknowledgeMode#MANUAL} because the
629
     * {@link WorkerListener#handleWorkerMessage} performs its own ack/nack
630
     * (ack-before-update pattern, see ADR-0004).
631
     *
632
     * @param connectionFactory the RabbitMQ connection factory
633
     * @param hotkeyWorkerQueue the per-instance Worker listener queue
634
     * @param workerListener    the Worker decision listener
635
     * @param properties        the Worker listener configuration properties
636
     * @return a configured {@link SimpleMessageListenerContainer}
637
     */
638
    @Bean
639
    @ConditionalOnMissingBean(name = "workerListenerContainer")
640
    public SimpleMessageListenerContainer workerListenerContainer(
641
      ConnectionFactory connectionFactory,
642
      Queue hotkeyWorkerQueue,
643
      WorkerListener workerListener,
644
      WorkerListenerProperties properties
645
    ) {
646
      SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(connectionFactory);
5✔
647
      container.setQueueNames(hotkeyWorkerQueue.getName());
9✔
648
      container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
3✔
649
      container.setMessageListener(
4✔
650
        (ChannelAwareMessageListener) (msg, channel) -> workerListener.handleWorkerMessage(channel, msg)
×
651
      );
652
      container.setConcurrentConsumers(properties.getConcurrentConsumers());
4✔
653
      container.setPrefetchCount(properties.getPrefetchCount());
4✔
654
      container.setErrorHandler(t ->
3✔
655
        log.warn("Worker listener uncaught exception (message will be requeued by container)", t)
×
656
      );
657
      container.setAutoStartup(properties.isAutoStartup());
4✔
658
      return container;
2✔
659
    }
660

661
    /**
662
     * Create the {@link WorkerHeartbeatVerifier} that periodically PINGs Workers
663
     * to verify they are alive.
664
     *
665
     * @param rabbitTemplate the RabbitMQ template for sending PING messages
666
     * @param healthView     the cluster health view to update on verification results
667
     * @param properties     the HotKey configuration properties
668
     * @return a new {@link WorkerHeartbeatVerifier} instance
669
     */
670
    @Bean(initMethod = "start", destroyMethod = "stop")
671
    @ConditionalOnMissingBean
672
    public WorkerHeartbeatVerifier workerHeartbeatVerifier(
673
      RabbitTemplate rabbitTemplate,
674
      ClusterHealthView healthView,
675
      HotKeyProperties properties,
676
      @Qualifier("hotKeyScheduler") ScheduledExecutorService hotKeyScheduler
677
    ) {
678
      return new WorkerHeartbeatVerifier(
6✔
679
        rabbitTemplate,
680
        healthView,
681
        properties.getInstanceId(),
4✔
682
        new WorkerHeartbeatVerifier.VerifierConfig(
683
          properties.getHeartbeat().getVerifyIntervalMs(),
3✔
684
          properties.getHeartbeat().getPingTimeoutMs(),
3✔
685
          properties.getHeartbeat().getVerifyMaxBackoffMs()
5✔
686
        ),
687
        hotKeyScheduler
688
      );
689
    }
690
  }
691
}
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