This commit is contained in:
@@ -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() })
|
||||
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 failed ?? null;
|
||||
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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user