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

Hyshmily / hotkey / 27925388821

22 Jun 2026 02:14AM UTC coverage: 92.403% (-1.2%) from 93.615%
27925388821

push

github

Hyshmily
fix:fix known bugs and promote performance

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

1143 of 1289 branches covered (88.67%)

Branch coverage included in aggregate %.

184 of 236 new or added lines in 19 files covered. (77.97%)

6 existing lines in 3 files now uncovered.

3321 of 3542 relevant lines covered (93.76%)

4.22 hits per line

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

76.65
/common/src/main/java/io/github/hyshmily/hotkey/reporting/HotKeyReporter.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.reporting;
17

18
import com.github.benmanes.caffeine.cache.Cache;
19
import com.github.benmanes.caffeine.cache.Caffeine;
20
import io.github.hyshmily.hotkey.sharding.RingManager;
21
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
22
import java.util.ArrayList;
23
import java.util.HashMap;
24
import java.util.List;
25
import java.util.Map;
26
import java.util.concurrent.BlockingQueue;
27
import java.util.concurrent.LinkedBlockingQueue;
28
import java.util.concurrent.ScheduledExecutorService;
29
import java.util.concurrent.TimeUnit;
30
import java.util.concurrent.atomic.AtomicBoolean;
31
import java.util.concurrent.atomic.AtomicLong;
32
import java.util.concurrent.atomic.LongAdder;
33
import lombok.Setter;
34
import lombok.extern.slf4j.Slf4j;
35

36

37
/**
38
 * Periodically aggregates per-key access counts and publishes them
39
 * to the Worker via {@link ReportPublisher}.
40
 *
41
 * <p>Uses a Caffeine cache as a temporary counter store; entries are
42
 * evicted after 30 seconds of inactivity to bound memory usage.
43
 * Flushed to the appropriate shard at a fixed interval.
44
 *
45
 * <p>Keys are routed via the {@link RingManager} consistent-hash ring, ensuring the
46
 * same key always maps to the same Worker node even as the cluster scales.
47
 * The RingManager also tracks Worker liveness via heartbeat, so dead shards are
48
 * automatically excluded from routing.
49
 *
50
 * <p>Burst absorption and backpressure are provided by a bounded
51
 * {@link LinkedBlockingQueue} between the flush loop and the
52
 * RabbitMQ publisher.  When the queue is full, {@code flush()} drops
53
 * batches after a configurable timeout, and the Caffeine eviction
54
 * provides natural rate-limiting.
55
 *
56
 * <p>When a {@link BbrRateLimiter} is configured, each flush cycle is submitted
57
 * to the BBR for admission control.  If the pipeline is saturated (high CPU
58
 * and/or excessive in-flight batches), the flush is skipped and counters
59
 * remain in the local Caffeine cache for the next cycle.  This provides
60
 * adaptive back-pressure proportional to system load.
61
 */
