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

Hyshmily / hotkey / 28637103998

03 Jul 2026 03:48AM UTC coverage: 90.399% (-0.2%) from 90.63%
28637103998

push

github

Hyshmily
fix: add packaging to gitignore, fix flaky jitter test, update docs references

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

1264 of 1461 branches covered (86.52%)

Branch coverage included in aggregate %.

3604 of 3924 relevant lines covered (91.85%)

4.19 hits per line

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

77.56
/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 static io.github.hyshmily.hotkey.util.TimeSource.currentTimeMillis;
19

20
import io.github.hyshmily.hotkey.Internal;
21
import io.github.hyshmily.hotkey.hotkeydetector.doublebuffer.BufferedCounter;
22
import io.github.hyshmily.hotkey.sharding.ClusterHealthView;
23
import io.github.hyshmily.hotkey.sharding.RingManager;
24
import java.util.*;
25
import java.util.concurrent.BlockingQueue;
26
import java.util.concurrent.LinkedBlockingQueue;
27
import java.util.concurrent.ScheduledExecutorService;
28
import java.util.concurrent.TimeUnit;
29
import java.util.concurrent.atomic.AtomicBoolean;
30
import java.util.concurrent.atomic.AtomicLong;
31
import lombok.Getter;
32
import lombok.Setter;
33
import lombok.extern.slf4j.Slf4j;
34

35
/**
36
 * Periodically aggregates per-key access counts and publishes them
37
 * to the Worker via {@link ReportPublisher}.
38
 *
39
 * <p>Uses a {@link BufferedCounter} double-buffer as a temporary counter store;
40
 * the active buffer accepts lock-free writes while the standby buffer is drained
41
 * and reset on each flush cycle. Flushed to the appropriate shard at a fixed
42
 * interval.
43
 *
44
 * <p>Keys are routed via the {@link RingManager} consistent-hash ring, ensuring the
45
 * same key always maps to the same Worker node even as the cluster scales.
46
 * The RingManager also tracks Worker liveness via heartbeat, so dead shards are
47
 * automatically excluded from routing.
48
 *
49
 * <p>Burst absorption and backpressure are provided by a bounded
50
 * {@link LinkedBlockingQueue} between the flush callback and the
51
 * RabbitMQ publisher.  When the queue is full, {@code onFlush()} drops
52
 * batches after a configurable timeout.
53
 *
54
 * <p>When a {@link BbrRateLimiter} is configured, each flush cycle is submitted
55
 * to the BBR for admission control.  If the pipeline is saturated (high CPU
56
 * and/or excessive in-flight batches), the flushed batch is dropped and
57
 * the counts are lost — at most one {@code reportIntervalMs} window.
58
 */
59
@Slf4j
4✔
60
@Internal
61
public class HotKeyReporter {
62

63
  /** Maximum distinct keys in the BufferedCounter before eager swap. */
64
  private static final int MAX_BUFFER_SIZE = 100_000;
65

66
  /** Fraction of maxBufferSize that triggers an eager buffer swap. */
67
  private static final double EAGER_SWAP_RATIO = 0.8;
68

69
  /** Double-buffered counter aggregating per-key access counts between flushes. */
70
  private final BufferedCounter bufferedCounter;
71

72
  /** Publishes aggregated reports to RabbitMQ. */
73
  private final ReportPublisher reportPublisher;
74

75
  /** Scheduler for the periodic flush loop. */
76
  @Getter
77
  private final ScheduledExecutorService scheduler;
78

79
  /** Fixed delay between report flushes in milliseconds. */
80
  private final long reportIntervalMs;
81
  /** Name of this application instance, included in report messages. */
82
  private final String appName;
83
  /** Maximum capacity of the dispatcher work queue. */
84
  private final int queueCapacity;
85
  /** Timeout (ms) for offering a batch to the dispatcher queue before dropping. */
86
  private final int queueOfferTimeoutMs;
87
  /** Number of consumer threads draining the dispatcher queue. */
88
  private final int consumerCount;
89
  /** Consistent-hashing ring manager for Worker node routing. */
90
  private final RingManager ringManager;
91
  /** Cluster health view for filtering dead Workers. */
92
  private final ClusterHealthView healthView;
93

94
  private volatile int lastNodeCount = -1;
3✔
95

96
  /** Optional BBR adaptive rate limiter; null disables BBR gating. */
97
  @Setter
98
  private volatile BbrRateLimiter bbrRateLimiter;
99

100
  /** Guards start() idempotency. */
101
  private final AtomicBoolean started = new AtomicBoolean(false);
6✔
102
  /** The report dispatcher instance; created on start(). */
103
  private ReportDispatcher dispatcher;
104

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

147
  /**
148
   * A batch of key-count mappings destined for a single Worker target.
149
   *
150
   * @param target    the Worker nodeId for this batch
151
   * @param timestamp wall-clock ms when the batch was assembled
152
   * @param counts    non-zero per-key counts accumulated since the last flush
153
   */
154
  record ShardBatch(String target, long timestamp, Map<String, Long> counts) {}
12✔
155

156
  /**
157
   * Record one access for the given cache key.
158
   *
159
   * <p>Idempotent per-key local counter increment.  The counter is stored in
160
   * a double-buffered {@link BufferedCounter} and flushed periodically to the
161
   * configured Workers.
162
   *
163
   * @param cacheKey the accessed key
164
   */
165
  public void record(String cacheKey) {
166
    bufferedCounter.count(cacheKey, 1);
5✔
167
  }
1✔
168

169
  /**
170
   * Start the periodic flush scheduler and the report dispatcher.
171
   * Idempotent — subsequent calls are silently ignored.
172
   *
173
   * <p>The flush callback drains the {@link BufferedCounter}, groups entries by
174
   * target (shard index or nodeId), and enqueues them.  Actual publishing
175
   * to RabbitMQ runs on dedicated consumer threads, decoupling the flush
176
   * callback from network I/O.
177
   */
178
  public void start() {
179
    if (!started.compareAndSet(false, true)) {
6✔
180
      log.debug("HotKeyReporter already started, skip");
3✔
181
      return;
1✔
182
    }
183
    try {
184
      dispatcher = new ReportDispatcher();
6✔
185
      dispatcher.start();
3✔
186

187
      bufferedCounter.afterPropertiesSet();
3✔
188

189
      log.info(
14✔
190
        "HotKeyReporter started: appName={}, intervalMs={}, queueCapacity={}, consumers={}",
191
        appName,
192
        reportIntervalMs,
6✔
193
        queueCapacity,
6✔
194
        dispatcher.consumerCount()
3✔
195
      );
196
    } catch (Exception e) {
×
197
      log.error(
×
198
        "Failed to start HotKeyReporter; per-key counts will not be flushed to Worker. " +
199
          "Application continues but Worker hot-key detection will be blind to this instance.",
200
        e
201
      );
202
    }
1✔
203
  }
1✔
204

205
  /**
206
   * Gracefully shut down the report dispatcher.
207
   *
208
   * <p>Interrupts all consumer threads and waits for them to finish
209
   * (with a 2-second timeout per thread). Any batches remaining in the
210
   * work queue are discarded. This method is typically registered as
211
   * the Spring bean {@code destroyMethod}.
212
   *
213
   * <p>After shutdown, the reporter no longer publishes reports.
214
   * The periodic flush loop continues to run but its output is silently
215
   * dropped because the dispatcher queue is no longer being consumed.
216
   *
217
   * <p>Idempotent — safe to call multiple times.
218
   */
219
  public void stop() {
220
    bufferedCounter.destroy();
3✔
221
    if (dispatcher != null) {
3✔
222
      dispatcher.shutdown();
3✔
223
    }
224
  }
1✔
225

226
  /**
227
   * Callback invoked by the {@link BufferedCounter} on each scheduled flush.
228
   * Groups the drained key-count map by target Worker via consistent-hash
229
   * routing and enqueues one {@link ShardBatch} per target.
230
   *
231
   * <p>When BBR rate limiting is active, the batch is dropped if the
232
   * limiter rejects the cycle.  Because the double-buffer has already
233
   * drained the counts, at most one {@code reportIntervalMs} window of
234
   * counts is lost — an acceptable trade-off for the simpler and more
235
   * predictable double-buffer design.
236
   *
237
   * <p>If no Workers are alive, the batch is silently discarded.
238
   */
239
  private void onFlush(Map<String, Long> keyCounts) {
240
    if (keyCounts.isEmpty()) {
3!
241
      return;
×
242
    }
243

244
    try {
245
      ringManager.reconcileFromHealthView(healthView);
5✔
246

247
      Set<String> aliveNodes = healthView.getAliveWorkerIds();
4✔
248
      if (aliveNodes.isEmpty()) {
3✔
249
        log.warn("No alive Worker nodes for routing; dropping {} keys in this flush", keyCounts.size());
6✔
250
        return;
1✔
251
      }
252

253
      if (bbrRateLimiter != null) {
3✔
254
        // sync minInFlight floor to current Worker count; only updates when count changes
255
        int currentCount = ringManager.nodeCount();
4✔
256
        if (currentCount != lastNodeCount) {
4!
257
          lastNodeCount = currentCount;
3✔
258
          bbrRateLimiter.setMinInFlight(currentCount);
4✔
259
        }
260
        if (!bbrRateLimiter.tryAcquire()) {
4!
261
          bbrRateLimiter.onGateDrop();
×
262
          return;
×
263
        }
264
      }
265

266
      long now = currentTimeMillis();
2✔
267
      Map<String, Map<String, Long>> sharded = new HashMap<>();
4✔
268

269
      keyCounts.forEach((key, val) -> {
6✔
270
        if (val > 0) {
5!
271
          String target = ringManager.routeNode(key, aliveNodes);
6✔
272
          if (target != null) {
2!
273
            sharded.computeIfAbsent(target, t -> new HashMap<>()).put(key, val);
13✔
274
          }
275
        }
276
      });
1✔
277

278
      sharded.forEach((target, counts) -> {
5✔
279
        if (!dispatcher.enqueue(new ShardBatch(target, now, counts))) {
10!
280
          long dropped = dispatcher.dropped();
×
281
          if (dropped % 100 == 0 || dropped == 1) {
×
282
            log.warn(
×
283
              "report queue full, dropped target={} keys={}, depth={}/{}, cumulativeDrops={}",
284
              target,
285
              counts.size(),
×
286
              dispatcher.depth(),
×
287
              queueCapacity,
×
288
              dropped
×
289
            );
290
          }
291
        } else if (bbrRateLimiter != null) {
3✔
292
          bbrRateLimiter.onEnqueue();
3✔
293
        }
294
      });
1✔
295
    } catch (Exception e) {
×
296
      log.error("Flush callback failed", e);
×
297
    }
1✔
298
  }
1✔
299

300
  /**
301
   * Return the current number of batches waiting in the dispatcher work queue.
302
   *
303
   * @return queue depth (number of enqueued but not yet consumed batches),
304
   *         or {@code -1} if the dispatcher has not been started
305
   */
306
  public int dispatcherDepth() {
307
    return dispatcher == null ? -1 : dispatcher.depth();
9✔
308
  }
309

310
  /**
311
   * Return the maximum capacity of the dispatcher work queue.
312
   *
313
   * @return the queue capacity as configured via {@code queueCapacity},
314
   *         or {@code -1} if the dispatcher has not been started
315
   */
316
  public int dispatcherCapacity() {
317
    return dispatcher == null ? -1 : dispatcher.capacity();
9✔
318
  }
319

320
  /**
321
   * Return the total number of batches that were discarded because they
322
   * waited longer than 5 seconds in the dispatcher queue (staleness expiry).
323
   *
324
   * @return total expired batch count since startup, or {@code -1} if the
325
   *         dispatcher has not been started
326
   */
327
  public long dispatcherExpired() {
328
    return dispatcher == null ? -1 : dispatcher.expired();
7!
329
  }
330

331
  /**
332
   * Return the total number of batches rejected because the dispatcher
333
   * queue was full (back-pressure drops from the flush loop).
334
   *
335
   * @return total dropped batch count since startup, or {@code -1} if the
336
   *         dispatcher has not been started
337
   */
338
  public long dispatcherDropped() {
339
    return dispatcher == null ? -1 : dispatcher.dropped();
7!
340
  }
341

342
  /**
343
   * Return the approximate number of unique keys currently buffered in the
344
   * local double-buffer counter store.
345
   *
346
   * <p>This is the sum of distinct keys in both the active and standby
347
   * buffers and may not reflect the exact count under concurrent access.
348
   *
349
   * @return estimated number of unique keys with pending access counts
350
   */
351
  public long getPendingKeyCount() {
352
    return bufferedCounter.estimatedSizeOfKeysCount();
4✔
353
  }
354

355
  /**
356
   * Return the total number of flush cycles that were permitted by the BBR
357
   * rate limiter since startup.
358
   *
359
   * @return total passed count, or {@code -1} if BBR rate limiting is disabled
360
   *         ({@link #bbrRateLimiter} is {@code null})
361
   */
362
  public long bbrPassed() {
363
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getTotalPassed();
9✔
364
  }
365

366
  /**
367
   * Return the total number of flush cycles that were dropped by the BBR
368
   * rate limiter since startup (both gate drops and consumer drops).
369
   *
370
   * @return total dropped count, or {@code -1} if BBR rate limiting is disabled
371
   */
372
  public long bbrDropped() {
373
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getTotalDropped();
9✔
374
  }
375

376
  /**
377
   * Return the current number of in-flight batches tracked by the BBR rate
378
   * limiter — those enqueued for publishing but not yet completed or dropped.
379
   *
380
   * @return current in-flight count (non-negative), or {@code -1} if BBR
381
   *         rate limiting is disabled
382
   */
383
  public long bbrInFlight() {
384
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getInFlight();
9✔
385
  }
386

387
  /**
388
   * Return the current BBR-computed maximum concurrency budget (max in-flight).
389
   *
390
   * <p>This value is derived from the sliding-window maxPASS and minRT
391
   * metrics via Little's Law. It represents the limiter's estimate of the
392
   * optimal number of concurrent in-flight batches before the pipeline
393
   * becomes congested.
394
   *
395
   * @return the computed max in-flight budget, or {@code -1} if BBR rate
396
   *         limiting is disabled
397
   */
398
  public long bbrMaxInFlight() {
399
    return bbrRateLimiter == null ? -1 : bbrRateLimiter.getCurrentMaxInFlight();
9✔
400
  }
401

402
  /**
403
   * Manages a bounded work queue and a fixed pool of consumer threads that
404
   * drain batches and publish them via {@link ReportPublisher}.
405
   *
406
   * <p>Burst absorption: the bound on {@code LinkedBlockingQueue} prevents
407
   * the flush callback from overwhelming RabbitMQ.  When the queue is
408
   * persistently full, batches are dropped after a configurable timeout.
409
   */
410
  class ReportDispatcher {
5✔
411

412
    /** Bounded work queue between the flush loop and the consumer threads. */
413
    private final BlockingQueue<ShardBatch> queue = new LinkedBlockingQueue<>(queueCapacity);
8✔
414
    /** Count of batches discarded due to staleness (>5 s wait in queue). */
415
    private final AtomicLong expiredCount = new AtomicLong();
5✔
416
    /** Count of batches rejected because the queue was full. */
417
    private final AtomicLong droppedCount = new AtomicLong();
5✔
418
    /** Active consumer threads draining the work queue. */
419
    private final List<Thread> consumers = new ArrayList<>();
6✔
420
    /** Lifecycle flag; false after shutdown. */
421
    private volatile boolean running;
422

423
    /**
424
     * Start the consumer threads that drain batches from the bounded work
425
     * queue and publish them via {@link ReportPublisher}.
426
     *
427
     * <p>Each consumer runs in a named daemon thread
428
     * ({@code "report-consumer-N"}) so they do not prevent JVM shutdown.
429
     * The number of consumer threads is determined by the
430
     * {@code consumerCount} configuration.
431
     */
432
    void start() {
433
      running = true;
3✔
434
      for (int i = 0; i < consumerCount; i++) {
9✔
435
        Thread t = new Thread(this::consumeLoop, "hotkey-report-consumer-" + i);
8✔
436
        t.setDaemon(true);
3✔
437
        t.start();
2✔
438
        consumers.add(t);
5✔
439
      }
440
    }
1✔
441

442
    /**
443
     * Offer a batch to the bounded work queue with a timeout.
444
     *
445
     * <p>Blocks for up to {@code queueOfferTimeoutMs} milliseconds waiting
446
     * for space to become available. If the queue remains full after the
447
     * timeout, the batch is rejected and the drop counter is incremented.
448
     *
449
     * <p>If the calling thread is interrupted while waiting, the batch is
450
     * discarded and {@code false} is returned.
451
     *
452
     * @param batch the sharded batch to enqueue for publishing
453
     * @return {@code true} if the batch was accepted into the queue;
454
     *         {@code false} if the queue was full or the thread was
455
     *         interrupted
456
     */
457
    boolean enqueue(ShardBatch batch) {
458
      try {
459
        boolean accepted = queue.offer(batch, queueOfferTimeoutMs, TimeUnit.MILLISECONDS);
10✔
460
        if (!accepted) {
2!
461
          droppedCount.incrementAndGet();
×
462
        }
463
        return accepted;
2✔
464
      } catch (InterruptedException e) {
×
465
        Thread.currentThread().interrupt();
×
466
        return false;
×
467
      }
468
    }
469

470
    /**
471
     * Gracefully shut down all consumer threads.
472
     *
473
     * <p>Signals all consumers to stop via the {@code running} flag and
474
     * thread interruption, then waits up to 2 seconds for each thread to
475
     * finish. After shutdown, the queue may still contain unconsumed
476
     * batches — they are discarded.
477
     *
478
     * <p>This method is called from {@link HotKeyReporter#stop()} and is
479
     * idempotent. However, after shutdown the dispatcher cannot be
480
     * restarted.
481
     */
482
    void shutdown() {
483
      running = false;
3✔
484
      for (Thread t : consumers) {
11✔
485
        t.interrupt();
2✔
486
      }
1✔
487
      for (Thread t : consumers) {
11✔
488
        try {
489
          t.join(2000);
3✔
490
        } catch (InterruptedException e) {
×
491
          Thread.currentThread().interrupt();
×
492
        }
1✔
493
      }
1✔
494
      consumers.clear();
3✔
495
      log.info(
9✔
496
        "ReportDispatcher stopped, remaining queue={}, expired={}, dropped={}",
497
        queue.size(),
7✔
498
        expiredCount.get(),
7✔
499
        droppedCount.get()
3✔
500
      );
501
    }
1✔
502

503
    /**
504
     * Return the current number of batches waiting in the work queue.
505
     *
506
     * @return current queue depth (non-negative)
507
     */
508
    int depth() {
509
      return queue.size();
4✔
510
    }
511

512
    /**
513
     * Return the maximum number of batches the work queue can hold.
514
     *
515
     * @return the configured queue capacity
516
     */
517
    int capacity() {
518
      return queueCapacity;
4✔
519
    }
520

521
    /**
522
     * Return the number of actively running consumer threads.
523
     *
524
     * @return current consumer thread count
525
     */
526
    int consumerCount() {
527
      return consumers.size();
4✔
528
    }
529

530
    /**
531
     * Return the total number of batches that were discarded due to
532
     * staleness (waited longer than 5 seconds in the queue) since startup.
533
     *
534
     * @return total expired batch count
535
     */
536
    long expired() {
537
      return expiredCount.get();
4✔
538
    }
539

540
    /**
541
     * Return the total number of batches that were rejected because the
542
     * queue was full since startup.
543
     *
544
     * @return total dropped batch count
545
     */
546
    long dropped() {
547
      return droppedCount.get();
4✔
548
    }
549

550
    /**
551
     * Consumer thread body: poll batches from the queue and publish them.
552
     *
553
     * <p>Staleness guard: batches that waited longer than 5 s in the queue
554
     * are discarded rather than published, preventing the Worker from receiving
555
     * stale access patterns during prolonged backpressure.
556
     *
557
     * <p>When BBR rate limiting is active:
558
     * <ul>
559
     *   <li>Successful publish records round-trip time via {@link BbrRateLimiter#onSuccess(long)}</li>
560
     *   <li>Stale batches (5s+ wait) or publish failures trigger ,
561
     *       allowing BBR to back off the send rate and prevent cascading overload</li>
562
     *   <li>InterruptedException is handled separately (break, not logged as error)
563
     *       to avoid noise during orderly shutdown</li>
564
     * </ul>
565
     */
566
    private void consumeLoop() {
567
      while (running) {
3!
568
        ShardBatch batch;
569
        try {
570
          batch = queue.take();
5✔
571
        } catch (InterruptedException e) {
1✔
572
          log.warn("ReportDispatcher consumer interrupted, shutting down");
3✔
573
          Thread.currentThread().interrupt();
2✔
574
          break;
1✔
575
        }
1✔
576

577
        // 5s stale check — discard data that waited too long in the queue
578
        if (currentTimeMillis() - batch.timestamp() > 5_000) {
7!
579
          expiredCount.incrementAndGet();
×
580
          if (bbrRateLimiter != null) {
×
581
            bbrRateLimiter.onConsumerDrop();
×
582
          }
583
          continue;
584
        }
585

586
        try {
587
          reportPublisher.publish(batch.target(), new ReportMessage(appName, batch.timestamp(), batch.counts()));
16✔
588
          if (bbrRateLimiter != null) {
4✔
589
            bbrRateLimiter.onSuccess(currentTimeMillis() - batch.timestamp());
8✔
590
          }
591
        } catch (Exception e) {
×
592
          log.error("Failed to publish report batch for target={}, keys={}", batch.target(), batch.counts().size(), e);
×
593
          if (bbrRateLimiter != null) {
×
594
            bbrRateLimiter.onConsumerDrop();
×
595
          }
596
        }
1✔
597
      }
1✔
598
    }
1✔
599
  }
600
}
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