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

Hyshmily / hotkey / 28356107387

29 Jun 2026 07:35AM UTC coverage: 91.168% (-0.01%) from 91.178%
28356107387

push

github

Hyshmily
test : fix CI tests

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

1276 of 1461 branches covered (87.34%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 4 files covered. (100.0%)

117 existing lines in 15 files now uncovered.

3596 of 3883 relevant lines covered (92.61%)

4.24 hits per line

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

84.4
/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 io.github.hyshmily.hotkey.constants.HotKeyConstants;
21

22
import com.github.benmanes.caffeine.cache.Cache;
23
import io.github.hyshmily.hotkey.cache.CacheExpireManager;
24
import io.github.hyshmily.hotkey.reporting.BbrRateLimiter;
25
import io.github.hyshmily.hotkey.reporting.HotKeyReporter;
26
import io.github.hyshmily.hotkey.reporting.ReportPublisher;
27
import io.github.hyshmily.hotkey.rule.RuleMatcher;
28
import io.github.hyshmily.hotkey.sharding.RingManager;
29
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
30
import io.github.hyshmily.hotkey.sync.local.CacheSyncListener;
31
import io.github.hyshmily.hotkey.sync.local.CacheSyncProperties;
32
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
33
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatMessage;
34
import io.github.hyshmily.hotkey.sync.worker.WorkerHeartbeatVerifier;
35
import io.github.hyshmily.hotkey.sync.worker.WorkerListener;
36
import io.github.hyshmily.hotkey.sync.worker.WorkerListenerProperties;
37
import io.github.hyshmily.hotkey.util.HotKeyThreadFactory;
38
import io.github.hyshmily.hotkey.util.InstanceIdGenerator;
39
import io.github.hyshmily.hotkey.util.SystemLoadMonitor;
40
import io.github.hyshmily.hotkey.util.ratelimit.SreRateLimiter;
41
import java.util.concurrent.Executors;
42
import java.util.concurrent.ScheduledExecutorService;
43
import java.util.function.Function;
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
@AutoConfiguration(after = { RedisAutoConfiguration.class, RabbitAutoConfiguration.class })
86
@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate")
87
@EnableConfigurationProperties({ HotKeyProperties.class, CacheSyncProperties.class, WorkerListenerProperties.class })
88
public class HotKeyAmqpAutoConfiguration {
3✔
89

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

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

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

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

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

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

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

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

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

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

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

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

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

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

331
    /**
332
     * Default Redis loader used by the sync listener to refresh cache entries via {@code GET}.
333
     *
334
     * @param stringRedisTemplate the String-based Redis template for reading values
335
     * @return a {@link Function} that reads a key from Redis and returns its value
336
     */
337
    @Bean
338
    @ConditionalOnMissingBean(name = "hotKeyRedisLoader")
339
    public Function<String, Object> hotKeyRedisLoader(StringRedisTemplate stringRedisTemplate) {
340
      return key -> stringRedisTemplate.opsForValue().get(key);
3✔
341
    }
342

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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