62
@Slf4j
4✔
63
public class HotKeyReporter {
64

65
  /** Caffeine cache acting as a temporary counter store; entries evict after 30 s of inactivity. */
66
  private final Cache<String, LongAdder> counters = Caffeine.newBuilder()
4✔
67
    .expireAfterAccess(30, TimeUnit.SECONDS)
2✔
68
    .maximumSize(100_000)
1✔
69
    .build();
2✔
70
  /** Publishes aggregated reports to RabbitMQ. */
71
  private final ReportPublisher reportPublisher;
72
  /** Scheduler for the periodic flush loop. */
73
  private final ScheduledExecutorService scheduler;
74
  /** Fixed delay between report flushes in milliseconds. */
75
  private final long reportIntervalMs;
76
  /** Name of this application instance, included in report messages. */
77
  private final String appName;
78
  /** Maximum capacity of the dispatcher work queue. */
79
  private final int queueCapacity;
80
  /** Timeout (ms) for offering a batch to the dispatcher queue before dropping. */
81
  private final int queueOfferTimeoutMs;
82
  /** Number of consumer threads draining the dispatcher queue. */
83
  private final int consumerCount;
84
  /** Consistent-hashing ring manager for Worker node routing. */
85
  private final RingManager ringManager;
86
  /** Cluster health view for filtering dead Workers. */
87
  private final ClusterHealthView healthView;
88

89
  /** Optional BBR adaptive rate limiter; null disables BBR gating. */
90
  @Setter
91
  private volatile BbrRateLimiter bbrRateLimiter;
92

93
  /** Guards start() idempotency. */
94
  private final AtomicBoolean started = new AtomicBoolean(false);
6✔
95
  /** The report dispatcher instance; created on start(). */
96
  private ReportDispatcher dispatcher;
97

98
  /**
99
   * Creates a new reporter that periodically flushes access counts to RabbitMQ.
100
   *
101
   * @param reportPublisher     the publisher used to send report messages to RabbitMQ
102
   * @param scheduler           the scheduler for periodic flush cycles
103
   * @param reportIntervalMs    fixed delay between consecutive flushes in milliseconds
104
   * @param appName             name of this application instance, included in report messages
105
   * @param queueCapacity       maximum capacity of the dispatcher work queue
106
   * @param queueOfferTimeoutMs timeout for offering a batch to the dispatcher queue before dropping
107
   * @param consumerCount       number of consumer threads draining the dispatcher queue
108
   * @param ringManager         consistent-hashing ring manager for Worker node routing
109
   * @param healthView          cluster health view for filtering dead Workers
110
   */
111
  public HotKeyReporter(
112
    ReportPublisher reportPublisher,
113
    ScheduledExecutorService scheduler,
114
    long reportIntervalMs,
115
    String appName,
116
    int queueCapacity,
117
    int queueOfferTimeoutMs,
118
    int consumerCount,
119
    RingManager ringManager,
120
    ClusterHealthView healthView
121
  ) {
2✔
122
    this.reportPublisher = reportPublisher;
3✔
123
    this.scheduler = scheduler;
3✔
124
    this.reportIntervalMs = reportIntervalMs;
3✔
125
    this.appName = appName;
3✔
126
    this.queueCapacity = queueCapacity;
3✔
127
    this.queueOfferTimeoutMs = queueOfferTimeoutMs;
3✔
128
    this.consumerCount = consumerCount;
3✔
129
    this.ringManager = ringManager;
3✔
130
    this.healthView = healthView;
3✔
131
  }
1✔
132

133
  /**
134
   * A batch of key-count mappings destined for a single Worker target.
135
   *
136
   * @param target    the Worker nodeId for this batch
137
   * @param timestamp wall-clock ms when the batch was assembled
138
   * @param counts    non-zero per-key counts accumulated since the last flush
139
   */
140
  record ShardBatch(String target, long timestamp, Map<String, Long> counts) {}
12✔
141

142
  /**
143
   * Record one access for the given cache key.
144
   *
145
   * <p>Idempotent per-key local counter increment.  The counter is stored in
146
   * a Caffeine cache that evicts after 30 s of inactivity, so low-frequency
147
   * keys are naturally forgotten without explicit cleanup.
148
   *
149
   * @param cacheKey the accessed key
150
   */
151
  public void record(String cacheKey) {
152
    counters.get(cacheKey, k -> new LongAdder()).increment();
11✔
153
  }
1✔
154

155
  /**
156
   * Start the periodic flush scheduler and the report dispatcher.
157
   * Idempotent — subsequent calls are silently ignored.
158
   *
159
   * <p>The flush loop drains the Caffeine counter map, groups entries by
160
   * target (shard index or nodeId), and enqueues them.  Actual publishing
161
   * to RabbitMQ runs on dedicated consumer threads, decoupling the flush
162
   * loop from network I/O.
163
   */
164
  public void start() {
165
    if (!started.compareAndSet(false, true)) {
6✔
166
      log.debug("HotKeyReporter already started, skip");
3✔
167
      return;
1✔
168
    }
169
    try {
170
      dispatcher = new ReportDispatcher();
6✔
171
      dispatcher.start();
3✔
172
      scheduler.scheduleAtFixedRate(this::flush, reportIntervalMs, reportIntervalMs, TimeUnit.MILLISECONDS);
11✔
173
      log.info(
14✔
174
        "HotKeyReporter started: appName={}, intervalMs={}, queueCapacity={}, consumers={}",
175
        appName,
176
        reportIntervalMs,
6✔
177
        queueCapacity,
6✔
178
        dispatcher.consumerCount()
3✔
179
      );
NEW
180
    } catch (Exception e) {
×
NEW
181
      log.error("Failed to start HotKeyReporter; per-key counts will not be flushed to Worker. " +
×
182
          "Application continues but Worker hot-key detection will be blind to this instance.", e);
183
    }
1✔
184
  }
1✔
185

186
  /**
187
   * Gracefully shut down the report dispatcher.
188
   *
189
   * <p>Interrupts all consumer threads and waits for them to finish
190
   * (with a 2-second timeout per thread). Any batches remaining in the
191
   * work queue are discarded. This method is typically registered as
192
   * the Spring bean {@code destroyMethod}.
193
   *
194
   * <p>After shutdown, the reporter no longer publishes reports.
195
   * The periodic flush loop continues to run but its output is silently
196
   * dropped because the dispatcher queue is no longer being consumed.
197
   *
198
   * <p>Idempotent — safe to call multiple times.
199
   */
200
  public void stop() {
201
    if (dispatcher != null) {
3✔
202
      dispatcher.shutdown();
3✔
203
    }
204
  }
1✔
205

206
  /**
207
   * Drain all locally accumulated counters, group by target, and enqueue
208
   * one {@link ShardBatch} per target for the consumer threads to publish.
209
   *
210
   * <p>In consistent-hashing mode, the ring is reconciled with the current
211
   * set of alive Worker nodes before grouping.
212
   *
213
   * <p>If the dispatcher queue is full, the batch for that target is
214
   * dropped (logged at WARN).  Caffeine eviction provides additional
215
   * backpressure — counters for cold keys are silently discarded.
216
   *
217
   * <p>When BBR rate limiting is active, {@code flush()} first checks
218
   * {@link BbrRateLimiter#tryAcquire()}.  If the limiter rejects the cycle,
219
   * the counters remain in the local cache and are merged with the next
220
   * flush.  This provides adaptive back-pressure proportional to system load.
221
   *
222
   * <p>Called periodically by the scheduler at {@code reportIntervalMs}.
223
   */
224
  private void flush() {
225
    try {
226
      if (counters.estimatedSize() == 0) {
6✔
227
        log.trace("Reporter flush tick: counters empty, no-op");
3✔
228
        return;
1✔
229
      }
230

231
      if (bbrRateLimiter != null && !bbrRateLimiter.tryAcquire()) {
3!
232
        bbrRateLimiter.onGateDrop();
×
233
        return;
×
234
      }
235

236
      // Reconcile ring with alive Worker nodes (from heartbeat state), then route via consistent hash
237
      ringManager.reconcileFromHealthView(healthView);
5✔
238

239
      Map<String, Map<String, Long>> sharded = new HashMap<>();
4✔
240
      long now = System.currentTimeMillis();
2✔
241

242
      counters
2✔
243
        .asMap()
4✔
244
        .forEach((key, adder) -> {
1✔
245
          long val = adder.sumThenReset();
3✔
246

247
          if (val > 0) {
4✔
248
            String target = ringManager.routeNode(key, healthView);
7✔
249

250
            if (target != null) {
2✔
251
              sharded.computeIfAbsent(target, t -> new HashMap<>()).put(key, val);
14✔
252
            }
253
          }
254
        });
1✔
255

256
      sharded.forEach((target, counts) -> {
5✔
257
        if (!dispatcher.enqueue(new ShardBatch(target, now, counts))) {
10!
258
          long dropped = dispatcher.dropped();
×
259

260
          if (dropped % 100 == 0 || dropped == 1) {
×
261
            log.warn(
×
262
              "report queue full, dropped target={} keys={}, depth={}/{}, cumulativeDrops={}",
263
              target,
264
              counts.size(),
×
265
              dispatcher.depth(),
×
266
              queueCapacity,
×
267
              dropped
×
268
            );
269
          }
270
        } else if (bbrRateLimiter != null) {
3!
271
          bbrRateLimiter.onEnqueue();
×
272
        }
273
      });
1✔
274
    } catch (Exception e) {
×
275
      log.error("Scheduled reporter flush failed", e);
×
276
    }
1✔
277
  }
1✔
278

279
  /**
280
   * Return the current number of batches waiting in the dispatcher work queue.
281
   *
282
   * @return queue depth (number of enqueued but not yet consumed batches),
283
   *         or {@code -1} if the dispatcher has not been started
284
   */
285
  public int dispatcherDepth() {
286
    return dispatcher == null ? -1 : dispatcher.depth();
9✔
287
  }
288

289
  /**
290
   * Return the maximum capacity of the dispatcher work queue.
291
   *
292
   * @return the queue capacity as configured via {@code queueCapacity},
293
   *         or {@code -1} if the dispatcher has not been started
294
   */
295
  public int dispatcherCapacity() {
296
    return dispatcher == null ? -1 : dispatcher.capacity();
9✔
297
  }
298

299
  /**
300
   * Return the total number of batches that were discarded because they
301
   * waited longer than 5 seconds in the dispatcher queue (staleness expiry).
302
   *
303
   * @return total expired batch count since startup, or {@code -1} if the
304
   *         dispatcher has not been started
305
   */
306
  public long dispatcherExpired() {
307
    return dispatcher == null ? -1 : dispatcher.expired();
7!
308
  }
309

310
  /**
311
   * Return the total number of batches rejected because the dispatcher
312
   * queue was full (back-pressure drops from the flush loop).
313
   *
314
   * @return total dropped batch count since startup, or {@code -1} if the
315
   *         dispatcher has not been started
316
   */
317
  public long dispatcherDropped() {
318
    return dispatcher == null ? -1 : dispatcher.dropped();
7!
319
  }
320

321
  /**
322
   * Return the approximate number of unique keys currently buffered in the
323
   * local Caffeine counter store.
324
   *
325
   * <p>This is an estimate provided by {@link Cache#estimatedSize()} and
326
   * may not reflect the exact count due to the concurrent nature of the
327
   * underlying data structure.
328
   *
329
   * @return estimated number of unique cache keys with pending access counts
330
   */
331
  public long getPendingKeyCount() {
332
    return counters.estimatedSize();
4✔
333
  }
334

335
  /**
336
   * Return the total number of flush cycles that were permitted by the BBR
337
   * rate limiter since startup.
338
   *
339
   * @return total passed count, or {@code -1} if BBR rate limiting is disabled
340
   *         ({@link #bbrRateLimiter} is {@code null})
341
   */
342
  public long bbrPassed() {
343
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getTotalPassed();
9✔
344
  }
345

346
  /**
347
   * Return the total number of flush cycles that were dropped by the BBR
348
   * rate limiter since startup (both gate drops and consumer drops).
349
   *
350
   * @return total dropped count, or {@code -1} if BBR rate limiting is disabled
351
   */
352
  public long bbrDropped() {
353
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getTotalDropped();
9✔
354
  }
355

356
  /**
357
   * Return the current number of in-flight batches tracked by the BBR rate
358
   * limiter — those enqueued for publishing but not yet completed or dropped.
359
   *
360
   * @return current in-flight count (non-negative), or {@code -1} if BBR
361
   *         rate limiting is disabled
362
   */
363
  public long bbrInFlight() {
364
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getInFlight();
9✔
365
  }
366

367
  /**
368
   * Return the current BBR-computed maximum concurrency budget (max in-flight).
369
   *
370
   * <p>This value is derived from the sliding-window maxPASS and minRT
371
   * metrics via Little's Law. It represents the limiter's estimate of the
372
   * optimal number of concurrent in-flight batches before the pipeline
373
   * becomes congested.
374
   *
375
   * @return the computed max in-flight budget, or {@code -1} if BBR rate
376
   *         limiting is disabled
377
   */
378
  public long bbrMaxInFlight() {
379
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getCurrentMaxInFlight();
9✔
380
  }
381

382
  /**
383
   * Manages a bounded work queue and a fixed pool of consumer threads that
384
   * drain batches and publish them via {@link ReportPublisher}.
385
   *
386
   * <p>Burst absorption: the bound on {@code LinkedBlockingQueue} prevents
387
   * the flush loop from overwhelming RabbitMQ.  Backpressure propagates to
388
   * Caffeine eviction when the queue is persistently full.
389
   */
390
  class ReportDispatcher {
5✔
391

392
    /** Bounded work queue between the flush loop and the consumer threads. */
393
    private final BlockingQueue<ShardBatch> queue = new LinkedBlockingQueue<>(queueCapacity);
8✔
394
    /** Count of batches discarded due to staleness (>5 s wait in queue). */
395
    private final AtomicLong expiredCount = new AtomicLong();
5✔
396
    /** Count of batches rejected because the queue was full. */
397
    private final AtomicLong droppedCount = new AtomicLong();
5✔
398
    /** Active consumer threads draining the work queue. */
399
    private final List<Thread> consumers = new ArrayList<>();
6✔
400
    /** Lifecycle flag; false after shutdown. */
401
    private volatile boolean running;
402

403
    /**
404
     * Start the consumer threads that drain batches from the bounded work
405
     * queue and publish them via {@link ReportPublisher}.
406
     *
407
     * <p>Each consumer runs in a named daemon thread
408
     * ({@code "report-consumer-N"}) so they do not prevent JVM shutdown.
409
     * The number of consumer threads is determined by the
410
     * {@code consumerCount} configuration.
411
     */
412
    void start() {
413
      running = true;
3✔
414
      for (int i = 0; i < consumerCount; i++) {
9✔
415
        Thread t = new Thread(this::consumeLoop, "report-consumer-" + i);
8✔
416
        t.setDaemon(true);
3✔
417
        t.start();
2✔
418
        consumers.add(t);
5✔
419
      }
420
    }
1✔
421

422
    /**
423
     * Offer a batch to the bounded work queue with a timeout.
424
     *
425
     * <p>Blocks for up to {@code queueOfferTimeoutMs} milliseconds waiting
426
     * for space to become available. If the queue remains full after the
427
     * timeout, the batch is rejected and the drop counter is incremented.
428
     *
429
     * <p>If the calling thread is interrupted while waiting, the batch is
430
     * discarded and {@code false} is returned.
431
     *
432
     * @param batch the sharded batch to enqueue for publishing
433
     * @return {@code true} if the batch was accepted into the queue;
434
     *         {@code false} if the queue was full or the thread was
435
     *         interrupted
436
     */
437
    boolean enqueue(ShardBatch batch) {
438
      try {
439
        boolean accepted = queue.offer(batch, queueOfferTimeoutMs, TimeUnit.MILLISECONDS);
10✔
440
        if (!accepted) {
2!
441
          droppedCount.incrementAndGet();
×
442
        }
443
        return accepted;
2✔
444
      } catch (InterruptedException e) {
×
445
        Thread.currentThread().interrupt();
×
446
        return false;
×
447
      }
448
    }
449

450
    /**
451
     * Gracefully shut down all consumer threads.
452
     *
453
     * <p>Signals all consumers to stop via the {@code running} flag and
454
     * thread interruption, then waits up to 2 seconds for each thread to
455
     * finish. After shutdown, the queue may still contain unconsumed
456
     * batches — they are discarded.
457
     *
458
     * <p>This method is called from {@link HotKeyReporter#stop()} and is
459
     * idempotent. However, after shutdown the dispatcher cannot be
460
     * restarted.
461
     */
462
    void shutdown() {
463
      running = false;
3✔
464
      for (Thread t : consumers) {
11✔
465
        t.interrupt();
2✔
466
      }
1✔
467
      for (Thread t : consumers) {
11✔
468
        try {
469
          t.join(2000);
3✔
470
        } catch (InterruptedException e) {
×
471
          Thread.currentThread().interrupt();
×
472
        }
1✔
473
      }
1✔
474
      consumers.clear();
3✔
475
      log.info(
9✔
476
        "ReportDispatcher stopped, remaining queue={}, expired={}, dropped={}",
477
        queue.size(),
7✔
478
        expiredCount.get(),
7✔
479
        droppedCount.get()
3✔
480
      );
481
    }
1✔
482

483
    /**
484
     * Return the current number of batches waiting in the work queue.
485
     *
486
     * @return current queue depth (non-negative)
487
     */
488
    int depth() {
489
      return queue.size();
4✔
490
    }
491

492
    /**
493
     * Return the maximum number of batches the work queue can hold.
494
     *
495
     * @return the configured queue capacity
496
     */
497
    int capacity() {
498
      return queueCapacity;
4✔
499
    }
500

501
    /**
502
     * Return the number of actively running consumer threads.
503
     *
504
     * @return current consumer thread count
505
     */
506
    int consumerCount() {
507
      return consumers.size();
4✔
508
    }
509

510
    /**
511
     * Return the total number of batches that were discarded due to
512
     * staleness (waited longer than 5 seconds in the queue) since startup.
513
     *
514
     * @return total expired batch count
515
     */
516
    long expired() {
517
      return expiredCount.get();
4✔
518
    }
519

520
    /**
521
     * Return the total number of batches that were rejected because the
522
     * queue was full since startup.
523
     *
524
     * @return total dropped batch count
525
     */
526
    long dropped() {
527
      return droppedCount.get();
4✔
528
    }
529

530
    /**
531
     * Consumer thread body: poll batches from the queue and publish them.
532
     *
533
     * <p>Staleness guard: batches that waited longer than 5 s in the queue
534
     * are discarded rather than published, preventing the Worker from receiving
535
     * stale access patterns during prolonged backpressure.
536
     *
537
     * <p>When BBR rate limiting is active:
538
     * <ul>
539
     *   <li>Successful publish records round-trip time via {@link BbrRateLimiter#onSuccess(long)}</li>
540
     *   <li>Stale batches (5s+ wait) or publish failures trigger ,
541
     *       allowing BBR to back off the send rate and prevent cascading overload</li>
542
     *   <li>InterruptedException is handled separately (break, not logged as error)
543
     *       to avoid noise during orderly shutdown</li>
544
     * </ul>
545
     */
546
    private void consumeLoop() {
547
      while (running) {
3✔
548
        ShardBatch batch;
549
        try {
550
          batch = queue.take();
5✔
551
        } catch (InterruptedException e) {
1✔
552
          log.warn("ReportDispatcher consumer interrupted, shutting down");
3✔
553
          Thread.currentThread().interrupt();
2✔
554
          break;
1✔
555
        }
1✔
556

557
        // 5s stale check — discard data that waited too long in the queue
558
        if (System.currentTimeMillis() - batch.timestamp() > 5_000) {
7!
559
          expiredCount.incrementAndGet();
×
560
          if (bbrRateLimiter != null) {
×
561
            bbrRateLimiter.onConsumerDrop();
×
562
          }
563
          continue;
564
        }
565

566
        try {
567
          reportPublisher.publish(batch.target(), new ReportMessage(appName, batch.timestamp(), batch.counts()));
16✔
568
          if (bbrRateLimiter != null) {
4!
569
            bbrRateLimiter.onSuccess(System.currentTimeMillis() - batch.timestamp());
×
570
          }
571
        } catch (Exception e) {
×
572
          log.error("Failed to publish report batch for target={}, keys={}", batch.target(), batch.counts().size(), e);
×
573
          if (bbrRateLimiter != null) {
×
574
            bbrRateLimiter.onConsumerDrop();
×
575
          }
576
        }
1✔
577
      }
1✔
578
    }
1✔
579
  }
580
}
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