Skip to content

Commit 9d66437

Browse files
authored
Merge pull request #131 from alucero270/feature/91-index-job-retries
feat(indexer): add retry policy and retry accounting for failed ingestion jobs
2 parents f13d6a3 + b803cb1 commit 9d66437

9 files changed

Lines changed: 182 additions & 20 deletions

File tree

docs/api-surface-audit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ explicit.
2323
| `POST /api/search` | `SearchController` | search indexed documents | intended product-facing endpoint | Core retrieval API for the current Strata slice. |
2424
| `GET /api/documents/{id}` | `DocumentsController` | fetch a known document | intended product-facing endpoint | Supports document viewing after search or direct lookup. |
2525
| `POST /api/index-jobs` | `IndexJobsController` | create indexing work | intended product-facing endpoint | Current request shape is intentionally minimal for the early product slice. |
26-
| `GET /api/index-jobs/{id}` | `IndexJobsController` | read indexing job status | intended product-facing endpoint | Supports current operational verification of indexing flow. |
26+
| `GET /api/index-jobs/{id}` | `IndexJobsController` | read indexing job status | intended product-facing endpoint | Supports current operational verification of indexing flow, including retry accounting. |
2727
| `GET /openapi/v1.json` | `MapOpenApi()` in `Program.cs` | development-time API description | development-only support endpoint | Present only in Development; useful for inspection, not part of the stable product contract. |
2828

2929
## Non-Product Or Limited-Scope Surface

docs/api.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ Create a new indexing job.
8787

8888
Returns `201 Created` with the created job payload.
8989

90+
### Behavior
91+
92+
- retry policy is server-controlled for the current product slice
93+
- each job currently allows up to `3` total processing attempts
94+
- jobs return retry accounting metadata so operators can tell whether a
95+
processing failure was the first attempt or a later retry
96+
9097
## `GET /api/index-jobs/{id}`
9198

9299
Fetch the current status of an indexing job.
@@ -100,12 +107,21 @@ Fetch the current status of an indexing job.
100107
"requestedAt": "2026-04-10T08:20:00Z",
101108
"claimedAt": "2026-04-10T08:20:02Z",
102109
"completedAt": "2026-04-10T08:20:05Z",
110+
"attemptCount": 1,
111+
"maxAttempts": 3,
103112
"workerId": "host:1234:abcd",
104113
"errorMessage": null,
105114
"stats": null
106115
}
107116
```
108117

118+
### Retry Semantics
119+
120+
- `attemptCount` increments each time a worker claims the job for processing
121+
- a failed attempt returns the job to `pending` while `attemptCount` is still
122+
below `maxAttempts`
123+
- a job becomes terminally `failed` only when the final allowed attempt fails
124+
109125
## Notes
110126

111127
- Use `/health` for basic readiness checks and product verification

docs/data-model.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ Stores indexing work items claimed by background workers.
3737
- `requested_at`: job creation timestamp
3838
- `claimed_at`: timestamp recorded when a worker claims the job
3939
- `completed_at`: timestamp recorded on successful completion
40+
- `attempt_count`: number of processing attempts claimed so far
41+
- `max_attempts`: server-controlled retry ceiling for the job
4042
- `worker_id`: worker identifier for claimed jobs
4143
- `error_message`: truncated failure detail when processing fails
4244

4345
### Notes
4446

4547
- `ix_index_jobs_status_requested_at_id` supports pending-job polling
4648
- Job creation is API-driven; claiming and completion are background operations
49+
- failed attempts return to `pending` while `attempt_count < max_attempts`
50+
- terminal `failed` state means the last allowed attempt has already been used
4751

4852
## Configured Source Model
4953

docs/operations.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ Get-Content -Raw ops/migrations/002_documents.sql |
9595
Get-Content -Raw ops/migrations/003_jobs.sql |
9696
docker compose -f ops/docker-compose.yml --env-file .env exec -T postgres `
9797
psql -v ON_ERROR_STOP=1 -U strata -d strata
98+
Get-Content -Raw ops/migrations/004_job_retries.sql |
99+
docker compose -f ops/docker-compose.yml --env-file .env exec -T postgres `
100+
psql -v ON_ERROR_STOP=1 -U strata -d strata
98101
```
99102

