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

Morry98 / fastapi-task-manager / 22868324715

09 Mar 2026 06:21PM UTC coverage: 97.577%. First build
22868324715

Pull #2

github

web-flow
Merge ad0ba7df0 into d04f85da2
Pull Request #2: Improve task management with Redis Streams, leader election and management API

3182 of 3264 new or added lines in 32 files covered. (97.49%)

3342 of 3425 relevant lines covered (97.58%)

4.86 hits per line

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

94.85
/src/fastapi_task_manager/stream_consumer.py
1
"""Stream consumer module - consumes and executes tasks from Redis Streams.
2

3
This module provides the StreamConsumer class which is responsible for:
4
- Setting up the Redis consumer groups for dual-queue (high/low priority)
5
- Consuming tasks from the appropriate stream based on priority and capacity
6
- Executing task functions with proper concurrency control
7
- Maintaining a running heartbeat during task execution for crash detection
8
- Acknowledging completed tasks with XACK
9
- Recording execution statistics
10
- Recovering pending messages from crashed workers on startup
11

12
The dual-queue design ensures:
13
- High priority tasks are always attempted first
14
- Low priority tasks are only read when the worker has available capacity
15
- Workers without capacity don't consume low priority messages, leaving them
16
  for other workers with available slots
17

18
The running heartbeat ensures:
19
- A "running" key with short TTL is renewed periodically while a task executes
20
- If a worker crashes, the key expires quickly, enabling fast recovery
21
- Long-running tasks (minutes/hours) are protected by the continuous heartbeat
22

23
The StreamConsumer runs on ALL workers (both leader and followers),
24
enabling horizontal scaling of task execution.
25
"""
26

27
import asyncio
5✔
28
import contextlib
5✔
29
import logging
5✔
30
import math
5✔
31
import time
5✔
32
from datetime import datetime, timezone
5✔
33
from typing import TYPE_CHECKING
5✔
34

35
from redis.asyncio import Redis
5✔
36
from redis.exceptions import ResponseError
5✔
37

38
from fastapi_task_manager.async_utils import interruptible_sleep
5✔
39
from fastapi_task_manager.redis_keys import RedisKeyBuilder
5✔
40
from fastapi_task_manager.schema.task import Task
5✔
41
from fastapi_task_manager.schema.worker_identity import WorkerIdentity
5✔
42
from fastapi_task_manager.statistics import StatisticsStorage
5✔
43

44
if TYPE_CHECKING:
45
    from fastapi_task_manager import TaskManager
46

47
logger = logging.getLogger("fastapi.task-manager.consumer")
5✔
48

49
# Short sleep duration when no messages or no capacity (50ms)
50
NO_WORK_SLEEP_SECONDS = 0.05
5✔
51

52

53
class StreamConsumer:
5✔
54
    """Consumes tasks from dual Redis Streams (high/low priority) and executes them.
55

56
    Uses a dual-queue design where:
57
    - High priority stream is always checked first
58
    - Low priority stream is only read when semaphore has available slots
59
    - If no capacity, messages are left in the stream for other workers
60

61
    The consumer uses non-blocking reads for high priority and short-blocking
62
    reads for low priority to balance responsiveness with efficiency.
63

64
    Responsibilities:
65
    - Setup consumer groups for both streams
66
    - Check semaphore capacity before reading low priority
67
    - Consume messages using XREADGROUP
68
    - Execute task functions (sync or async) in parallel
69
    - Acknowledge successful executions with XACK
70
    - Record execution statistics
71
    - Respect concurrency limits via semaphore
72
    """
73

74
    def __init__(
5✔
75
        self,
76
        redis_client: Redis,
77
        key_builder: RedisKeyBuilder,
78
        worker_identity: WorkerIdentity,
79
        task_manager: "TaskManager",
80
        semaphore: asyncio.Semaphore,
81
        statistics: StatisticsStorage,
82
    ) -> None:
83
        """Initialize the StreamConsumer.
84

85
        Args:
86
            redis_client: Async Redis client for stream operations.
87
            key_builder: RedisKeyBuilder instance for key construction.
88
            worker_identity: WorkerIdentity instance for this worker.
89
            task_manager: TaskManager instance containing registered task groups.
90
            semaphore: asyncio.Semaphore for concurrency control.
91
            statistics: StatisticsStorage for recording execution stats.
92
        """
