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;
},
};
}