feat: bound pricing job retries
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-09 18:02:35 -04:00
parent 22d13af604
commit b58b84a81e
7 changed files with 65 additions and 10 deletions
@@ -321,6 +321,8 @@ export const pricingRefreshJobs = pgTable('pricing_refresh_jobs', {
conditionId: uuid('condition_id').notNull().references(() => conditions.id), conditionId: uuid('condition_id').notNull().references(() => conditions.id),
providerId: text('provider_id').notNull(), providerId: text('provider_id').notNull(),
status: text('status').default('PENDING').notNull(), status: text('status').default('PENDING').notNull(),
attemptCount: integer('attempt_count').default(0).notNull(),
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }),
observations: jsonb('observations').default([]).notNull(), observations: jsonb('observations').default([]).notNull(),
valuation: jsonb('valuation'), valuation: jsonb('valuation'),
error: text('error'), error: text('error'),
@@ -1,8 +1,9 @@
import { and, asc, desc, eq } from 'drizzle-orm'; import { and, asc, desc, eq, isNull, lte, or } from 'drizzle-orm';
import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js'; import { pricingRefreshJobs } from '../../../infrastructure/database/schema.js';
export function createPricingRefreshJobRepository(db) { export function createPricingRefreshJobRepository(db) {
const maxAttempts = 3;
return { return {
async create(job) { async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning(); const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
@@ -12,13 +13,16 @@ export function createPricingRefreshJobRepository(db) {
async claimNext() { async claimNext() {
return db.transaction(async (transaction) => { return db.transaction(async (transaction) => {
const [job] = await transaction.select().from(pricingRefreshJobs) const [job] = await transaction.select().from(pricingRefreshJobs)
.where(eq(pricingRefreshJobs.status, 'PENDING')) .where(and(
eq(pricingRefreshJobs.status, 'PENDING'),
or(isNull(pricingRefreshJobs.nextAttemptAt), lte(pricingRefreshJobs.nextAttemptAt, new Date())),
))
.orderBy(asc(pricingRefreshJobs.createdAt)) .orderBy(asc(pricingRefreshJobs.createdAt))
.limit(1) .limit(1)
.for('update', { skipLocked: true }); .for('update', { skipLocked: true });
if (!job) return null; if (!job) return null;
const [claimed] = await transaction.update(pricingRefreshJobs) const [claimed] = await transaction.update(pricingRefreshJobs)
.set({ status: 'PROCESSING', updatedAt: new Date(), error: null }) .set({ status: 'PROCESSING', attemptCount: pricingRefreshJobs.attemptCount, updatedAt: new Date(), error: null, nextAttemptAt: null })
.where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING'))) .where(and(eq(pricingRefreshJobs.id, job.id), eq(pricingRefreshJobs.status, 'PENDING')))
.returning(); .returning();
return claimed ?? null; return claimed ?? null;
@@ -46,8 +50,8 @@ export function createPricingRefreshJobRepository(db) {
async retryForSeller(id, sellerId) { async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs) const [job] = await db.update(pricingRefreshJobs)
.set({ status: 'PENDING', error: null, observations: [], valuation: null, updatedAt: new Date() }) .set({ status: 'PENDING', error: null, observations: [], valuation: null, nextAttemptAt: new Date(), updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'))) .where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1)))
.returning(); .returning();
return job ?? null; return job ?? null;
}, },
@@ -61,11 +65,31 @@ export function createPricingRefreshJobRepository(db) {
}, },
async fail(id, message) { async fail(id, message) {
const [failed] = await db.update(pricingRefreshJobs) return db.transaction(async (transaction) => {
.set({ status: 'FAILED', error: message, updatedAt: new Date() }) const [current] = await transaction.select().from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING'))) .where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
.returning(); if (!current) return null;
return failed ?? null; const attemptCount = current.attemptCount + 1;
const retryable = attemptCount < maxAttempts;
const [updated] = await transaction.update(pricingRefreshJobs)
.set({
status: retryable ? 'PENDING' : 'FAILED',
error: message,
attemptCount,
updatedAt: new Date(),
nextAttemptAt: retryable ? new Date(Date.now() + (2 ** attemptCount) * 1000) : null,
})
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
.returning();
return updated ?? null;
});
},
async purgeCompletedBefore(sellerId, cutoff) {
const deleted = await db.delete(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'COMPLETED'), lte(pricingRefreshJobs.updatedAt, cutoff)))
.returning({ id: pricingRefreshJobs.id });
return deleted.length;
}, },
}; };
} }
@@ -62,3 +62,17 @@ test('persists completed and failed outcomes through the job repository', async
assert.deepEqual(calls, [['complete', 'job-3', [{ id: 'observation-1' }], { valueMinor: 2500 }]]); assert.deepEqual(calls, [['complete', 'job-3', [{ id: 'observation-1' }], { valueMinor: 2500 }]]);
}); });
test('persists failed pricing refresh outcomes through the repository', async () => {
const calls = [];
await processNextPersistentPricingRefreshJob({
jobRepository: {
async claimNext() { return { id: 'job-4' }; },
async complete() { throw new Error('not expected'); },
async fail(id, error) { calls.push([id, error]); },
},
async pricingRefreshJob() { return { status: 'FAILED', error: 'Provider unavailable.' }; },
});
assert.deepEqual(calls, [['job-4', 'Provider unavailable.']]);
});
@@ -0,0 +1,2 @@
ALTER TABLE "pricing_refresh_jobs" ADD COLUMN "attempt_count" integer DEFAULT 0 NOT NULL;
ALTER TABLE "pricing_refresh_jobs" ADD COLUMN "next_attempt_at" timestamp with time zone;
+7
View File
@@ -134,6 +134,13 @@
"when": 1790150400000, "when": 1790150400000,
"tag": "0018_provider_mappings", "tag": "0018_provider_mappings",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1790236800000,
"tag": "0019_pricing_job_retries",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -154,6 +154,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema
- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling. - Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling.
- Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly. - Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly.
- Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries. - Local development and CI can enable a deterministic fixture provider for end-to-end refresh coverage; production provider adapters remain explicitly configured deployment boundaries.
- Pricing refresh jobs have a bounded three-attempt retry policy with exponential backoff and an explicit completed-job purge operation for retention scheduling.
- `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes. - `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes.
## Local Commands ## Local Commands
+5
View File
@@ -117,6 +117,11 @@ refresh-job storage and row-locked worker claim/complete/fail lifecycle are
implemented in PostgreSQL. External provider configuration remains deployment implemented in PostgreSQL. External provider configuration remains deployment
work. work.
Refresh jobs allow three attempts. Failed attempts requeue with short
exponential backoff; the third failure becomes terminal `FAILED`. Completed-job
retention is handled explicitly through the repository cleanup operation so a
deployment can schedule cleanup according to its retention policy.
Provider failure should not invalidate other valid observations. The failed provider is recorded for admin diagnostics. Provider failure should not invalidate other valid observations. The failed provider is recorded for admin diagnostics.
## Overrides ## Overrides