Add retries to processEvent handler with DB integration
This commit is contained in:
@@ -12,6 +12,8 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
.addColumn("state", "jsonb")
|
.addColumn("state", "jsonb")
|
||||||
.addColumn("started_at", "timestamptz")
|
.addColumn("started_at", "timestamptz")
|
||||||
.addColumn("ended_at", "timestamptz")
|
.addColumn("ended_at", "timestamptz")
|
||||||
|
.addColumn("available_at", "timestamptz", (cb) => cb.notNull().defaultTo(sql`now()`))
|
||||||
|
.addColumn("tries", "integer", (cb) => cb.notNull().defaultTo(0))
|
||||||
.addColumn("has_failed", "boolean", (cb) => cb.notNull().defaultTo(false))
|
.addColumn("has_failed", "boolean", (cb) => cb.notNull().defaultTo(false))
|
||||||
.execute()
|
.execute()
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ export const app = new Elysia()
|
|||||||
))
|
))
|
||||||
|
|
||||||
// WEBHOOKS
|
// WEBHOOKS
|
||||||
// TODO: account for automatic webhook retries from the webhook sender end (especially timeouts)
|
|
||||||
.post("/webhooks/printful", async ({ body }) => {
|
.post("/webhooks/printful", async ({ body }) => {
|
||||||
// https://webflow.com/integrations/printful
|
// https://webflow.com/integrations/printful
|
||||||
const payload = body as Printful.Webhook.EventPayload;
|
const payload = body as Printful.Webhook.EventPayload;
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ class ApparelSyncService {
|
|||||||
|
|
||||||
async getLatestSyncState(sessionId: string) {
|
async getLatestSyncState(sessionId: string) {
|
||||||
const latestSync = await db.selectFrom("events")
|
const latestSync = await db.selectFrom("events")
|
||||||
.select(["started_at", "ended_at", "has_failed", "state"])
|
.select(["started_at", "available_at", "ended_at", "has_failed", "state"])
|
||||||
.where("group", "=", this.Queue.group)
|
.where("group", "=", this.Queue.group)
|
||||||
.where("type", "=", "apparel_sync_update")
|
.where("type", "=", "apparel_sync_update")
|
||||||
.where(sql<string>`payload->'session'->>'id'`, "=", sessionId)
|
.where(sql<string>`payload->'session'->>'id'`, "=", sessionId)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Selectable, Transaction } from "kysely";
|
import { sql, type Selectable, type Transaction } from "kysely";
|
||||||
import type { DB, JsonValue } from "../database/out/db";
|
import type { DB, JsonValue } from "../database/out/db";
|
||||||
import { log } from "../util";
|
import { log } from "../util";
|
||||||
import { sleep } from "bun";
|
import { sleep } from "bun";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { Value } from "@sinclair/typebox/value";
|
import { AssertError, Value } from "@sinclair/typebox/value";
|
||||||
import type { TSchema, Static } from "@sinclair/typebox";
|
import type { TSchema, Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
export type EventSource = "portal" | "printful" | "webflow";
|
export type EventSource = "portal" | "printful" | "webflow";
|
||||||
export type EventStatus = "pending" | "processing" | "failed" | "fulfilled";
|
export type EventStatus = "waiting" | "available" | "processing" | "failed" | "fulfilled";
|
||||||
|
|
||||||
type EventProcessor<P extends TSchema, S extends TSchema> =
|
type EventProcessor<P extends TSchema, S extends TSchema> =
|
||||||
(payload: Static<P>, setState: (state: Static<S>) => Promise<void>, id: string) => Promise<void>;
|
(payload: Static<P>, setState: (state: Static<S>) => Promise<void>, id: string) => Promise<void>;
|
||||||
@@ -36,6 +36,9 @@ export function defineEventType<P extends TSchema, S extends TSchema>(opt: Event
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class EventQueue<G extends string, T extends Record<string, EventTypeOptions<any, any>>> {
|
export class EventQueue<G extends string, T extends Record<string, EventTypeOptions<any, any>>> {
|
||||||
|
private static readonly DEQUEUE_RETRY_DELAY_MS = 10000; // 10 seconds
|
||||||
|
private static readonly DEQUEUE_RETRY_LIMIT = 3;
|
||||||
|
|
||||||
private _lastCleanDate: Date = new Date(0);
|
private _lastCleanDate: Date = new Date(0);
|
||||||
readonly cfg: EventQueueCfg;
|
readonly cfg: EventQueueCfg;
|
||||||
readonly group: G;
|
readonly group: G;
|
||||||
@@ -49,8 +52,11 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
|
|
||||||
get lastCleanDate() { return this._lastCleanDate; };
|
get lastCleanDate() { return this._lastCleanDate; };
|
||||||
|
|
||||||
static status(event: Pick<Selectable<DB["events"]>, "started_at" | "ended_at" | "has_failed">): EventStatus {
|
static status(event: Pick<Selectable<DB["events"]>, "available_at" | "started_at" | "ended_at" | "has_failed">): EventStatus {
|
||||||
if (!event.started_at) return "pending";
|
if (!event.started_at) {
|
||||||
|
if (Date.now() < event.available_at.getTime()) return "waiting";
|
||||||
|
return "available";
|
||||||
|
};
|
||||||
if (!event.ended_at) return "processing";
|
if (!event.ended_at) return "processing";
|
||||||
return event.has_failed ? "failed" : "fulfilled";
|
return event.has_failed ? "failed" : "fulfilled";
|
||||||
}
|
}
|
||||||
@@ -66,24 +72,24 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (err instanceof AlreadyClaimedEventError) {
|
if (err instanceof AlreadyClaimedEventError) {
|
||||||
log.warn("event handling already claimed by another worker, skipping");
|
log.warn("event processing already claimed by another worker, skipping");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (err instanceof ConcurrencyLimitReachedError) {
|
if (err instanceof ConcurrencyLimitReachedError) {
|
||||||
await sleep(this.cfg.retry.delayMs);
|
await sleep(EventQueue.DEQUEUE_RETRY_DELAY_MS);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (++consecutiveFailures <= this.cfg.retry.limit) {
|
if (++consecutiveFailures <= EventQueue.DEQUEUE_RETRY_LIMIT) {
|
||||||
log.warn({ err, consecutiveFailures }, "Dequeue failed, retrying")
|
log.warn({ err, consecutiveFailures }, "dequeue failed, retrying")
|
||||||
await sleep(this.cfg.retry.delayMs);
|
await sleep(EventQueue.DEQUEUE_RETRY_DELAY_MS);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.error({ err, consecutiveFailures }, "failed to drain queue");
|
log.error({ err, consecutiveFailures }, "failed to drain queue");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async dequeue() {
|
async dequeue() {
|
||||||
@@ -109,20 +115,21 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
)
|
)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
// Guard against another worker/machine having already claimed this event
|
// Guard against another worker/machine having already claimed this event and guard against max concurrency
|
||||||
if (result.numUpdatedRows === 0n) {
|
if (result.numUpdatedRows === 0n) {
|
||||||
const freshFront = await db.selectFrom("events")
|
const frontFresh = await db.selectFrom("events")
|
||||||
.select("started_at")
|
.select("started_at")
|
||||||
.where("id", "=", front.id)
|
.where("id", "=", front.id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!freshFront) throw new AlreadyClaimedEventError();
|
if (!frontFresh) throw new AlreadyClaimedEventError();
|
||||||
if (freshFront?.started_at) throw new AlreadyClaimedEventError();
|
if (frontFresh?.started_at) throw new AlreadyClaimedEventError();
|
||||||
throw new ConcurrencyLimitReachedError();
|
throw new ConcurrencyLimitReachedError();
|
||||||
};
|
};
|
||||||
|
|
||||||
log.trace({ name: front.id }, "dequeuing event");
|
log.trace({ name: front.id }, "dequeuing event");
|
||||||
await this.processEvent(front.id, front.type, front.payload);
|
await this.processEvent(front);
|
||||||
|
|
||||||
return front;
|
return front;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +163,7 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
async clean() {
|
async clean() {
|
||||||
try {
|
try {
|
||||||
// Find and fail any timed out processing events
|
// Find and fail any timed out processing events
|
||||||
const processingEvents = await this.processingEvents();
|
const processingEvents = await this.list({ filter: { status: "processing" } });
|
||||||
for (const processingEvent of processingEvents) {
|
for (const processingEvent of processingEvents) {
|
||||||
const processingEventTimedOut = (Date.now() - (processingEvent.started_at?.getTime() ?? 0) > this.cfg.concurrency.timeoutMs);
|
const processingEventTimedOut = (Date.now() - (processingEvent.started_at?.getTime() ?? 0) > this.cfg.concurrency.timeoutMs);
|
||||||
if (processingEventTimedOut) {
|
if (processingEventTimedOut) {
|
||||||
@@ -172,41 +179,92 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
|
|
||||||
async front(trx?: Transaction<DB>) {
|
async front(trx?: Transaction<DB>) {
|
||||||
const result = await (trx ?? db).selectFrom("events")
|
const result = await (trx ?? db).selectFrom("events")
|
||||||
.selectAll()
|
.select(["id", "type", "payload", "available_at", "tries"])
|
||||||
.where("group", "=", this.group)
|
.where("group", "=", this.group)
|
||||||
.where("started_at", "is", null)
|
.where("started_at", "is", null)
|
||||||
|
.where("available_at", "<=", new Date())
|
||||||
.orderBy("created_at", "asc")
|
.orderBy("created_at", "asc")
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processingEvents(trx?: Transaction<DB>) {
|
async list(opt: { filter?: { status?: EventStatus } } = {}, trx?: Transaction<DB>) {
|
||||||
return await (trx ?? db).selectFrom("events")
|
return await (trx ?? db).selectFrom("events")
|
||||||
.select(["started_at", "id"])
|
.select(["started_at", "id"])
|
||||||
.where("group", "=", this.group)
|
.where("group", "=", this.group)
|
||||||
|
.$if(opt.filter?.status === "waiting", (qb) => qb
|
||||||
|
.where("started_at", "is", null)
|
||||||
|
.where("available_at", ">", new Date())
|
||||||
|
)
|
||||||
|
.$if(opt.filter?.status === "available", (qb) => qb
|
||||||
|
.where("started_at", "is", null)
|
||||||
|
.where("available_at", "<=", new Date())
|
||||||
|
)
|
||||||
|
.$if(opt.filter?.status === "processing", (qb) => qb
|
||||||
.where("started_at", "is not", null)
|
.where("started_at", "is not", null)
|
||||||
.where("ended_at", "is", null)
|
.where("ended_at", "is", null)
|
||||||
|
)
|
||||||
|
.$if(opt.filter?.status === "failed", (qb) => qb
|
||||||
|
.where("started_at", "is not", null)
|
||||||
|
.where("ended_at", "is not", null)
|
||||||
|
.where("has_failed", "is", true)
|
||||||
|
)
|
||||||
|
.$if(opt.filter?.status === "fulfilled", (qb) => qb
|
||||||
|
.where("started_at", "is not", null)
|
||||||
|
.where("ended_at", "is not", null)
|
||||||
|
.where("has_failed", "is", false)
|
||||||
|
)
|
||||||
|
.orderBy(sql`(started_at is not null and ended_at is null)`, "desc")
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processEvent(id: string, type: string, payload: JsonValue) {
|
private async processEvent(event: Pick<Selectable<DB["events"]>, "id" | "type" | "tries" | "payload">) {
|
||||||
log.info({ id, payload }, "processing event");
|
log.info({ id: event.id, type: event.type, payload: event.payload }, "processing event");
|
||||||
if (!(type in this.events)) throw new Error(`event type not registerd in this queue: ${type}`);
|
if (!(event.type in this.events)) throw new Error(`event type not registered in this queue (${this.group}): ${event.type}`);
|
||||||
const cfg = this.events[type as keyof T]!;
|
const cfg = this.events[event.type as keyof T]!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Value.Assert(cfg.schemas.payload, payload);
|
Value.Assert(cfg.schemas.payload, event.payload);
|
||||||
await cfg.processor(payload, async (state) => {
|
await cfg.processor(event.payload, (state) => this.setEventState(event.id, state), event.id);
|
||||||
|
await this.fulfill(event.id);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof AssertError) {
|
||||||
|
log.error({ tries: event.tries }, "could not process event: malformed payload");
|
||||||
|
await this.fail(event.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.tries < this.cfg.retry.limit) {
|
||||||
|
log.warn({ err, tries: event.tries, delay: this.cfg.retry.delayMs }, "encountered an error during event processing, retrying")
|
||||||
|
await this.retry(event.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error({ err, tries: event.tries }, "event failed");
|
||||||
|
await this.fail(event.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setEventState(id: string, state: JsonValue) {
|
||||||
await db.updateTable("events")
|
await db.updateTable("events")
|
||||||
.set({ state })
|
.set({ state })
|
||||||
.where("id", "=", id)
|
.where("id", "=", id)
|
||||||
.execute();
|
.execute();
|
||||||
}, id);
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
|
||||||
await this.fail(id);
|
private async retry(id: string) {
|
||||||
throw err;
|
await db.updateTable("events")
|
||||||
}
|
.set((eb) => ({
|
||||||
await this.fulfill(id);
|
tries: eb("tries", "+", 1),
|
||||||
|
started_at: null,
|
||||||
|
ended_at: null,
|
||||||
|
has_failed: false,
|
||||||
|
available_at: new Date(Date.now() + this.cfg.retry.delayMs)
|
||||||
|
}))
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { api } from "$lib/api.js";
|
import { api } from "$lib/api.js";
|
||||||
|
|
||||||
type SyncStatus = "pending" | "processing" | "failed" | "fulfilled";
|
type SyncStatus =
|
||||||
|
| "waiting"
|
||||||
|
| "available"
|
||||||
|
| "processing"
|
||||||
|
| "failed"
|
||||||
|
| "fulfilled";
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -11,7 +16,11 @@
|
|||||||
status: "none" as SyncStatus | "none",
|
status: "none" as SyncStatus | "none",
|
||||||
syncingPProductIds: [] as number[],
|
syncingPProductIds: [] as number[],
|
||||||
get isInProgress() {
|
get isInProgress() {
|
||||||
return this.status === "processing" || this.status === "pending";
|
return (
|
||||||
|
this.status !== "failed" &&
|
||||||
|
this.status !== "fulfilled" &&
|
||||||
|
this.status !== "none"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
poll: async function () {
|
poll: async function () {
|
||||||
const res = await api.commerce.products.sync.get();
|
const res = await api.commerce.products.sync.get();
|
||||||
@@ -20,8 +29,7 @@
|
|||||||
synchronizer.syncingPProductIds = [];
|
synchronizer.syncingPProductIds = [];
|
||||||
} else {
|
} else {
|
||||||
synchronizer.status = res.data.status;
|
synchronizer.status = res.data.status;
|
||||||
synchronizer.syncingPProductIds =
|
synchronizer.syncingPProductIds = res.data.syncingPProductIds;
|
||||||
res.data.syncingPProductIds;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
startPolling: () => {
|
startPolling: () => {
|
||||||
@@ -47,9 +55,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isProductSynced = async (
|
const isProductSynced = async (pProduct: Printful.Products.SyncProduct) => {
|
||||||
pProduct: Printful.Products.SyncProduct,
|
|
||||||
) => {
|
|
||||||
const wProducts = await data.products.webflow;
|
const wProducts = await data.products.webflow;
|
||||||
return wProducts.some(
|
return wProducts.some(
|
||||||
(p) => p.product.id === pProduct.external_id.split("-")[0],
|
(p) => p.product.id === pProduct.external_id.split("-")[0],
|
||||||
@@ -88,8 +94,7 @@
|
|||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
const res = await api.commerce
|
const res = await api.commerce
|
||||||
.products({
|
.products({
|
||||||
pProductId:
|
pProductId: pProduct.id,
|
||||||
pProduct.id,
|
|
||||||
})
|
})
|
||||||
.get();
|
.get();
|
||||||
console.log(res.data);
|
console.log(res.data);
|
||||||
@@ -119,8 +124,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
disabled={synchronizer.status === "processing"}
|
disabled={synchronizer.status === "processing"}
|
||||||
onclick={() =>
|
onclick={() => synchronizer.sync(pProduct.id)}
|
||||||
synchronizer.sync(pProduct.id)}
|
|
||||||
class="btn"
|
class="btn"
|
||||||
>
|
>
|
||||||
Sync
|
Sync
|
||||||
|
|||||||
Reference in New Issue
Block a user