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

Morry98 / fastapi-task-manager / 22851238314

09 Mar 2026 11:26AM UTC coverage: 97.615%. First build
22851238314

Pull #2

github

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

3196 of 3277 new or added lines in 32 files covered. (97.53%)

3356 of 3438 relevant lines covered (97.61%)

4.86 hits per line

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

95.0
/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
                # Decode message_id if bytes
307
                decoded_id = msg_id.decode("utf-8") if isinstance(msg_id, bytes) else msg_id
5✔
308
                return decoded_id, data
5✔
309

NEW
310
        return None
×
311

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

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

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

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

343
        Acquires the semaphore, extracts task information, finds the
344
        corresponding task definition, and executes it.
345

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

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

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

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

378
            await self._execute_and_ack(message_id, group_name, task, is_high_priority)
5✔
379

380
    def _decode_field(self, value: str | bytes) -> str:
5✔
381
        """Decode a field value from bytes to string if necessary.
382

383
        Args:
384
            value: The field value, potentially as bytes.
385

386
        Returns:
387
            The decoded string value.
388
        """
389
        if isinstance(value, bytes):
5✔
390
            return value.decode("utf-8")
5✔
391
        return value
5✔
392

393
    async def _execute_and_ack(
5✔
394
        self,
395
        message_id: str,
396
        group_name: str,
397
        task: Task,
398
        is_high_priority: bool,
399
    ) -> None:
400
        """Execute task with running heartbeat and acknowledge on completion.
401

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

406
        On success: records stats, removes tracking entries, ACKs the message.
407
        On failure: stops heartbeat, removes running key, does NOT ACK (message
408
        remains pending for reconciliation/retry).
409

410
        Args:
411
            message_id: The Redis message ID to acknowledge.
412
            group_name: The task group name for statistics recording.
413
            task: The Task to execute.
414
            is_high_priority: Whether this is from the high priority stream.
415
        """
416
        keys = self._keys.get_task_keys(group_name, task.name)
5✔
417
        config = self._task_manager.config
5✔
418
        task_id = f"{group_name}:{task.name}"
5✔
419
        running_key = self._keys.running_task_key(group_name, task.name)
5✔
420

421
        # Mark the task as running with a TTL derived from heartbeat interval
422
        running_ttl = math.ceil(config.running_heartbeat_interval * 3)
5✔
423
        await self._redis.set(
5✔
424
            running_key,
425
            self._worker.redis_safe_id,
426
            ex=running_ttl,
427
        )
428

429
        # Start the heartbeat loop in the background
430
        heartbeat_active = True
5✔
431

432
        async def _heartbeat() -> None:
5✔
433
            """Renew the running key at regular intervals while the task executes."""
434
            while heartbeat_active:
3✔
435
                await interruptible_sleep(config.running_heartbeat_interval, lambda: heartbeat_active)
3✔
NEW
436
                if heartbeat_active:
×
NEW
437
                    try:
×
NEW
438
                        await self._redis.set(
×
439
                            running_key,
440
                            self._worker.redis_safe_id,
441
                            ex=running_ttl,
442
                        )
NEW
443
                    except Exception:
×
NEW
444
                        logger.exception("Failed to renew running heartbeat for %s/%s", group_name, task.name)
×
445

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

448
        start = time.monotonic_ns()
5✔
449
        try:
5✔
450
            # Execute the task function
451
            if asyncio.iscoroutinefunction(task.function):
5✔
452
                await task.function(**(task.kwargs or {}))
5✔
453
            else:
454
                # Run sync functions in a thread to avoid blocking
455
                await asyncio.to_thread(task.function, **(task.kwargs or {}))
5✔
456

457
            end = time.monotonic_ns()
5✔
458

459
            # Stop heartbeat
460
            heartbeat_active = False
5✔
461
            heartbeat_task.cancel()
5✔
462
            with contextlib.suppress(asyncio.CancelledError):
5✔
463
                await heartbeat_task
5✔
464

465
            # Record execution statistics
466
            await self._statistics.record_execution(
5✔
467
                stream_key=keys.stats_stream,
468
                timestamp=datetime.now(timezone.utc).timestamp(),
469
                duration_seconds=(end - start) / 1e9,
470
            )
471

472
            # Clean up: remove running key and tracking entry
473
            await self._redis.delete(running_key)
5✔
474
            await self._redis.srem(self._keys.scheduled_set_key(), task_id)  # ty: ignore[invalid-await]
5✔
475

476
            # Reset backoff state on success (if any was active)
477
            await self._redis.delete(keys.retry_after, keys.retry_delay)
5✔
478

479
            # Acknowledge successful execution
480
            await self._ack_message(message_id, is_high_priority)
5✔
481

482
            logger.debug(
5✔
483
                "Task %s/%s completed successfully (%.3fs)",
484
                group_name,
485
                task.name,
486
                (end - start) / 1e9,
487
            )
488

489
        except Exception:
5✔
490
            logger.exception("Task %s/%s failed", group_name, task.name)
5✔
491

492
            # Stop heartbeat and remove running key
493
            heartbeat_active = False
5✔
494
            heartbeat_task.cancel()
5✔
495
            with contextlib.suppress(asyncio.CancelledError):
5✔
496
                await heartbeat_task
5✔
497
            await self._redis.delete(running_key)
5✔
498

499
            # Apply exponential backoff with cap
