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

Hyshmily / hotkey / 28425041198

30 Jun 2026 06:30AM UTC coverage: 90.88% (-0.3%) from 91.186%
28425041198

push

github

Hyshmily
refactor: simplify Worker cluster management and failure detection

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

1250 of 1437 branches covered (86.99%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

3543 of 3837 relevant lines covered (92.34%)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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