Skip to content

Feature: Add recover pending for redis stream_queue - #14

Open
Haruko386 wants to merge 2 commits into
CeruleanFlow:mainfrom
Haruko386:main
Open

Feature: Add recover pending for redis stream_queue#14
Haruko386 wants to merge 2 commits into
CeruleanFlow:mainfrom
Haruko386:main

Conversation

@Haruko386

@Haruko386 Haruko386 commented Jul 11, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

Related to #5

  • New Features

    • Workers now automatically recover eligible pending jobs, improving resilience after interruptions.
    • Added configuration for job timeouts, recovery intervals, idle thresholds, and batch sizes.
  • Bug Fixes

    • Completed paper ingestion and reindexing tasks are no longer processed again.
    • Queue message decoding now reports malformed or invalid messages instead of silently skipping them.
  • Documentation

    • Improved documentation for worker, ingestion, parsing, and job-processing functionality.

@Haruko386 Haruko386 added this to the v1.0 milestone Jul 11, 2026
@Haruko386 Haruko386 self-assigned this Jul 11, 2026
@Haruko386 Haruko386 added the 🧩feature New feature for the project label Jul 11, 2026
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Worker configuration now supports claim timing and batch settings. Redis streams can claim idle pending messages, and workers recover them periodically. Ingest and reindex skip already succeeded tasks, while several exported and helper methods receive documentation comments.

Changes

Worker recovery and processing

Layer / File(s) Summary
Redis pending-message claiming
internal/queue/queue.go, internal/queue/redis_stream.go
The queue contract and Redis implementation add pending-message claiming, shared payload decoding, input validation, and decoding errors.
Worker claim configuration and recovery
internal/config/config.go, cmd/worker/main.go, internal/executor/...
Duration-based claim settings and batch size are loaded and passed to workers, which periodically claim and process pending messages.
Succeeded-task processing guards and documentation
internal/ingest/service.go, internal/pipeline/...
Ingest and reindex return immediately for succeeded jobs, and processing methods receive documentation comments.
Parser documentation
internal/parser/...
Chunking and PDF parsing helpers receive updated GoDoc comments without logic changes.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant RedisStreamQueue
  participant handleBatch
  Worker->>RedisStreamQueue: ClaimPending with cursor, idle threshold, and batch size
  RedisStreamQueue-->>Worker: Claimed messages and next cursor
  Worker->>handleBatch: Process claimed messages
  handleBatch-->>Worker: Processing result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: adding pending-job recovery for the Redis stream queue.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/worker/main.go (1)

87-111: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

cfg.WorkerJobTimeout is logged but never actually applied — JobTimeout stays hardcoded.

WorkerOptions.JobTimeout is set to a literal 30 * time.Minute, ignoring the newly-added cfg.WorkerJobTimeout (sourced from CERULEAN_WORKER_JOB_TIMEOUT via envDuration in config.go). The startup log at Line 109 then prints cfg.WorkerJobTimeout, giving the false impression that this value governs job execution timeouts — an operator who sets the env var will see it reflected in the log but observe no actual change in behavior.

🐛 Proposed fix
 	worker, err := executor.NewWorker(q, registry, executor.WorkerOptions{
 		BatchSize:   cfg.WorkerBatchSize,
 		BlockMillis: 5000,
-		JobTimeout:  30 * time.Minute,
+		JobTimeout:  cfg.WorkerJobTimeout,
 		Concurrency: cfg.WorkerConcurrency,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/worker/main.go` around lines 87 - 111, Update the WorkerOptions
initialization in the executor.NewWorker call to assign JobTimeout from
cfg.WorkerJobTimeout instead of the hardcoded 30-minute duration, keeping the
existing startup log consistent with the applied configuration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/executor/worker.go`:
- Around line 122-141: Initialize lastClaimAt so the first loop iteration
satisfies the recovery condition, allowing recoverPending to run immediately
when the worker starts. Update the initialization adjacent to the worker loop
while preserving the existing periodic claimInterval scheduling and error
handling.

In `@internal/ingest/service.go`:
- Around line 379-381: Correct the call to ProcessPaperIngest in the paper
ingest pipeline to pass arguments in its declared order: context, paperID, then
taskID. Keep the reindex handler’s existing argument order unchanged so normal
ingest and recovery resolve the intended paper and task before relying on the
succeeded-status guard.

In `@internal/queue/redis_stream.go`:
- Around line 150-152: Update the minIdle validation in the surrounding public
queue method to use a non-positive check, so minIdle == 0 receives the existing
35-minute default just like negative values; leave the neighboring max guard and
other behavior unchanged.
- Around line 132-138: Update decodeRedisMessages and its
DequeueBatch/ClaimPending callers to decode messages best-effort: retain
successfully decoded entries while handling malformed payloads individually, and
ensure failures cannot prevent cursor advancement or block later messages.
Preserve terminal-error handling by logging/reporting each invalid entry, or
explicitly acknowledging entries that cannot be processed.
- Around line 193-196: Implement failure handling in RedisStreamQueue.Nack:
increment and persist the job’s attempt count, enforce a retry limit, and route
messages exceeding that limit to the existing dead-letter path instead of
leaving them eligible for recoverPending indefinitely. Use the Job.Attempt field
and existing queue acknowledgment/dead-letter symbols, preserving normal retry
behavior below the cap.

---

Outside diff comments:
In `@cmd/worker/main.go`:
- Around line 87-111: Update the WorkerOptions initialization in the
executor.NewWorker call to assign JobTimeout from cfg.WorkerJobTimeout instead
of the hardcoded 30-minute duration, keeping the existing startup log consistent
with the applied configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d102b46-1d14-4dc2-b7fb-0d0ce0c0d1d6

📥 Commits

Reviewing files that changed from the base of the PR and between da5a668 and 30c7111.

📒 Files selected for processing (11)
  • cmd/worker/main.go
  • internal/config/config.go
  • internal/executor/registry.go
  • internal/executor/worker.go
  • internal/ingest/service.go
  • internal/parser/chunker.go
  • internal/parser/pdf_text.go
  • internal/pipeline/paper_ingest.go
  • internal/pipeline/paper_reindex.go
  • internal/queue/queue.go
  • internal/queue/redis_stream.go

Comment on lines +122 to +141
lastClaimAt := time.Now()

for {
select {
case <-ctx.Done():
log.Println("executor worker stopping")
return ctx.Err()
default:
}

if lastClaimAt.IsZero() || time.Since(lastClaimAt) >= w.claimInterval {
if err := w.recoverPending(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}

log.Printf("recover pending jobs failed: %v", err)
}
lastClaimAt = time.Now()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

First pending-recovery pass is delayed by a full claimInterval after startup.

lastClaimAt := time.Now() is set immediately before the loop, so time.Since(lastClaimAt) >= w.claimInterval is false on the first iteration and recoverPending is skipped until an interval has elapsed. Since a core goal of this feature is recovering jobs left pending by a previously-crashed worker, delaying that first recovery attempt (default 1 minute, configurable) seems counter to intent.

♻️ Proposed fix
-	lastClaimAt := time.Now()
+	var lastClaimAt time.Time
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
lastClaimAt := time.Now()
for {
select {
case <-ctx.Done():
log.Println("executor worker stopping")
return ctx.Err()
default:
}
if lastClaimAt.IsZero() || time.Since(lastClaimAt) >= w.claimInterval {
if err := w.recoverPending(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
log.Printf("recover pending jobs failed: %v", err)
}
lastClaimAt = time.Now()
}
var lastClaimAt time.Time
for {
select {
case <-ctx.Done():
log.Println("executor worker stopping")
return ctx.Err()
default:
}
if lastClaimAt.IsZero() || time.Since(lastClaimAt) >= w.claimInterval {
if err := w.recoverPending(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
log.Printf("recover pending jobs failed: %v", err)
}
lastClaimAt = time.Now()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/executor/worker.go` around lines 122 - 141, Initialize lastClaimAt
so the first loop iteration satisfies the recovery condition, allowing
recoverPending to run immediately when the worker starts. Update the
initialization adjacent to the worker loop while preserving the existing
periodic claimInterval scheduling and error handling.

Comment on lines +379 to +381
if job.Status == task.Succeeded {
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fix the ingest caller’s argument order before relying on this guard.

ProcessPaperIngest expects (ctx, paperID, taskID), but Line 37 of internal/pipeline/paper_ingest.go passes (ctx, taskID, paperID); the reindex handler uses the correct order. Normal ingest and recovery therefore look up the wrong task and paper, preventing this succeeded-task guard from applying to the intended job.

Proposed fix
-	return h.ingest.ProcessPaperIngest(ctx, taskID, paperID)
+	return h.ingest.ProcessPaperIngest(ctx, paperID, taskID)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ingest/service.go` around lines 379 - 381, Correct the call to
ProcessPaperIngest in the paper ingest pipeline to pass arguments in its
declared order: context, paperID, then taskID. Keep the reindex handler’s
existing argument order unchanged so normal ingest and recovery resolve the
intended paper and task before relying on the succeeded-status guard.

Comment on lines +132 to +138
redisMessages := make([]redis.XMessage, 0)

for _, stream := range streams {
for _, redisMsg := range stream.Messages {
raw, ok := redisMsg.Values["payload"].(string)
if !ok {
continue
}

var job Job
if err := json.Unmarshal([]byte(raw), &job); err != nil {
continue
}

messages = append(messages, Message{
RedisID: redisMsg.ID,
Job: job,
})
redisMessages = append(redisMessages, stream.Messages...)
}
return decodeRedisMessages(redisMessages)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Single malformed payload discards the whole decoded batch.

decodeRedisMessages returns nil, err on the first bad payload, discarding any already-decoded messages in that batch for the claimed entries as an array along with a stream ID intended for cursor-like use as the start argument for its subsequent call. Per the change details, this replaces prior behavior that skip invalid payloads/unmarshal errors.

Concretely:

  • In DequeueBatch, one bad message causes the whole read batch to be dropped from processing (messages stay unacked in the PEL, delayed but not lost).
  • In ClaimPending, since the cursor (nextStart) is only advanced by the caller when err == nil (see worker.go recoverPending), a poison message at the head of a claimed batch causes recoverPending to fail every cycle without ever advancing past it — and since XAUTOCLAIM resets idle time on claim, the message won't be eligible for reclaim again until minIdle elapses, but it will keep resurfacing at the same cursor position, permanently blocking the batch behind it from being recovered.

Consider decoding messages best-effort (collecting successfully-decoded messages and reporting/logging failures individually, or explicitly acking terminally-bad entries) instead of failing the entire batch.

Also applies to: 158-177, 206-238

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/queue/redis_stream.go` around lines 132 - 138, Update
decodeRedisMessages and its DequeueBatch/ClaimPending callers to decode messages
best-effort: retain successfully decoded entries while handling malformed
payloads individually, and ensure failures cannot prevent cursor advancement or
block later messages. Preserve terminal-error handling by logging/reporting each
invalid entry, or explicitly acknowledging entries that cannot be processed.

Comment on lines +150 to +152
if minIdle < 0 {
minIdle = 35 * time.Minute
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

minIdle guard should use <= 0 for consistency with the max guard.

if minIdle < 0 {
    minIdle = 35 * time.Minute
}

A caller passing minIdle == 0 slips through un-defaulted (unlike max, guarded with <= 0 two lines below), and 0 means "claim regardless of idle time," which would let this consumer steal messages that another consumer is still actively processing. Even though the current in-repo caller (Worker.recoverPending) always supplies a validated non-zero value, this is a public interface method and the asymmetric guard is a latent footgun.

🛡️ Proposed fix
-	if minIdle < 0 {
+	if minIdle <= 0 {
 		minIdle = 35 * time.Minute
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/queue/redis_stream.go` around lines 150 - 152, Update the minIdle
validation in the surrounding public queue method to use a non-positive check,
so minIdle == 0 receives the existing 35-minute default just like negative
values; leave the neighboring max guard and other behavior unchanged.

Comment on lines +193 to 196
// Nack handles a failed Redis message.
func (q *RedisStreamQueue) Nack(ctx context.Context, msg Message, reason error) error {
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether Job.Attempt is read/incremented anywhere in the codebase
rg -n '\.Attempt\b' --type=go

Repository: CeruleanFlow/Cerulean

Length of output: 159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant queue implementation and job type.
git ls-files | rg '^(internal/queue/redis_stream\.go|internal/queue/.*\.go)$' || true

echo '--- redis_stream.go around Nack / recoverPending / ClaimPending ---'
nl -ba internal/queue/redis_stream.go | sed -n '1,320p'

echo '--- Job definition and attempt usage across queue package ---'
rg -n 'type Job|Attempt|ClaimPending|recoverPending|Nack\(' internal/queue --type=go

Repository: CeruleanFlow/Cerulean

Length of output: 344


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any tests or docs that describe retry/attempt semantics for this queue.
rg -n 'retry|dead[- ]letter|deadletter|Attempt|recoverPending|claimInterval|claimMinIdle' . --type=go --type=md --type=yaml --type=toml

Repository: CeruleanFlow/Cerulean

Length of output: 1823


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the queue package symbols before reading smaller slices if needed.
ast-grep outline internal/queue/redis_stream.go --view expanded || true

Repository: CeruleanFlow/Cerulean

Length of output: 1405


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- internal/queue/job.go ---'
sed -n '1,120p' internal/queue/job.go

echo '--- internal/executor/worker.go around processing and recoverPending ---'
sed -n '220,320p' internal/executor/worker.go

echo '--- internal/queue/redis_stream.go around ClaimPending/Ack/Nack ---'
sed -n '130,210p' internal/queue/redis_stream.go

Repository: CeruleanFlow/Cerulean

Length of output: 4976


Add a retry cap or dead-letter path for repeated failures. Nack is still a no-op, and Job.Attempt is never incremented or checked anywhere, so a job that keeps failing will be reclaimed by recoverPending forever and keep burning worker capacity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/queue/redis_stream.go` around lines 193 - 196, Implement failure
handling in RedisStreamQueue.Nack: increment and persist the job’s attempt
count, enforce a retry limit, and route messages exceeding that limit to the
existing dead-letter path instead of leaving them eligible for recoverPending
indefinitely. Use the Job.Attempt field and existing queue
acknowledgment/dead-letter symbols, preserving normal retry behavior below the
cap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🧩feature New feature for the project

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant