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),
providerId: text('provider_id').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(),
valuation: jsonb('valuation'),
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';
export function createPricingRefreshJobRepository(db) {
const maxAttempts = 3;
return {
async create(job) {
const [created] = await db.insert(pricingRefreshJobs).values(job).returning();
@@ -12,13 +13,16 @@ export function createPricingRefreshJobRepository(db) {
async claimNext() {
return db.transaction(async (transaction) => {
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))
.limit(1)
.for('update', { skipLocked: true });
if (!job) return null;
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')))
.returning();
return claimed ?? null;
@@ -46,8 +50,8 @@ export function createPricingRefreshJobRepository(db) {
async retryForSeller(id, sellerId) {
const [job] = await db.update(pricingRefreshJobs)
.set({ status: 'PENDING', error: null, observations: [], valuation: null, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.sellerId, sellerId), eq(pricingRefreshJobs.status, 'FAILED')))
.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'), lte(pricingRefreshJobs.attemptCount, maxAttempts - 1)))
.returning();
return job ?? null;
},
@@ -61,11 +65,31 @@ export function createPricingRefreshJobRepository(db) {
},
async fail(id, message) {
const [failed] = await db.update(pricingRefreshJobs)
.set({ status: 'FAILED', error: message, updatedAt: new Date() })
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')))
.returning();
return failed ?? null;
return db.transaction(async (transaction) => {
const [current] = await transaction.select().from(pricingRefreshJobs)
.where(and(eq(pricingRefreshJobs.id, id), eq(pricingRefreshJobs.status, 'PROCESSING')));
if (!current) return 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 }]]);
});
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,
"tag": "0018_provider_mappings",
"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.
- 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.
- 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.
## 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
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.
## Overrides