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

Hyshmily / hotkey / 28762732885

06 Jul 2026 01:49AM UTC coverage: 90.336% (-0.1%) from 90.457%
28762732885

push

github

Hyshmily
fix: fix known bugs

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

1314 of 1518 branches covered (86.56%)

Branch coverage included in aggregate %.

72 of 80 new or added lines in 9 files covered. (90.0%)

2 existing lines in 2 files now uncovered.

3715 of 4049 relevant lines covered (91.75%)

4.18 hits per line

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

92.08
/common/src/main/java/io/github/hyshmily/hotkey/hotkeydetector/doublebuffer/BufferedCounter.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.hotkeydetector.doublebuffer;
17

18
import io.github.hyshmily.hotkey.Internal;
19
import io.github.hyshmily.hotkey.util.HotKeyThreadFactory;
20
import java.util.HashMap;
21
import java.util.Map;
22
import java.util.concurrent.ConcurrentHashMap;
23
import java.util.concurrent.Executors;
24
import java.util.concurrent.ScheduledExecutorService;
25
import java.util.concurrent.TimeUnit;
26
import java.util.concurrent.atomic.AtomicReference;
27
import java.util.concurrent.atomic.LongAdder;
28
import java.util.function.Consumer;
29
import javax.security.auth.Destroyable;
30
import lombok.extern.slf4j.Slf4j;
31
import org.springframework.beans.factory.InitializingBean;
32

33
/**
34
 * Double-buffered counter that aggregates high-frequency single-key increments
35
 * and flushes them in batch to a downstream consumer.
36
 *
37
 * <p><b>Design:</b> Two {@link CounterBuffer} instances (active and standby)
38
 * are maintained as {@code AtomicReference<CounterBuffer>}. The active buffer
39
 * accepts incoming {@link #count(String, long)} calls via a lock-free
40
 * {@code ConcurrentHashMap}. A scheduled flusher periodically swaps and drains
41
 * both buffers into the downstream consumer (see {@link #flushStandby()}).
42
 *
43
 * <p><b>Eager swap:</b> When the active buffer exceeds 80 % of
44
 * {@link #DEFAULT_MAX_BUFFER_SIZE}, the buffers are swapped eagerly to prevent any
45
 * single buffer from growing unbounded under a traffic spike. The hot path
46
 * (the {@code count} call) remains lock-free — it only does an atomic
47
 * {@code getAndSet} on the active reference.
48
 *
49
 * <p><b>Lifecycle:</b> Implements {@link InitializingBean} to start the
50
 * periodic flush scheduler, and {@link Destroyable} to perform a final drain
51
 * on shutdown. The scheduler is either self-created (and owned) or externally
52
 * provided (shared), controlling whether {@link #destroy()} shuts it down.
53
 *
54
 * <p>Thread-safe. All public methods can be called concurrently from
55
 * multiple threads.
56
 */
57
@Slf4j
4✔
58
@Internal
59
public class BufferedCounter implements InitializingBean, Destroyable {
60

61
  /** Default maximum distinct keys in one buffer before forced swap ({@value}). */
62
  static final int DEFAULT_MAX_BUFFER_SIZE = 10_000;
63

64
  /** Default flush interval in milliseconds ({@value}). */
65
  static final long DEFAULT_FLUSH_INTERVAL_MS = 500;
66

67
  /** Default eager swap ratio ({@value}). */
68
  static final double DEFAULT_EAGER_SWAP_RATIO = 0.8;
69

70
  private final int maxBufferSize;
71

72
  private final long flushIntervalMs;
73

74
  private final double eagerSwapRatio;
75

76
  private final AtomicReference<CounterBuffer> active;
77

78
  private final AtomicReference<CounterBuffer> standbyRef;
79

80
  private final Consumer<Map<String, Long>> batchConsumer;
81

82
  private final ScheduledExecutorService scheduler;
83

84
  private final boolean ownsScheduler;
85

86
  /**
87
   * Creates a buffered counter that flushes aggregated counts to the given consumer.
88
   * Creates its own single-thread scheduler with default parameters ({@value DEFAULT_MAX_BUFFER_SIZE}
89
   * max keys, {@value DEFAULT_FLUSH_INTERVAL_MS} ms interval, {@value DEFAULT_EAGER_SWAP_RATIO} swap ratio).
90
   *
91
   * @param batchConsumer callback receiving the aggregated key-count map on each flush
92
   */
93
  public BufferedCounter(Consumer<Map<String, Long>> batchConsumer) {
94
    this(batchConsumer, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_EAGER_SWAP_RATIO, true, null);
8✔
95
  }
1✔
96

97
  /**
98
   * Creates a buffered counter with an externally provided shared scheduler and default parameters.
99
   *
100
   * @param batchConsumer callback receiving the aggregated key-count map on each flush
101
   * @param scheduler     the shared scheduler (not shut down on destroy)
102
   */
103
  public BufferedCounter(Consumer<Map<String, Long>> batchConsumer, ScheduledExecutorService scheduler) {
104
    this(batchConsumer, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_EAGER_SWAP_RATIO, false, scheduler);
8✔
105
  }
1✔
106

107
  /**
108
   * Creates a buffered counter with custom parameters and an externally provided shared scheduler.
109
   *
110
   * @param batchConsumer   callback receiving the aggregated key-count map on each flush
111
   * @param maxBufferSize   maximum distinct keys in one buffer before forced eager swap
112
   * @param flushIntervalMs fixed delay between consecutive flushes in milliseconds
113
   * @param eagerSwapRatio  fraction of {@code maxBufferSize} that triggers an eager buffer swap (0.0 – 1.0)
114
   * @param scheduler       the shared scheduler (not shut down on destroy)
115
   */
116
  public BufferedCounter(
117
    Consumer<Map<String, Long>> batchConsumer,
118
    int maxBufferSize,
119
    long flushIntervalMs,
120
    double eagerSwapRatio,
121
    ScheduledExecutorService scheduler
122
  ) {
123
    this(batchConsumer, maxBufferSize, flushIntervalMs, eagerSwapRatio, false, scheduler);
8✔
124
  }
1✔
125

126
  private BufferedCounter(
127
    Consumer<Map<String, Long>> batchConsumer,
128
    int maxBufferSize,
129
    long flushIntervalMs,
130
    double eagerSwapRatio,
131
    boolean ownsScheduler,
132
    ScheduledExecutorService scheduler
133
  ) {
2✔
134
    this.batchConsumer = batchConsumer;
3✔
135
    this.maxBufferSize = maxBufferSize;
3✔
136
    this.flushIntervalMs = flushIntervalMs;
3✔
137
    this.eagerSwapRatio = eagerSwapRatio;
3✔
138
    this.active = new AtomicReference<>(new CounterBuffer());
8✔
139
    this.standbyRef = new AtomicReference<>(new CounterBuffer());
8✔
140
    this.ownsScheduler = ownsScheduler;
3✔
141
    this.scheduler = ownsScheduler
3✔
142
      ? Executors.newSingleThreadScheduledExecutor(new HotKeyThreadFactory("hotkey-buffered-counter-flusher"))
6✔
143
      : scheduler;
2✔
144
  }
1✔
145

146
  /**
147
   * Record one or more accesses for the given key into the active buffer.
148
   *
149
   * <p>This is the hot path — it performs a lock-free update on the active
150
   * {@link CounterBuffer}. If the active buffer exceeds 80 % capacity
151
   * after this increment, an eager swap is triggered to keep the buffer
152
   * from overflowing before the next scheduled flush.
153
   *
154
   * @param key   the accessed key (must not be {@code null})
155
   * @param delta the number of accesses to record (must be positive)
156
   */
157
  public void count(String key, long delta) {
158
    CounterBuffer buffer = active.get();
5✔
159
    buffer.add(key, delta);
4✔
160

161
    if (buffer.size() >= maxBufferSize * eagerSwapRatio) {
11✔
162
      trySwitch();
2✔
163
    }
164
  }
1✔
165

166
  /**
167
   * Return an approximate count of distinct keys request across both buffers.
168
   *
169
   * @return sum of distinct keys request in the active and standby buffers
170
   */
171
  public long estimatedSizeOfKeysCount() {
172
    return (long) active.get().size() + (long) standbyRef.get().size();
14✔
173
  }
174

175
  /**
176
   * Drain all remaining counts from both buffers without calling the consumer.
177
   * After this call both buffers are empty and ready for reuse.
178
   */
179
  public void clear() {
180
    active.getAndSet(new CounterBuffer()).drain();
9✔
181
    standbyRef.getAndSet(new CounterBuffer()).drain();
9✔
182
  }
1✔
183

184
  /**
185
   * try to switch the active buffer with a new one and move the old one to standby for flushing.
186
   */
187
  private void trySwitch() {
188
    CounterBuffer newBuffer = new CounterBuffer();
4✔
189
    CounterBuffer oldBuffer = active.getAndSet(newBuffer);
6✔
190
    if (oldBuffer != null) {
2!
191
      standbyRef.set(oldBuffer);
4✔
192
    }
193
  }
1✔
194

195
  private void flushStandby() {
196
    try {
197
      // Swap active and standby independently to avoid races between
198
      // count()->trySwitch() and the scheduled flush.
199
      CounterBuffer flushedActive = active.getAndSet(new CounterBuffer());
8✔
200
      CounterBuffer flushedStandby = standbyRef.getAndSet(new CounterBuffer());
8✔
201

202
      drainBuffer(flushedActive);
3✔
203
      drainBuffer(flushedStandby);
3✔
204
    } catch (Exception e) {
1✔
205
      log.error("Scheduled flushStandby failed", e);
4✔
206
    }
1✔
207
  }
1✔
208

209
  private void drainBuffer(CounterBuffer buf) {
210
    if (!buf.isEmpty()) {
3✔
211
      Map<String, Long> snapshot = buf.drain();
3✔
212
      if (!snapshot.isEmpty()) {
3✔
213
        batchConsumer.accept(snapshot);
4✔
214
      }
215
    }
216
  }
1✔
217

218
  /**
219
   * Start the periodic flush scheduler.  Called by the Spring container
220
   * after all bean properties have been set.
221
   */
222
  @Override
223
  public void afterPropertiesSet() {
224
    try {
225
      scheduler.scheduleAtFixedRate(this::flushStandby, flushIntervalMs, flushIntervalMs, TimeUnit.MILLISECONDS);
11✔
226
    } catch (Exception e) {
1✔
227
      log.error(
4✔
228
        "Failed to start BufferedCounter flush scheduler; buffered counts will not " +
229
          "be flushed to HeavyKeeper. Hot-key detection may be impaired.",
230
        e
231
      );
232
    }
1✔
233
  }
1✔
234

235
  /**
236
   * Perform a final drain of any remaining buffered counts and shut down
237
   * the scheduler only if owned (self-created). For a shared scheduler
238
   * the task is simply cancelled.
239
   *
240
   * <p>Called by the Spring container during context close.
241
   */
242
  @Override
243
  public void destroy() {
244
    if (ownsScheduler) {
3✔
245
      scheduler.shutdown();
3✔
246
      try {
247
        if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)) {
6!
NEW
248
          scheduler.shutdownNow();
×
249
        }
NEW
250
      } catch (InterruptedException e) {
×
NEW
251
        scheduler.shutdownNow();
×
NEW
252
        Thread.currentThread().interrupt();
×
253
      }
1✔
254
    }
255
    // Swap any remaining active data into standby and flush both
256
    trySwitch();
2✔
257
    flushStandby();
2✔
258
  }
1✔
259

260
  /**
261
   * Returns the ratio of the active buffer's current distinct-key count
262
   * to {@code maxBufferSize}. A value {@code >= 0.8} (the default
263
   * {@code eagerSwapRatio}) indicates the buffer is close to triggering
264
   * an eager swap.
265
   *
266
   * @return saturation ratio in the {@code [0, 1+)} range
267
   */
268
  public double activeBufferSaturation() {
269
    return (double) active.get().size() / maxBufferSize;
11✔
270
  }
271

272
  private static class CounterBuffer {
2✔
273

274
    private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
5✔
275
    private final LongAdder totalSize = new LongAdder();
6✔
276

277
    /** Reusable result map — avoids allocation on every drain cycle. */
278
    private Map<String, Long> reusableResult;
279

280
    /**
281
     * Record one or more accesses for the given key in this buffer.
282
     *
283
     * @param key   the accessed key
284
     * @param delta the number of accesses to record
285
     */
286
    void add(String key, long delta) {
287
      counters.computeIfAbsent(key, k -> new LongAdder()).add(delta);
12✔
288
      totalSize.add(delta);
4✔
289
    }
1✔
290

291
    /**
292
     * Return the number of distinct keys held in this buffer.
293
     *
294
     * @return the number of distinct keys
295
     */
296
    int size() {
297
      return counters.size();
4✔
298
    }
299

300
    /**
301
     * Return whether this buffer holds no entries.
302
     *
303
     * @return {@code true} if the buffer is empty
304
     */
305
    boolean isEmpty() {
306
      return size() == 0;
7✔
307
    }
308

309
    /**
310
     * Atomically drain all counters and return a snapshot of the accumulated
311
     * counts. After this call the buffer is empty and ready for reuse.
312
     *
313
     * <p>The returned map is reused between calls to reduce GC pressure.
314
     * The caller must not retain a reference beyond the synchronous
315
     * {@code batchConsumer.accept()} callback.
316
     *
317
     * @return a map of keys to their accumulated counts, never {@code null}
318
     */
319
    Map<String, Long> drain() {
320
      Map<String, LongAdder> oldCounters = counters;
3✔
321

322
      if (reusableResult == null) {
3!
323
        reusableResult = new HashMap<>(oldCounters.size());
8✔
324
      } else {
325
        reusableResult.clear();
×
326
      }
327
      oldCounters.forEach((key, adder) -> {
4✔
328
        long val = adder.sumThenReset();
3✔
329
        if (val > 0) {
4✔
330
          reusableResult.put(key, val);
7✔
331
        }
332
      });
1✔
333
      return reusableResult;
3✔
334
    }
335
  }
336
}
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