500
            await self._apply_backoff(group_name, task)
5✔
501

502
            # ACK the message — retry is managed by the backoff mechanism,
503
            # not by leaving the message pending in the stream.
504
            await self._ack_message(message_id, is_high_priority)
5✔
505

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

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

512
        Reads the current delay from Redis (or uses the initial value),
513
        sets a retry_after timestamp, and stores the next delay (capped).
514
        Both keys have a TTL as a safety net for cleanup.
515

516
        Args:
517
            group_name: The task group name.
518
            task: The Task that failed.
519
        """
520
        keys = self._keys.get_task_keys(group_name, task.name)
5✔
521
        config = self._task_manager.config
5✔
522

523
        # Resolve effective backoff values (per-task override or global config)
524
        initial = task.retry_backoff or config.retry_backoff
5✔
525
        max_delay = task.retry_backoff_max or config.retry_backoff_max
5✔
526
        multiplier = config.retry_backoff_multiplier
5✔
527
        # Safety net: retry state expires after 24 hours if no new failures occur
528
        ttl = 86_400
5✔
529

530
        # Read current delay from Redis, or start with initial value
531
        current_raw = await self._redis.get(keys.retry_delay)
5✔
532
        current = float(current_raw) if current_raw else initial
5✔
533

534
        # Set retry_after timestamp — Coordinator will skip scheduling until this expires
535
        retry_after = time.time() + current
5✔
536
        await self._redis.set(keys.retry_after, str(retry_after), ex=ttl)
5✔
537

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

542
        logger.warning(
5✔
543
            "Task %s/%s in backoff: retry after %.1fs (next delay: %.1fs, cap: %.1fs)",
544
            group_name,
545
            task.name,
546
            current,
547
            next_delay,
548
            max_delay,
549
        )
550

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

554
        Removes the message from the pending entries list for this consumer.
555
        Once acknowledged, the message won't be redelivered.
556

557
        Args:
558
            message_id: The Redis message ID to acknowledge.
559
            is_high_priority: Whether this is from the high priority stream.
560
        """
561
        stream_keys = self._keys.get_stream_keys()
5✔
562
        stream_key = stream_keys.task_stream_high if is_high_priority else stream_keys.task_stream_low
5✔
563

564
        await self._redis.xack(
5✔
565
            stream_key,
566
            stream_keys.consumer_group,
567
            message_id,
568
        )
569

570
        # Remove the message from the stream to keep memory usage low
571
        await self._redis.xdel(stream_key, message_id)
5✔
572

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

576
        Searches through all registered task groups to find the matching task.
577

578
        Args:
579
            group_name: The task group name.
580
            task_name: The task name.
581

582
        Returns:
583
            The Task if found, None otherwise.
584
        """
585
        for task_group in self._task_manager.task_groups:
5✔
586
            if task_group.name == group_name:
5✔
587
                for task in task_group.tasks:
5✔
588
                    if task.name == task_name:
5✔
589
                        return task
5✔
590
        return None
5✔
591

592
    async def _recover_pending_on_startup(self) -> None:
5✔
593
        """Recover pending messages from crashed consumers on startup.
594

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

599
        Reclaimed messages are spawned as tasks for this worker to process.
600
        This runs once at startup before the main consume loop begins.
601
        """
602
        stream_keys = self._keys.get_stream_keys()
5✔
603
        config = self._task_manager.config
5✔
604
        # Derived from running_heartbeat_interval: TTL is interval * 3,
605
        # and we wait 3x the TTL to be sure the heartbeat has expired
606
        min_idle = math.ceil(config.running_heartbeat_interval * 9) * 1000
5✔
607

608
        for stream_key, is_high in [
5✔
609
            (stream_keys.task_stream_high, True),
610
            (stream_keys.task_stream_low, False),
611
        ]:
612
            try:
5✔
613
                # XAUTOCLAIM returns: [next_start_id, [(msg_id, data), ...], [deleted_ids]]
614
                result = await self._redis.xautoclaim(
5✔
615
                    stream_key,
616
                    stream_keys.consumer_group,
617
                    self._worker.redis_safe_id,
618
                    min_idle_time=min_idle,
619
                    count=50,
620
                )
621

622
                if not result or not result[1]:
5✔
623
                    continue
5✔
624

625
                claimed_messages = result[1]
5✔
626
                priority_label = "high" if is_high else "low"
5✔
627
                logger.info(
5✔
628
                    "Recovered %d pending %s priority message(s) on startup",
629
                    len(claimed_messages),
630
                    priority_label,
631
                )
632

633
                for msg_id, data in claimed_messages:
5✔
634
                    decoded_id = msg_id.decode("utf-8") if isinstance(msg_id, bytes) else msg_id
5✔
635
                    self._spawn_task(decoded_id, data, is_high_priority=is_high)
5✔
636

637
            except ResponseError as e:
5✔
638
                # XAUTOCLAIM requires Redis >= 6.2; log and continue if unavailable
639
                if "unknown command" in str(e).lower():
5✔
640
                    logger.warning(
5✔
641
                        "XAUTOCLAIM not available (requires Redis >= 6.2), skipping pending recovery for %s stream",
642
                        "high" if is_high else "low",
643
                    )
644
                else:
645
                    logger.exception("Error recovering pending messages from %s stream", stream_key)
5✔
646
            except Exception:
5✔
647
                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