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

Hyshmily / hotkey / 28835396755

07 Jul 2026 01:38AM UTC coverage: 89.623% (-0.7%) from 90.336%
28835396755

push

github

Hyshmily
fix and feat : fix known bugs and accept lz4 to wrap value for smaller memory,"wrap key" needs to consider

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

1325 of 1552 branches covered (85.37%)

Branch coverage included in aggregate %.

167 of 236 new or added lines in 12 files covered. (70.76%)

3745 of 4105 relevant lines covered (91.23%)

4.19 hits per line

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

90.57
/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.*;
23
import java.util.concurrent.atomic.AtomicReference;
24
import java.util.concurrent.atomic.LongAdder;
25
import java.util.function.Consumer;
26
import javax.security.auth.Destroyable;
27
import lombok.extern.slf4j.Slf4j;
28
import org.springframework.beans.factory.InitializingBean;
29

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

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

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

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

68
  private final int maxBufferSize;
69

70
  private final long flushIntervalMs;
71

72
  private final double eagerSwapRatio;
73

74
  private final AtomicReference<CounterBuffer> active;
75

76
  private final ConcurrentLinkedQueue<CounterBuffer> flushQueue;
77

78
  private final Consumer<Map<String, Long>> batchConsumer;
79

80
  private final ScheduledExecutorService scheduler;
81

82
  private final boolean ownsScheduler;
83

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

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

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

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

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

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

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

173
  /**
174
   * Drain all remaining counts from both buffers without calling the consumer.
175
   * After this call both buffers are empty and ready for reuse.
176
   */
177
  public void clear() {
178
    active.getAndSet(new CounterBuffer()).drain();
9✔
179
    CounterBuffer buf;
180
    while ((buf = flushQueue.poll()) != null) {
7!
NEW
181
      buf.drain();
×
182
    }
183
  }
1✔
184

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

196
  private void flushStandby() {
197
    try {
198
      CounterBuffer flushedActive = active.getAndSet(new CounterBuffer());
8✔
199
      drainBuffer(flushedActive);
3✔
200

201
      CounterBuffer buf;
202
      while ((buf = flushQueue.poll()) != null) {
7✔
203
        drainBuffer(buf);
4✔
204
      }
205
    } catch (Exception e) {
1✔
206
      log.error("Scheduled flushStandby failed", e);
4✔
207
    }
1✔
208
  }
1✔
209

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

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

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