93
        self._redis = redis_client
5✔
94
        self._keys = key_builder
5✔
95
        self._worker = worker_identity
5✔
96
        self._task_manager = task_manager
5✔
97
        self._semaphore = semaphore
5✔
98
        self._statistics = statistics
5✔
99
        self._running = False
5✔
100
        # Track running tasks for graceful shutdown
101
        self._running_tasks: set[asyncio.Task] = set()
5✔
102

103
    async def setup_consumer_groups(self) -> None:
5✔
104
        """Create consumer groups for both streams if they don't exist.
105

106
        Uses XGROUP CREATE with MKSTREAM to create both the stream and
107
        consumer group atomically. If the group already exists, the
108
        BUSYGROUP error is caught and ignored.
109

110
        The consumer groups are created with ID "$" which means consumers
111
        will only receive new messages (not historical ones).
112
        """
113
        stream_keys = self._keys.get_stream_keys()
5✔
114
        group_name = stream_keys.consumer_group
5✔
115

116
        # Setup consumer group for high priority stream
117
        await self._create_consumer_group(
5✔
118
            stream_keys.task_stream_high,
119
            group_name,
120
            "high priority",
121
        )
122

123
        # Setup consumer group for low priority stream
124
        await self._create_consumer_group(
5✔
125
            stream_keys.task_stream_low,
126
            group_name,
127
            "low priority",
128
        )
129

130
    async def _create_consumer_group(
5✔
131
        self,
132
        stream_key: str,
133
        group_name: str,
134
        stream_label: str,
135
    ) -> None:
136
        """Create a consumer group for a specific stream.
137

138
        Args:
139
            stream_key: The Redis stream key.
140
            group_name: The consumer group name.
141
            stream_label: Human-readable label for logging.
142
        """
143
        try:
5✔
144
            await self._redis.xgroup_create(
5✔
145
                stream_key,
146
                group_name,
147
                id="$",  # Only new messages
148
                mkstream=True,  # Create stream if not exists
149
            )
150
            logger.info(
5✔
151
                "Created consumer group '%s' for %s stream '%s'",
152
                group_name,
153
                stream_label,
154
                stream_key,
155
            )
156
        except ResponseError as e:
5✔
157
            if "BUSYGROUP" in str(e):
5✔
158
                logger.debug(
5✔
159
                    "Consumer group '%s' already exists for %s stream",
160
                    group_name,
161
                    stream_label,
162
                )
163
            else:
164
                raise
5✔
165

166
    async def start(self) -> asyncio.Task:
5✔
167
        """Start the consumer loop.
168

169
        Before entering the main loop, attempts to recover any pending messages
170
        left by crashed consumers (using XAUTOCLAIM).
171

172
        Returns:
173
            The asyncio Task running the consumer loop.
174
        """
175
        self._running = True
5✔
176
        await self._recover_pending_on_startup()
5✔
177
        return asyncio.create_task(self._consume_loop(), name="StreamConsumer")
5✔
178

179
    async def stop(self) -> None:
5✔
180
        """Stop the consumer loop, wait for running tasks, and remove consumer from group."""
181
        self._running = False
5✔
182

183
        # Wait for all running tasks to complete
184
        if self._running_tasks:
5✔
185
            logger.info("Waiting for %d running tasks to complete...", len(self._running_tasks))
5✔
186
            await asyncio.gather(*self._running_tasks, return_exceptions=True)
5✔
187

188
        # Remove this consumer from both consumer groups so it doesn't linger in Redis
189
        await self._deregister_consumer()
5✔
190

191
    async def _deregister_consumer(self) -> None:
5✔
192
        """Remove this consumer from both stream consumer groups on shutdown.
193

194
        Uses XGROUP DELCONSUMER to cleanly remove the consumer entry from Redis,
195
        preventing stale consumers from lingering after the worker stops.
196
        """
197
        stream_keys = self._keys.get_stream_keys()
5✔
198
        consumer_name = self._worker.redis_safe_id
5✔
199

200
        for stream_key, label in [
5✔
201
            (stream_keys.task_stream_high, "high"),
202
            (stream_keys.task_stream_low, "low"),
203
        ]:
204
            try:
5✔
205
                await self._redis.xgroup_delconsumer(
5✔
206
                    stream_key,
207
                    stream_keys.consumer_group,
208
                    consumer_name,
209
                )
210
                logger.info(
5✔
211
                    "Removed consumer '%s' from %s priority group",
212
                    consumer_name,
213
                    label,
214
                )
NEW
215
            except Exception:
×
NEW
216
                logger.warning(
×
217
                    "Failed to remove consumer '%s' from %s priority group",
218
                    consumer_name,
219
                    label,
220
                    exc_info=True,
221
                )
222

223
    async def _consume_loop(self) -> None:
5✔
224
        """Main consumer loop with dual-queue priority logic.
225

226
        The loop follows this priority:
227
        1. If semaphore has capacity, try to read from high priority stream
228
        2. If no high priority message, try low priority stream
229
        3. If no capacity or no messages, sleep briefly and retry
230

231
        This ensures high priority tasks are always processed first, and
232
        low priority tasks don't block the queue when workers are at capacity.
233
        """
234
        stream_keys = self._keys.get_stream_keys()
5✔
235
        config = self._task_manager.config
5✔
236

237
        while self._running:
5✔
238
            try:
5✔
239
                # Check if we have available capacity (semaphore not fully locked)
240
                if not self._semaphore.locked():
5✔
241
                    # Try high priority stream first (non-blocking)
242
                    result = await self._try_read_stream(
5✔
243
                        stream_keys.task_stream_high,
244
                        stream_keys.consumer_group,
245
                        block_ms=None,  # None = non-blocking; 0 would block forever
246
                    )
247

248
                    if result:
5✔
249
                        message_id, data = result
5✔
250
                        self._spawn_task(message_id, data, is_high_priority=True)
5✔
251
                        continue
5✔
252

253
                    # No high priority, try low priority (short blocking)
254
                    result = await self._try_read_stream(
5✔
255
                        stream_keys.task_stream_low,
256
                        stream_keys.consumer_group,
257
                        block_ms=config.stream_block_ms,
258
                    )
259

260
                    if result:
5✔
261
                        message_id, data = result
5✔
262
                        self._spawn_task(message_id, data, is_high_priority=False)
5✔
263
                        continue
5✔
264

265
                # No capacity or no messages - short sleep to avoid busy loop
266
                await interruptible_sleep(NO_WORK_SLEEP_SECONDS, lambda: self._running)
5✔
267

268
            except asyncio.CancelledError:
5✔
NEW
269
                logger.info("Consumer loop cancelled")
×
NEW
270
                break
×
271
            except Exception:
5✔
272
                logger.exception("Error in consumer loop")
5✔
273
                await interruptible_sleep(1, lambda: self._running)
5✔
274

275
    async def _try_read_stream(
5✔
276
        self,
277
        stream_key: str,
278
        group_name: str,
279
        block_ms: int | None,
280
    ) -> tuple[str, dict] | None:
281
        """Try to read a single message from a stream.
282

283
        Args:
284
            stream_key: The Redis stream key to read from.
285
            group_name: The consumer group name.
286
            block_ms: How long to block waiting for a message (None = non-blocking, 0 = forever).
287

288
        Returns:
289
            Tuple of (message_id, data) if a message was read, None otherwise.
290
        """
291
        messages = await self._redis.xreadgroup(
5✔
292
            groupname=group_name,
293
            consumername=self._worker.redis_safe_id,
294
            streams={stream_key: ">"},
295
            count=1,  # Read one message at a time
296
            block=block_ms,
297
        )
298

299
        if not messages:
5✔
300
            return None
5✔
301

302
        # Extract the first message from the response
303
        # Response format: [(stream_name, [(message_id, data), ...])]
304
        for _stream_name, stream_messages in messages:
5✔
305
            for msg_id, data in stream_messages:
5✔
306
                return msg_id, data
5✔
307

NEW
308
        return None
×
309

310
    def _spawn_task(
5✔
311
        self,
312
        message_id: str,
313
        data: dict,
314
        is_high_priority: bool,
315
    ) -> None:
316
        """Spawn an async task to process the message.
317

318
        The spawned task will acquire the semaphore, execute the task,
319
        record statistics, and acknowledge the message.
320

321
        Args:
322
            message_id: The Redis message ID.
323
            data: The message data containing task information.
324
            is_high_priority: Whether this is from the high priority stream.
325
        """