100103
If you change the PostgreSQL credentials in `.env`, update the `psql` arguments
@@ -188,6 +191,16 @@ Invoke-WebRequest -Uri http://localhost:8080/api/index-jobs -Method Post `
188191
-ContentType "application/json" -Body "{}"
189192
```
190193

194+
Verify index-job retry state:
195+
196+
- poll `GET /api/index-jobs/{id}` after creating the job
197+
- expect `attemptCount` to increment each time the worker claims the job
198+
- if an attempt fails and retries remain, expect the job to return to `pending`
199+
instead of remaining terminal immediately
200+
- expect a terminal `failed` job to report `attemptCount == maxAttempts`
201+
- the current retry policy is server-controlled and allows up to `3` total
202+
attempts per job
203+
191204
Platform readiness validation:
192205

193206
```powershell

ops/migrations/004_job_retries.sql

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
-- Persist retry accounting so ingestion jobs can retry predictably.
2+
ALTER TABLE index_jobs
3+
ADD COLUMN IF NOT EXISTS attempt_count INTEGER NOT NULL DEFAULT 0,
4+
ADD COLUMN IF NOT EXISTS max_attempts INTEGER NOT NULL DEFAULT 3;
5+
6+
DO $$
7+
BEGIN
8+
IF NOT EXISTS (
9+
SELECT 1
10+
FROM pg_constraint
11+
WHERE conname = 'ck_index_jobs_attempt_count_non_negative'
12+
) THEN
13+
ALTER TABLE index_jobs
14+
ADD CONSTRAINT ck_index_jobs_attempt_count_non_negative
15+
CHECK (attempt_count >= 0);
16+
END IF;
17+
END
18+
$$;
19+
20+
DO $$
21+
BEGIN
22+
IF NOT EXISTS (
23+
SELECT 1
24+
FROM pg_constraint
25+
WHERE conname = 'ck_index_jobs_max_attempts_positive'
26+
) THEN
27+
ALTER TABLE index_jobs
28+
ADD CONSTRAINT ck_index_jobs_max_attempts_positive
29+
CHECK (max_attempts >= 1);
30+
END IF;
31+
END
32+
$$;
33+
34+
DO $$
35+
BEGIN
36+
IF NOT EXISTS (
37+
SELECT 1
38+
FROM pg_constraint
39+
WHERE conname = 'ck_index_jobs_attempt_count_within_limit'
40+
) THEN
41+
ALTER TABLE index_jobs
42+
ADD CONSTRAINT ck_index_jobs_attempt_count_within_limit
43+
CHECK (attempt_count <= max_attempts);
44+
END IF;
45+
END
46+
$$;

src/Codex.Api/Data/IndexJobsStore.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,24 @@ namespace Codex.Api.Data;
66

77
public sealed class IndexJobsStore(NpgsqlDataSource dataSource, CodexSettings settings)
88
{
9+
private const int DefaultMaxAttempts = 3;
10+
911
// Current schema has no root_path column. Reference docs_root in the insert path
1012
// so job creation still depends on server-side configuration.
1113
private const string InsertJobSql = """
1214
WITH configured_root AS (
1315
SELECT @docs_root::text AS docs_root
1416
)
15-
INSERT INTO index_jobs (status)
16-
SELECT @status
17+
INSERT INTO index_jobs (status, max_attempts)
18+
SELECT @status, @max_attempts
1719
FROM configured_root
18-
RETURNING id, status, requested_at, claimed_at, completed_at, worker_id, error_message;
20+
RETURNING id, status, requested_at, claimed_at, completed_at, attempt_count,
21+
max_attempts, worker_id, error_message;
1922
""";
2023

2124
private const string SelectJobByIdSql = """
22-
SELECT id, status, requested_at, claimed_at, completed_at, worker_id, error_message
25+
SELECT id, status, requested_at, claimed_at, completed_at, attempt_count,
26+
max_attempts, worker_id, error_message
2327
FROM index_jobs
2428
WHERE id = @id;
2529
""";
@@ -29,6 +33,8 @@ public async Task<IndexJobResponse> CreatePendingJobAsync(CancellationToken canc
2933
await using var command = dataSource.CreateCommand(InsertJobSql);
3034
command.Parameters.AddWithValue("docs_root", settings.DocsRoot);
3135
command.Parameters.AddWithValue("status", "pending");
36+
// Retry policy remains server-controlled for the current product slice.
37+
command.Parameters.AddWithValue("max_attempts", DefaultMaxAttempts);
3238

3339
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
3440
if (!await reader.ReadAsync(cancellationToken))
@@ -60,6 +66,8 @@ private static IndexJobResponse Map(NpgsqlDataReader reader)
6066
{
6167
var claimedAtOrdinal = reader.GetOrdinal("claimed_at");
6268
var completedAtOrdinal = reader.GetOrdinal("completed_at");
69+
var attemptCountOrdinal = reader.GetOrdinal("attempt_count");
70+
var maxAttemptsOrdinal = reader.GetOrdinal("max_attempts");
6371
var workerIdOrdinal = reader.GetOrdinal("worker_id");
6472
var errorMessageOrdinal = reader.GetOrdinal("error_message");
6573

@@ -73,6 +81,8 @@ private static IndexJobResponse Map(NpgsqlDataReader reader)
7381
CompletedAt: reader.IsDBNull(completedAtOrdinal)
7482
? null
7583
: reader.GetDateTime(completedAtOrdinal),
84+
AttemptCount: reader.GetInt32(attemptCountOrdinal),
85+
MaxAttempts: reader.GetInt32(maxAttemptsOrdinal),
7686
WorkerId: reader.IsDBNull(workerIdOrdinal)
7787
? null
7888
: reader.GetString(workerIdOrdinal),

src/Codex.Contracts/IndexJobs/IndexJobResponse.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ public sealed record IndexJobResponse(
66
DateTime RequestedAt,
77
DateTime? ClaimedAt,
88
DateTime? CompletedAt,
9+
int AttemptCount,
10+
int MaxAttempts,
911
string? WorkerId,
1012
string? ErrorMessage,
1113
object? Stats);

src/Codex.Indexer/Data/IndexJobsStore.cs

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22

33
namespace Codex.Indexer.Data;
44

5-
public sealed record ClaimedIndexJob(long Id);
5+
public sealed record ClaimedIndexJob(long Id, int AttemptCount, int MaxAttempts);
6+
7+
public sealed record JobFailureDisposition(
8+
long Id,
9+
string Status,
10+
int AttemptCount,
11+
int MaxAttempts)
12+
{
13+
public bool WillRetry => string.Equals(Status, "pending", StringComparison.Ordinal);
14+
}
615

716
public sealed class IndexJobsStore(NpgsqlDataSource dataSource)
817
{
@@ -19,11 +28,13 @@ LIMIT 1
1928
UPDATE index_jobs AS jobs
2029
SET status = 'processing',
2130
claimed_at = NOW(),
31+
completed_at = NULL,
2232
worker_id = @worker_id,
23-
error_message = NULL
33+
error_message = NULL,
34+
attempt_count = jobs.attempt_count + 1
2435
FROM next_job
2536
WHERE jobs.id = next_job.id
26-
RETURNING jobs.id;
37+
RETURNING jobs.id, jobs.attempt_count, jobs.max_attempts;
2738
""";
2839

2940
private const string MarkJobCompletedSql = """
@@ -34,12 +45,27 @@ UPDATE index_jobs
3445
WHERE id = @id;
3546
""";
3647

37-
private const string MarkJobFailedSql = """
48+
private const string RecordJobFailureSql = """
3849
UPDATE index_jobs
39-
SET status = 'failed',
40-
completed_at = NOW(),
50+
SET status = CASE
51+
WHEN attempt_count < max_attempts THEN 'pending'
52+
ELSE 'failed'
53+
END,
54+
claimed_at = CASE
55+
WHEN attempt_count < max_attempts THEN NULL
56+
ELSE claimed_at
57+
END,
58+
completed_at = CASE
59+
WHEN attempt_count < max_attempts THEN NULL
60+
ELSE NOW()
61+
END,
62+
worker_id = CASE
63+
WHEN attempt_count < max_attempts THEN NULL
64+
ELSE worker_id
65+
END,
4166
error_message = @error_message
42-
WHERE id = @id;
67+
WHERE id = @id
68+
RETURNING id, status, attempt_count, max_attempts;
4369
""";
4470

4571
public async Task<ClaimedIndexJob?> ClaimNextPendingJobAsync(
@@ -57,7 +83,10 @@ UPDATE index_jobs
5783
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
5884
{
5985
claimedJob = await reader.ReadAsync(cancellationToken)
60-
? new ClaimedIndexJob(reader.GetInt64(0))
86+
? new ClaimedIndexJob(
87+
reader.GetInt64(0),
88+
reader.GetInt32(1),
89+
reader.GetInt32(2))
6190
: null;
6291
}
6392

@@ -72,14 +101,25 @@ public async Task MarkJobCompletedAsync(long id, CancellationToken cancellationT
72101
await command.ExecuteNonQueryAsync(cancellationToken);
73102
}
74103

75-
public async Task MarkJobFailedAsync(
104+
public async Task<JobFailureDisposition> RecordJobFailureAsync(
76105
long id,
77106
string errorMessage,
78107
CancellationToken cancellationToken)
79108
{
80-
await using var command = dataSource.CreateCommand(MarkJobFailedSql);
109+
await using var command = dataSource.CreateCommand(RecordJobFailureSql);
81110
command.Parameters.AddWithValue("id", id);
82111
command.Parameters.AddWithValue("error_message", errorMessage);
83-
await command.ExecuteNonQueryAsync(cancellationToken);
112+
113+
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
114+
if (!await reader.ReadAsync(cancellationToken))
115+
{
116+
throw new InvalidOperationException($"Failed to record failure for job {id}.");
117+
}
118+
119+
return new JobFailureDisposition(
120+
reader.GetInt64(0),
121+
reader.GetString(1),
122+
reader.GetInt32(2),
123+
reader.GetInt32(3));
84124
}
85125
}

src/Codex.Indexer/Worker.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,21 @@ private async Task PollOnceAsync(CancellationToken cancellationToken)
5656
}
5757

5858
// Claiming commits in the store before this point, so processing is lock-free.
59-
logger.LogInformation("Claimed index job {JobId}", claimedJob.Id);
59+
logger.LogInformation(
60+
"Claimed index job {JobId} (attempt {AttemptCount}/{MaxAttempts})",
61+
claimedJob.Id,
62+
claimedJob.AttemptCount,
63+
claimedJob.MaxAttempts);
6064

6165
try
6266
{
6367
await ProcessClaimedJobAsync(claimedJob, documentsStore, cancellationToken);
6468
await indexJobsStore.MarkJobCompletedAsync(claimedJob.Id, cancellationToken);
65-
logger.LogInformation("Completed index job {JobId}", claimedJob.Id);
69+
logger.LogInformation(
70+
"Completed index job {JobId} on attempt {AttemptCount}/{MaxAttempts}",
71+
claimedJob.Id,
72+
claimedJob.AttemptCount,
73+
claimedJob.MaxAttempts);
6674
}
6775
catch (Exception ex)
6876
{
@@ -73,8 +81,31 @@ private async Task PollOnceAsync(CancellationToken cancellationToken)
7381
errorMessage = errorMessage[..1000];
7482
}
7583

76-
await indexJobsStore.MarkJobFailedAsync(claimedJob.Id, errorMessage, cancellationToken);
77-
logger.LogError(ex, "Failed index job {JobId}", claimedJob.Id);
84+
var failureDisposition =
85+
await indexJobsStore.RecordJobFailureAsync(
86+
claimedJob.Id,
87+
errorMessage,
88+
cancellationToken);
89+
90+
if (failureDisposition.WillRetry)
91+
{
92+
logger.LogWarning(
93+
ex,
94+
"Index job {JobId} failed on attempt {AttemptCount}/{MaxAttempts}; " +
95+
"returned to pending for retry.",
96+
claimedJob.Id,
97+
failureDisposition.AttemptCount,
98+
failureDisposition.MaxAttempts);
99+
}
100+
else
101+
{
102+
logger.LogError(
103+
ex,
104+
"Index job {JobId} failed on final attempt {AttemptCount}/{MaxAttempts}",
105+
claimedJob.Id,
106+
failureDisposition.AttemptCount,
107+
failureDisposition.MaxAttempts);
108+
}
78109
}
79110
}
80111

0 commit comments

Comments
 (0)