Implement basic event deduplication
This commit is contained in:
@@ -17,6 +17,15 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
.addColumn("rate_limits", "integer", (cb) => cb.notNull().defaultTo(0))
|
.addColumn("rate_limits", "integer", (cb) => cb.notNull().defaultTo(0))
|
||||||
.addColumn("has_failed", "boolean", (cb) => cb.notNull().defaultTo(false))
|
.addColumn("has_failed", "boolean", (cb) => cb.notNull().defaultTo(false))
|
||||||
.addColumn("last_error", "jsonb")
|
.addColumn("last_error", "jsonb")
|
||||||
|
.addColumn("dedupe_key", "text")
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema.createIndex("events_idx_dedupe_key")
|
||||||
|
.on("events")
|
||||||
|
.columns(["group", "type", "dedupe_key"])
|
||||||
|
.unique()
|
||||||
|
.where("dedupe_key", "is not", null)
|
||||||
|
.where(sql.ref("ended_at"), "is", null)
|
||||||
.execute()
|
.execute()
|
||||||
|
|
||||||
// TABLE: STANDARDS_DATASETS
|
// TABLE: STANDARDS_DATASETS
|
||||||
@@ -60,6 +69,8 @@ export async function down(db: Kysely<any>): Promise<void> {
|
|||||||
// TABLE: EVENTS
|
// TABLE: EVENTS
|
||||||
await db.schema.dropTable("events").ifExists().execute()
|
await db.schema.dropTable("events").ifExists().execute()
|
||||||
|
|
||||||
|
await db.schema.dropIndex("events_idx_dedupe_key").ifExists().execute();
|
||||||
|
|
||||||
// TABLE: CALCULATORS
|
// TABLE: CALCULATORS
|
||||||
await db.schema.dropTable("calculators").ifExists().execute()
|
await db.schema.dropTable("calculators").ifExists().execute()
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { EventsService, EventStatusSchema } from "./services/events";
|
|||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
// -----------------------
|
// -----------------------
|
||||||
const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec
|
const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec
|
||||||
const JWT_EXP = "7d";
|
const JWT_EXP = "1d";
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
// -----------------------
|
// -----------------------
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class ApparelOrdersService {
|
|||||||
types: {
|
types: {
|
||||||
"apparel_order_create": defineEventType({
|
"apparel_order_create": defineEventType({
|
||||||
schemas: { payload: t.Object({ wOrder: t.Object({}) }), state: t.Null() },
|
schemas: { payload: t.Object({ wOrder: t.Object({}) }), state: t.Null() },
|
||||||
|
dedupeKey: (p) => (p.wOrder as Webflow.Orders.Order).orderId,
|
||||||
processor: async (payload) => {
|
processor: async (payload) => {
|
||||||
const wOrder = payload.wOrder as Webflow.Orders.Order;
|
const wOrder = payload.wOrder as Webflow.Orders.Order;
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ class ApparelOrdersService {
|
|||||||
}),
|
}),
|
||||||
"apparel_order_fulfill": defineEventType({
|
"apparel_order_fulfill": defineEventType({
|
||||||
schemas: { payload: t.Object({ wOrderId: t.String(), shipment: t.Object({}) }), state: t.Null() },
|
schemas: { payload: t.Object({ wOrderId: t.String(), shipment: t.Object({}) }), state: t.Null() },
|
||||||
|
dedupeKey: (p) => `${p.wOrderId}:${(p.shipment as Printful.Webhook.PackageShipped["data"]["shipment"]).tracking_number}`,
|
||||||
processor: async (payload) => {
|
processor: async (payload) => {
|
||||||
const wOrderId = payload.wOrderId;
|
const wOrderId = payload.wOrderId;
|
||||||
const shipment = payload.shipment as Printful.Webhook.PackageShipped["data"]["shipment"];
|
const shipment = payload.shipment as Printful.Webhook.PackageShipped["data"]["shipment"];
|
||||||
@@ -131,6 +133,7 @@ class ApparelSyncService {
|
|||||||
t.Null()
|
t.Null()
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
dedupeKey: (p) => (p.filter?.pProductIds ?? []).join(","),
|
||||||
processor: async (payload, setState) => {
|
processor: async (payload, setState) => {
|
||||||
await this.ProductSyncer.syncApparel({
|
await this.ProductSyncer.syncApparel({
|
||||||
filter: payload.filter,
|
filter: payload.filter,
|
||||||
@@ -145,6 +148,7 @@ class ApparelSyncService {
|
|||||||
payload: t.Object({ wProductId: t.String() }),
|
payload: t.Object({ wProductId: t.String() }),
|
||||||
state: t.Union([t.Object({}), t.Null()])
|
state: t.Union([t.Object({}), t.Null()])
|
||||||
},
|
},
|
||||||
|
dedupeKey: (p) => p.wProductId,
|
||||||
processor: async (payload) => {
|
processor: async (payload) => {
|
||||||
await this.Webflow.Products.remove(payload.wProductId);
|
await this.Webflow.Products.remove(payload.wProductId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type EventProcessor<P extends TSchema, S extends TSchema> =
|
|||||||
export type EventTypeOptions<P extends TSchema, S extends TSchema> = {
|
export type EventTypeOptions<P extends TSchema, S extends TSchema> = {
|
||||||
schemas: { payload: P, state: S },
|
schemas: { payload: P, state: S },
|
||||||
processor: EventProcessor<P, S>,
|
processor: EventProcessor<P, S>,
|
||||||
|
dedupeKey: (payload: Static<P>) => string | undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventQueueCfg = {
|
type EventQueueCfg = {
|
||||||
@@ -137,17 +138,33 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
return front;
|
return front;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueue<K extends keyof T & string>(opt: {
|
async enqueue<K extends keyof T & string>({ source, type, payload }: {
|
||||||
source: EventSource,
|
source: EventSource,
|
||||||
type: K,
|
type: K,
|
||||||
payload: Static<T[K]["schemas"]["payload"]>
|
payload: Static<T[K]["schemas"]["payload"]>
|
||||||
}): Promise<string> {
|
}): Promise<void> {
|
||||||
log.info({ payload: opt.payload }, "enqueuing");
|
if (!(type in this.types)) throw new Error(`event type not registered in this queue (${this.group}): ${type}`);
|
||||||
const result = await db.insertInto("events")
|
const cfg = this.types[type as keyof T]!;
|
||||||
.values({ group: this.group, type: opt.type, source: opt.source, payload: opt.payload as JsonValue })
|
|
||||||
|
const dedupeKey = cfg.dedupeKey(payload);
|
||||||
|
log.info({ payload, dedupeKey }, "enqueuing");
|
||||||
|
|
||||||
|
await db.insertInto("events")
|
||||||
|
.values({
|
||||||
|
group: this.group,
|
||||||
|
type,
|
||||||
|
source,
|
||||||
|
payload,
|
||||||
|
dedupe_key: dedupeKey ?? null,
|
||||||
|
})
|
||||||
|
.onConflict((oc) => oc
|
||||||
|
.columns(["group", "type", "dedupe_key"])
|
||||||
|
.where("dedupe_key", "is not", null)
|
||||||
|
.where("ended_at", "is", null)
|
||||||
|
.doNothing()
|
||||||
|
)
|
||||||
.returning(["id"])
|
.returning(["id"])
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirst();
|
||||||
return result.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async front(trx?: Transaction<DB>) {
|
async front(trx?: Transaction<DB>) {
|
||||||
@@ -174,11 +191,11 @@ export class EventQueue<G extends string, T extends Record<string, EventTypeOpti
|
|||||||
private async process(event: Pick<Selectable<DB["events"]>, "id" | "type" | "errors" | "payload" | "rate_limits">) {
|
private async process(event: Pick<Selectable<DB["events"]>, "id" | "type" | "errors" | "payload" | "rate_limits">) {
|
||||||
log.info({ id: event.id, type: event.type, payload: event.payload }, "processing event");
|
log.info({ id: event.id, type: event.type, payload: event.payload }, "processing event");
|
||||||
if (!(event.type in this.types)) throw new Error(`event type not registered in this queue (${this.group}): ${event.type}`);
|
if (!(event.type in this.types)) throw new Error(`event type not registered in this queue (${this.group}): ${event.type}`);
|
||||||
const cfg = this.types[event.type as keyof T]!;
|
const typeCfg = this.types[event.type as keyof T]!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Value.Assert(cfg.schemas.payload, event.payload);
|
Value.Assert(typeCfg.schemas.payload, event.payload);
|
||||||
await cfg.processor(event.payload, (state) => this.Events.setState(event.id, state), event.id);
|
await typeCfg.processor(event.payload, (state) => this.Events.setState(event.id, state), event.id);
|
||||||
await this.Events.resolve(event.id);
|
await this.Events.resolve(event.id);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user