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

Hyshmily / hotkey / 28503055923

01 Jul 2026 08:06AM UTC coverage: 90.445% (-0.4%) from 90.887%
28503055923

push

github

Hyshmily
perf : promote throughput performance

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

1248 of 1441 branches covered (86.61%)

Branch coverage included in aggregate %.

123 of 139 new or added lines in 10 files covered. (88.49%)

2 existing lines in 2 files now uncovered.

3532 of 3844 relevant lines covered (91.88%)

4.2 hits per line

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

96.74
/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.util.HotKeyThreadFactory;
19
import java.util.HashMap;
20
import java.util.Map;
21
import java.util.concurrent.ConcurrentHashMap;
22
import java.util.concurrent.Executors;
23
import java.util.concurrent.ScheduledExecutorService;
24
import java.util.concurrent.TimeUnit;
25
import java.util.concurrent.atomic.AtomicReference;
26
import java.util.concurrent.atomic.LongAdder;
27
import java.util.function.Consumer;
28
import javax.security.auth.Destroyable;
29
import lombok.extern.slf4j.Slf4j;
30
import org.springframework.beans.factory.InitializingBean;
31

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

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

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

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

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

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

233
  /**
234
   * Perform a final drain of any remaining buffered counts and shut down
235
   * the scheduler only if owned (self-created). For a shared scheduler
236
   * the task is simply cancelled.
237
   *
238
   * <p>Called by the Spring container during context close.
239
   */
240
  @Override
241
  public void destroy() {
242
    if (ownsScheduler) {
3✔
243
      scheduler.shutdown();
3✔
244
    }
245
    // Swap any remaining active data into standby and flush both
246
    trySwitch();
2✔
247
    flushStandby();
2✔
248
  }
1✔
249

250
  private static class CounterBuffer {
2✔
251

252
    private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
5✔
253
    private final LongAdder totalSize = new LongAdder();
6✔
254

255
    /** Reusable result map — avoids allocation on every drain cycle. */
256
    private Map<String, Long> reusableResult;
257

258
    /**
259
     * Record one or more accesses for the given key in this buffer.
260
     *
261
     * @param key   the accessed key
262
     * @param delta the number of accesses to record
263
     */
264
    void add(String key, long delta) {
265
      counters.computeIfAbsent(key, k -> new LongAdder()).add(delta);
12✔
266
      totalSize.add(delta);
4✔
267
    }
1✔
268

269
    /**
270
     * Return the number of distinct keys held in this buffer.
271
     *
272
     * @return the number of distinct keys
273
     */
274
    int size() {
275
      return counters.size();
4✔
276
    }
277

278
    /**
279
     * Return whether this buffer holds no entries.
280
     *
281
     * @return {@code true} if the buffer is empty
282
     */
283
    boolean isEmpty() {
284
      return size() == 0;
7✔
285
    }
286

287
    /**
288
     * Atomically drain all counters and return a snapshot of the accumulated
289
     * counts. After this call the buffer is empty and ready for reuse.
290
     *
291
     * <p>The returned map is reused between calls to reduce GC pressure.
292
     * The caller must not retain a reference beyond the synchronous
293
     * {@code batchConsumer.accept()} callback.
294
     *
295
     * @return a map of keys to their accumulated counts, never {@code null}
296
     */
297
    Map<String, Long> drain() {
298
      Map<String, LongAdder> oldCounters = counters;
3✔
299

300
      if (reusableResult == null) {
3!
301
        reusableResult = new HashMap<>(oldCounters.size());
8✔
302
      } else {
NEW
303
        reusableResult.clear();
×
304
      }
305
      oldCounters.forEach((key, adder) -> {
4✔
306
        long val = adder.sumThenReset();
3✔
307
        if (val > 0) {
4✔
308
          reusableResult.put(key, val);
7✔
309
        }
310
      });
1✔
311
      return reusableResult;
3✔
312
    }
313
  }
314
}
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