326
        task = asyncio.create_task(
5✔
327
            self._process_message(message_id, data, is_high_priority),
328
            name=f"Task-{message_id}",
329
        )
330
        self._running_tasks.add(task)
5✔
331
        task.add_done_callback(self._running_tasks.discard)
5✔
332

333
    async def _process_message(
5✔
334
        self,
335
        message_id: str,
336
        data: dict,
337
        is_high_priority: bool,
338
    ) -> None:
339
        """Process a single message from the stream.
340

341
        Acquires the semaphore, extracts task information, finds the
342
        corresponding task definition, and executes it.
343

344
        Args:
345
            message_id: The Redis message ID.
346
            data: The message data containing task information.
347
            is_high_priority: Whether this is from the high priority stream.
348
        """
349

350
        # Execute with semaphore to respect concurrency limits
351
        async with self._semaphore:
5✔
352
            # Extract message fields
353
            group_name = data.get("group", "")
5✔
354
            task_name = data.get("task", "")
5✔
355

356
            priority_label = "high" if is_high_priority else "low"
5✔
357
            logger.debug(
5✔
358
                "Processing message %s: %s/%s (priority: %s)",
359
                message_id,
360
                group_name,
361
                task_name,
362
                priority_label,
363
            )
364

365
            # Find the task definition
366
            task = self._find_task(group_name, task_name)
5✔
367
            if task is None:
5✔
368
                logger.warning(
5✔
369
                    "Task not found: %s/%s, acknowledging to prevent reprocessing",
370
                    group_name,
371
                    task_name,
372
                )
373
                await self._ack_message(message_id, is_high_priority)
5✔
374
                return
5✔
375

376
            await self._execute_and_ack(message_id, group_name, task, is_high_priority)
5✔
377

378
    async def _execute_and_ack(
5✔
379
        self,
380
        message_id: str,
381
        group_name: str,
382
        task: Task,
383
        is_high_priority: bool,
384
    ) -> None:
385
        """Execute task with running heartbeat and acknowledge on completion.
386

387
        While the task executes, a background heartbeat periodically renews a
388
        "running" key in Redis with a short TTL. This allows the Reconciler to
389
        quickly detect crashed workers: if the key expires, the worker is dead.
390

391
        On success: records stats, removes tracking entries, ACKs the message.
392
        On failure: stops heartbeat, removes running key, does NOT ACK (message
393
        remains pending for reconciliation/retry).
394

395
        Args:
396
            message_id: The Redis message ID to acknowledge.
397
            group_name: The task group name for statistics recording.
398
            task: The Task to execute.
399
            is_high_priority: Whether this is from the high priority stream.
400
        """
401
        keys = self._keys.get_task_keys(group_name, task.name)
5✔
402
        config = self._task_manager.config
5✔
403
        task_id = f"{group_name}:{task.name}"
5✔
404
        running_key = self._keys.running_task_key(group_name, task.name)
5✔
405

406
        # Mark the task as running with a TTL derived from heartbeat interval
407
        running_ttl = math.ceil(config.running_heartbeat_interval * 3)
5✔
408
        await self._redis.set(
5✔
409
            running_key,
410
            self._worker.redis_safe_id,
411
            ex=running_ttl,
412
        )
413

414
        # Start the heartbeat loop in the background
415
        heartbeat_active = True
5✔
416

417
        async def _heartbeat() -> None:
5✔
418
            """Renew the running key at regular intervals while the task executes."""
419
            while heartbeat_active:
3✔
420
                await interruptible_sleep(config.running_heartbeat_interval, lambda: heartbeat_active)
3✔
NEW
421
                if heartbeat_active:
×
NEW
422
                    try:
×
NEW
423
                        await self._redis.set(
×
424
                            running_key,
425
                            self._worker.redis_safe_id,
426
                            ex=running_ttl,
427
                        )
NEW
428
                    except Exception:
×
NEW
429
                        logger.exception("Failed to renew running heartbeat for %s/%s", group_name, task.name)
×
430

431
        heartbeat_task = asyncio.create_task(_heartbeat(), name=f"Heartbeat-{task_id}")
5✔
432

