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

Hyshmily / hotkey / 28159619024

25 Jun 2026 09:10AM UTC coverage: 92.667% (-0.02%) from 92.685%
28159619024

push

github

Hyshmily
release: v1.1.52

1199 of 1351 branches covered (88.75%)

Branch coverage included in aggregate %.

3426 of 3640 relevant lines covered (94.12%)

4.25 hits per line

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

81.0
/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
      );
180
    } catch (Exception e) {
1✔
181
      log.error("Failed to start HotKeyReporter; per-key counts will not be flushed to Worker. " +
4✔
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
      // Reconcile ring with alive Worker nodes (from heartbeat state), then route via consistent hash
232
      ringManager.reconcileFromHealthView(healthView);
5✔
233

234
      // Set BBR minInFlight to the current number of Workers so that multi-Worker deployments
235
      // don't trigger false-positive drops (each Worker produces one ShardBatch per flush).
236
      if (bbrRateLimiter != null) {
3✔
237
        bbrRateLimiter.setMinInFlight(ringManager.nodeCount());
6✔
238
        if (!bbrRateLimiter.tryAcquire()) {
4!
239
          bbrRateLimiter.onGateDrop();
×
240
          return;
×
241
        }
242
      }
243

244
      Map<String, Map<String, Long>> sharded = new HashMap<>();
4✔
245
      long now = System.currentTimeMillis();
2✔
246

247
      counters
2✔
248
        .asMap()
4✔
249
        .forEach((key, adder) -> {
1✔
250
          long val = adder.sum();
3✔
251

252
          if (val > 0) {
4✔
253
            String target = ringManager.routeNode(key, healthView);
7✔
254

255
            if (target != null) {
2✔
256
              adder.sumThenReset();
3✔
257
              sharded.computeIfAbsent(target, t -> new HashMap<>()).put(key, val);
14✔
258
            }
259
          }
260
        });
1✔
261

262
      sharded.forEach((target, counts) -> {
5✔
263
        if (!dispatcher.enqueue(new ShardBatch(target, now, counts))) {
10!
264
          long dropped = dispatcher.dropped();
×
265

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

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

295
  /**
296
   * Return the maximum capacity of the dispatcher work queue.
297
   *
298
   * @return the queue capacity as configured via {@code queueCapacity},
299
   *         or {@code -1} if the dispatcher has not been started
300
   */
301
  public int dispatcherCapacity() {
302
    return dispatcher == null ? -1 : dispatcher.capacity();
9✔
303
  }
304

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

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

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

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

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

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

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

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

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

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

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

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

489
    /**
490
     * Return the current number of batches waiting in the work queue.
491
     *
492
     * @return current queue depth (non-negative)
493
     */
494
    int depth() {
495
      return queue.size();
4✔
496
    }
497

498
    /**
499
     * Return the maximum number of batches the work queue can hold.
500
     *
501
     * @return the configured queue capacity
502
     */
503
    int capacity() {
504
      return queueCapacity;
4✔
505
    }
506

507
    /**
508
     * Return the number of actively running consumer threads.
509
     *
510
     * @return current consumer thread count
511
     */
512
    int consumerCount() {
513
      return consumers.size();
4✔
514
    }
515

516
    /**
517
     * Return the total number of batches that were discarded due to
518
     * staleness (waited longer than 5 seconds in the queue) since startup.
519
     *
520
     * @return total expired batch count
521
     */
522
    long expired() {
523
      return expiredCount.get();
4✔
524
    }
525

526
    /**
527
     * Return the total number of batches that were rejected because the
528
     * queue was full since startup.
529
     *
530
     * @return total dropped batch count
531
     */
532
    long dropped() {
533
      return droppedCount.get();
4✔
534
    }
535

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

563
        // 5s stale check — discard data that waited too long in the queue
564
        if (System.currentTimeMillis() - batch.timestamp() > 5_000) {
7!
565
          expiredCount.incrementAndGet();
×
566
          if (bbrRateLimiter != null) {
×
567
            bbrRateLimiter.onConsumerDrop();
×
568
          }
569
          continue;
570
        }
571

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