typescript-backend-generation-queue-persistence
Root cause. POST /characters/:id/portrait called characterService.generatePortrait — a legacy placeholder that returned a job id but never wrote to the generation queue the status endpoint reads. Every 202-ack advertised a jobId the queue store had never seen, so GET /generations/:jobId consistently 404'd.
Fix. Wire the route to the queue-tracked GenerationService.generatePortrait, which durably persists the job row before returning the 202 ack (eliminating the false-404 race), then hands it to the worker. Terminal jobs are retained in the store, so they stay queryable after the worker finishes.
// src/http/routes.ts — FIXED
export function makeRoutes(generationService: GenerationService) {
return {
// POST /characters/:id/portrait -> 202 Accepted
async startGeneration(req: Req): Promise<HttpResponse> {
const size = (req.body as { size?: string } | undefined)?.size;
const { jobId, status } = await generationService.generatePortrait(req.params.id, { size });
return { status: 202, body: { jobId, status, self: `/generations/${jobId}` } };
},
// GET /generations/:jobId -> reads the queue-tracked job
async jobStatus(req: Req): Promise<HttpResponse> {
const job = await generationService.getStatus(req.params.jobId);
if (!job) return { status: 404, body: { error: 'job_not_found', jobId: req.params.jobId } };
return { status: 200, body: { jobId: job.id, status: job.status, error: job.error ?? null, resultUrl: job.resultUrl ?? null } };
},
};
}
// src/generation.service.ts — the queue-tracked entry point (route's ONLY enqueue path)
export class GenerationService {
async generatePortrait(characterId: string, opts: { size?: string } = {}): Promise<{ jobId: string; status: 'queued' }> {
const job: GenerationJob = { id: randomUUID(), characterId, status: 'queued', createdAt: Date.now(), updatedAt: Date.now() };
await this.store.create(job); // durable BEFORE ack -> no false 404
void this.worker(job.id, opts); // async queue processing
return { jobId: job.id, status: 'queued' };
}
async getStatus(jobId: string): Promise<GenerationJob | null> { return this.store.get(jobId); }
private async worker(jobId: string): Promise<void> {
const job = await this.store.get(jobId);
if (!job) return;
await this.store.updateStatus(jobId, 'generating', { startedAt: Date.now() });
const provider = await this.lookupProvider(); // async registry lookup
if (!provider) { // DEMO: zero providers
await this.store.updateStatus(jobId, 'failed', { // -> terminal `failed`, job retained
finishedAt: Date.now(), error: `No image providers configured (${this.mode} mode)` });
return;
}
try {
const url = await provider.generate(job.characterId);
await this.store.updateStatus(jobId, 'succeeded', { finishedAt: Date.now(), resultUrl: url });
} catch (err) {
await this.store.updateStatus(jobId, 'failed', { finishedAt: Date.now(), error: String(err) });
}
}
}
The store (JobStore) is the persistence contract: create / updateStatus / get, backed by SQLite in production. Terminal rows are never deleted — that is the specific property that makes failed queryable.
Built an executable harness at `/tmp/queue-fix` (Node 22, TS strip-types) and ran it 3× — 21/21 assertions pass, stable.
**Lifecycle test (DEMO mode, zero providers)** — the required proof:
- POST → **202** `{ jobId, status: 'queued', self: '/generations/<id>' }`
- Job row exists in store **at ack time** (durability before ack → no false-404 race)
- Poll GET status → samples `generating` → terminal `failed` with reason `No image providers configured (demo mode)`
- **Zero 404s** during the full poll
- Terminal job re-queried → 200 `failed` (retained, not deleted)
- New service instance over the same store ("restart") → still 200 `failed`
**Bug regression reproduced first:** the placeholder route 202'd, then status 404'd on the same id, with zero rows in the store — exactly the reported failure.
**Live E2E trace** (client-facing, DEMO mode):
```
POST /characters/42/portrait -> 202 {"jobId":"b6a0e19e…","status":"queued","self":"/generations/…"}
GET /generations/b6a0e19e… -> 200 {"status":"generating",…} (x5)
GET /generations/b6a0e19e… -> 200 {"status":"failed","error":"No image providers configured (demo mode)"}
LIFECYCLE: queued -> generating -> failed — zero 404s
```
**Edge cases tested:** unknown jobId → genuine 404 `job_not_found` (correct, not a false 404); 3 concurrent POSTs → distinct ids, each reaches `failed` with zero 404s; live mode with a provider → `generating → succeeded` + `resultUrl`; provider throwing → `failed` with error, still queryable.{"model": "deepseek-v4-flash", "problem_class": "typescript-backend-generation-queue-persistence", "result": "passed", "tests": 21}