433
        start = time.monotonic_ns()
5✔
434
        try:
5✔
435
            # Execute the task function
436
            if asyncio.iscoroutinefunction(task.function):
5✔
437
                await task.function(**(task.kwargs or {}))
5✔
438
            else:
439
                # Run sync functions in a thread to avoid blocking
440
                await asyncio.to_thread(task.function, **(task.kwargs or {}))
5✔
441

442
            end = time.monotonic_ns()
5✔
443

444
            # Stop heartbeat
445
            heartbeat_active = False
5✔
446
            heartbeat_task.cancel()
5✔
447
            with contextlib.suppress(asyncio.CancelledError):
5✔
448
                await heartbeat_task
5✔
449

450
            # Record execution statistics
451
            await self._statistics.record_execution(
5✔
452
                stream_key=keys.stats_stream,
453
                timestamp=datetime.now(timezone.utc).timestamp(),
454
                duration_seconds=(end - start) / 1e9,
455
            )
456

457
            # Clean up: remove running key and tracking entry
458
            await self._redis.delete(running_key)
5✔
459
            await self._redis.srem(self._keys.scheduled_set_key(), task_id)  # ty: ignore[invalid-await]
5✔
460

461
            # Reset backoff state on success (if any was active)
462
            await self._redis.delete(keys.retry_after, keys.retry_delay)
5✔
463

464
            # Acknowledge successful execution
465
            await self._ack_message(message_id, is_high_priority)
5✔
466

467
            logger.debug(
5✔
468
                "Task %s/%s completed successfully (%.3fs)",
469
                group_name,
470
                task.name,
471
                (end - start) / 1e9,
472
            )
473

474
        except Exception:
5✔
475
            logger.exception("Task %s/%s failed", group_name, task.name)
5✔
476

477
            # Stop heartbeat and remove running key
478
            heartbeat_active = False
5✔
479
            heartbeat_task.cancel()
5✔
480
            with contextlib.suppress(asyncio.CancelledError):
5✔
481
                await heartbeat_task
5✔
482
            await self._redis.delete(running_key)
5✔
483

484
            # Apply exponential backoff with cap
485
            await self._apply_backoff(group_name, task)
5✔
486

487
            # ACK the message — retry is managed by the backoff mechanism,
488
            # not by leaving the message pending in the stream.
489
            await self._ack_message(message_id, is_high_priority)
5✔
490

491
            # Clean up tracking entry
492
            await self._redis.srem(self._keys.scheduled_set_key(), task_id)  # ty: ignore[invalid-await]
5✔
493

494
    async def _apply_backoff(self, group_name: str, task: Task) -> None:
5✔
495
        """Apply exponential backoff after a task failure.
496

497
        Reads the current delay from Redis (or uses the initial value),
498
        sets a retry_after timestamp, and stores the next delay (capped).
499
        Both keys have a TTL as a safety net for cleanup.
500

501
        Args:
502
            group_name: The task group name.
503
            task: The Task that failed.
504
        """
505
        keys = self._keys.get_task_keys(group_name, task.name)
5✔
506
        config = self._task_manager.config
5✔
507

508
        # Resolve effective backoff values (per-task override or global config)
509
        initial = task.retry_backoff or config.retry_backoff
5✔
510
        max_delay = task.retry_backoff_max or config.retry_backoff_max
5✔
511
        multiplier = config.retry_backoff_multiplier
5✔
512
        # Safety net: retry state expires after 24 hours if no new failures occur
513
        ttl = 86_400
5✔
514

515
        # Read current delay from Redis, or start with initial value
516
        current_raw = await self._redis.get(keys.retry_delay)
5✔
517
        current = float(current_raw) if current_raw else initial
5✔
518

519
        # Set retry_after timestamp — Coordinator will skip scheduling until this expires
520
        retry_after = time.time() + current
5✔
521
        await self._redis.set(keys.retry_after, str(retry_after), ex=ttl)
5✔
522

523
        # Calculate and store next delay for the next potential failure (capped at max)
524
        next_delay = min(current * multiplier, max_delay)
5✔
525
        await self._redis.set(keys.retry_delay, str(next_delay), ex=ttl)
5✔
526

