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