527
        logger.warning(
5✔
528
            "Task %s/%s in backoff: retry after %.1fs (next delay: %.1fs, cap: %.1fs)",
529
            group_name,
530
            task.name,
531
            current,
532
            next_delay,
533
            max_delay,
534
        )
535

536
    async def _ack_message(self, message_id: str, is_high_priority: bool) -> None:
5✔
537
        """Acknowledge a message as successfully processed.
538

539
        Removes the message from the pending entries list for this consumer.
540
        Once acknowledged, the message won't be redelivered.
541

542
        Args:
543
            message_id: The Redis message ID to acknowledge.
544
            is_high_priority: Whether this is from the high priority stream.
545
        """
546
        stream_keys = self._keys.get_stream_keys()
5✔
547
        stream_key = stream_keys.task_stream_high if is_high_priority else stream_keys.task_stream_low
5✔
548

549
        await self._redis.xack(
5✔
550
            stream_key,
551
            stream_keys.consumer_group,
552
            message_id,
553
        )
554

555
        # Remove the message from the stream to keep memory usage low
556
        await self._redis.xdel(stream_key, message_id)
5✔
557

558
    def _find_task(self, group_name: str, task_name: str) -> Task | None:
5✔
559
        """Find a task by group and task name.
560

561
        Searches through all registered task groups to find the matching task.
562

563
        Args:
564
            group_name: The task group name.
565
            task_name: The task name.
566

567
        Returns:
568
            The Task if found, None otherwise.
569
        """
570
        for task_group in self._task_manager.task_groups:
5✔
571
            if task_group.name == group_name:
5✔
572
                for task in task_group.tasks:
5✔
573
                    if task.name == task_name:
5✔
574
                        return task
5✔
575
        return None
5✔
576

577
    async def _recover_pending_on_startup(self) -> None:
5✔
578
        """Recover pending messages from crashed consumers on startup.
579

580
        Uses XAUTOCLAIM (Redis >= 6.2) to reclaim messages that have been idle
581
        for longer than running_heartbeat_interval * 9 (in ms). These are messages that were
582
        read by a consumer but never ACK'd (e.g., the consumer crashed mid-execution).
583

584
        Reclaimed messages are spawned as tasks for this worker to process.
585
        This runs once at startup before the main consume loop begins.
586
        """
587
        stream_keys = self._keys.get_stream_keys()
5✔
588
        config = self._task_manager.config
5✔
589
        # Derived from running_heartbeat_interval: TTL is interval * 3,
590
        # and we wait 3x the TTL to be sure the heartbeat has expired
591
        min_idle = math.ceil(config.running_heartbeat_interval * 9) * 1000
5✔
592

593
        for stream_key, is_high in [
5✔
594
            (stream_keys.task_stream_high, True),
595
            (stream_keys.task_stream_low, False),
596
        ]:
597
            try:
5✔
598
                # XAUTOCLAIM returns: [next_start_id, [(msg_id, data), ...], [deleted_ids]]
599
                result = await self._redis.xautoclaim(
5✔
600
                    stream_key,
601
                    stream_keys.consumer_group,
602
                    self._worker.redis_safe_id,
603
                    min_idle_time=min_idle,
604
                    count=50,
605
                )
606

607
                if not result or not result[1]:
5✔
608
                    continue
5✔
609

610
                claimed_messages = result[1]
5✔
611
                priority_label = "high" if is_high else "low"
5✔
612
                logger.info(
5✔
613
                    "Recovered %d pending %s priority message(s) on startup",
614
                    len(claimed_messages),
615
                    priority_label,
616
                )
617

618
                for msg_id, data in claimed_messages:
5✔
619
                    self._spawn_task(msg_id, data, is_high_priority=is_high)
5✔
620

621
            except ResponseError as e:
5✔
622
                # XAUTOCLAIM requires Redis >= 6.2; log and continue if unavailable
623
                if "unknown command" in str(e).lower():
5✔
624
                    logger.warning(
5✔
625
                        "XAUTOCLAIM not available (requires Redis >= 6.2), skipping pending recovery for %s stream",
626
                        "high" if is_high else "low",
627
                    )
628
                else:
629
                    logger.exception("Error recovering pending messages from %s stream", stream_key)
5✔
630
            except Exception:
5✔
631
                logger.exception("Error recovering pending messages from %s stream", stream_key)
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc