Finish up event queue abstraction
This commit is contained in:
@@ -1,28 +1,32 @@
|
||||
import { PrintfulClient, ProductSyncer, WebflowClient, type ProductSyncerOptions } from "@blade-and-brawn/commerce";
|
||||
import { PrintfulClient, ProductSyncer, WebflowClient } from "@blade-and-brawn/commerce";
|
||||
import { env, log } from "../util";
|
||||
import { db } from "../database/db";
|
||||
import type { Selectable, Transaction } from "kysely";
|
||||
import type { DB } from "../database/out/db";
|
||||
import { sleep } from "bun";
|
||||
import { EventQueueService, type EventType } from "./event-queue";
|
||||
import { Type as t } from "@sinclair/typebox";
|
||||
import type { Static } from "@sinclair/typebox";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import { sql } from "kysely";
|
||||
|
||||
type EventStatus = "queued" | "active" | "failed" | "fulfilled";
|
||||
const ProductSyncPayloadSchema = t.Object({
|
||||
session: t.Object({
|
||||
name: t.String(),
|
||||
id: t.String()
|
||||
}),
|
||||
filter: t.Optional(
|
||||
t.Object({
|
||||
pProductIds: t.Optional(t.Array(t.Number()))
|
||||
})
|
||||
)
|
||||
});
|
||||
type ProductSyncPayload = Static<typeof ProductSyncPayloadSchema>;
|
||||
|
||||
export type ProductSyncPayload = {
|
||||
session: {
|
||||
name: string,
|
||||
id: string
|
||||
},
|
||||
filter: ProductSyncerOptions["filter"]
|
||||
}
|
||||
|
||||
export type ProductSyncState = {
|
||||
syncingPProductIds: number[]
|
||||
}
|
||||
|
||||
type ProductSyncHandler = (id: string, payload: ProductSyncPayload, setState: (state: ProductSyncState) => Promise<void>) => Promise<void>
|
||||
|
||||
class AlreadyClaimedEventError extends Error { }
|
||||
class ActiveEventLimitReachedError extends Error { }
|
||||
const ProductSyncStateSchema = t.Union([
|
||||
t.Object({
|
||||
pProductIds: t.Optional(t.Array(t.Number()))
|
||||
}),
|
||||
t.Null()
|
||||
]);
|
||||
type ProductSyncState = Static<typeof ProductSyncStateSchema>;
|
||||
|
||||
export class CommerceService {
|
||||
public readonly Printful = new PrintfulClient({
|
||||
@@ -39,188 +43,49 @@ export class CommerceService {
|
||||
}
|
||||
|
||||
class ProductSyncService {
|
||||
// TODO: make service have their own logger, so we can enforce .child({component: <ServiceName>})
|
||||
readonly Queue: ProductSyncQueueService;
|
||||
static readonly EVENT_TYPE: EventType = "product_sync";
|
||||
|
||||
readonly Queue: EventQueueService<ProductSyncPayload, ProductSyncState>;
|
||||
private readonly ProductSyncer: ProductSyncer;
|
||||
|
||||
// TODO: make each service have their own logger, so we can enforce .child({component: <ServiceName>})
|
||||
|
||||
constructor(private readonly Printful: PrintfulClient, private readonly Webflow: WebflowClient) {
|
||||
this.ProductSyncer = new ProductSyncer(this.Printful, this.Webflow, log);
|
||||
this.Queue = new ProductSyncQueueService(async (_id, payload, setState) => {
|
||||
await this.ProductSyncer.run({
|
||||
filter: payload.filter,
|
||||
beforeStep: async (syncingPProductIds: number[]) => {
|
||||
await setState({ syncingPProductIds });
|
||||
}
|
||||
});
|
||||
this.Queue = new EventQueueService({
|
||||
type: ProductSyncService.EVENT_TYPE,
|
||||
schemas: { payload: ProductSyncPayloadSchema, state: ProductSyncStateSchema },
|
||||
cfg: {
|
||||
retry: { limit: 3, delayMs: 10000 },
|
||||
concurrency: { limit: 1, timeoutMs: 600000 }
|
||||
},
|
||||
handler: async (id, payload, setState) => {
|
||||
await this.ProductSyncer.run({
|
||||
filter: payload.filter,
|
||||
beforeStep: async (pProductIds: number[]) => {
|
||||
await setState({ pProductIds });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getSessionSyncState(sessionId: string) {
|
||||
const latestSync = await db.selectFrom("product_syncs")
|
||||
.selectAll()
|
||||
.where("session_id", "=", sessionId)
|
||||
const latestSync = await db.selectFrom("events")
|
||||
.select(["started_at", "ended_at", "has_failed", "state"])
|
||||
.where("type", "=", ProductSyncService.EVENT_TYPE)
|
||||
.where(sql<string>`payload->'session'->>'id'`, "=", sessionId)
|
||||
.orderBy("created_at", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!latestSync) return;
|
||||
|
||||
Value.Assert(ProductSyncStateSchema, latestSync.state);
|
||||
|
||||
return {
|
||||
startDate: latestSync.started_at,
|
||||
status: ProductSyncQueueService.status(latestSync),
|
||||
syncingPProductIds: latestSync.syncing_p_product_ids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProductSyncQueueService {
|
||||
private static DRAIN_SLEEP_TIME = 10000; // 10 seconds
|
||||
private static EVENT_TIMEOUT = 600000; // 10 min
|
||||
private static MAX_ACTIVE_EVENTS = 1;
|
||||
|
||||
constructor(private handler: ProductSyncHandler) { }
|
||||
|
||||
static status(event: Selectable<ProductSyncs>): EventStatus {
|
||||
if (!event.started_at) return "queued";
|
||||
if (!event.ended_at) return "active";
|
||||
return event.has_failed ? "failed" : "fulfilled";
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
log.trace("draining queue");
|
||||
while (true) {
|
||||
try {
|
||||
const nextActiveEvent = await this.dequeue();
|
||||
if (!nextActiveEvent) break;
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof AlreadyClaimedEventError) {
|
||||
log.warn("event handling already claimed by another worker, skipping");
|
||||
continue;
|
||||
}
|
||||
if (err instanceof ActiveEventLimitReachedError) {
|
||||
await sleep(ProductSyncQueueService.DRAIN_SLEEP_TIME);
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
log.error("failed to drain queue");
|
||||
throw err;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dequeue() {
|
||||
log.trace("attempting to dequeue");
|
||||
const eventToHandle = await db.transaction().execute(async (trx) => {
|
||||
const activeEvents = await this.activeEvents(trx);
|
||||
let activeEventCount = activeEvents.length;
|
||||
for (const activeEvent of activeEvents) {
|
||||
// If event has been active past max active time, fail it
|
||||
const activeEventTimedOut = (Date.now() - (activeEvent.started_at?.getTime() ?? 0) > ProductSyncQueueService.EVENT_TIMEOUT);
|
||||
if (activeEventTimedOut) {
|
||||
log.info({ name: activeEvent.id }, "active event has timed out. failing it and continuing...");
|
||||
await this.fail(activeEvent.id, trx);
|
||||
--activeEventCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEventCount >= ProductSyncQueueService.MAX_ACTIVE_EVENTS)
|
||||
throw new ActiveEventLimitReachedError();
|
||||
|
||||
const front = await this.front(trx);
|
||||
if (!front) return;
|
||||
|
||||
const result = await trx.updateTable("product_syncs")
|
||||
.set({ started_at: new Date() })
|
||||
.where("id", "=", front.id)
|
||||
.where("started_at", "is", null)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Guard against another worker/machine having already claimed this event
|
||||
if (result.numUpdatedRows === 0n) throw new AlreadyClaimedEventError();
|
||||
|
||||
return front;
|
||||
});
|
||||
|
||||
if (eventToHandle) {
|
||||
log.trace({ name: eventToHandle.id }, "dequeuing event");
|
||||
await this.handleEvent(eventToHandle.id, {
|
||||
session: {
|
||||
id: eventToHandle.session_id,
|
||||
name: eventToHandle.session_name
|
||||
},
|
||||
filter: { pProductIds: eventToHandle.p_product_id_filter }
|
||||
});
|
||||
return eventToHandle;
|
||||
}
|
||||
}
|
||||
|
||||
async enqueue(payload: ProductSyncPayload): Promise<string> {
|
||||
log.info({ payload }, "enqueuing");
|
||||
const result = await db.insertInto("product_syncs")
|
||||
.values({
|
||||
session_id: payload.session.id,
|
||||
session_name: payload.session.name,
|
||||
p_product_id_filter: payload.filter?.pProductIds ?? null,
|
||||
started_at: null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async fail(id: string, trx?: Transaction<DB>): Promise<void> {
|
||||
await (trx ?? db).updateTable("product_syncs")
|
||||
.set({ syncing_p_product_ids: [], ended_at: new Date(), has_failed: true })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async fulfill(id: string, trx?: Transaction<DB>): Promise<void> {
|
||||
await (trx ?? db).updateTable("product_syncs")
|
||||
.set({ syncing_p_product_ids: [], ended_at: new Date() })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async clean() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
async front(trx?: Transaction<DB>) {
|
||||
const result = await (trx ?? db).selectFrom("product_syncs")
|
||||
.selectAll()
|
||||
.where("started_at", "is", null)
|
||||
.orderBy("created_at", "asc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result;
|
||||
}
|
||||
|
||||
async activeEvents(trx?: Transaction<DB>) {
|
||||
return await (trx ?? db).selectFrom("product_syncs")
|
||||
.select(["started_at", "id"])
|
||||
.where("started_at", "is not", null)
|
||||
.where("ended_at", "is", null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async handleEvent(id: string, payload: ProductSyncPayload) {
|
||||
log.info({ id, payload }, "handling event");
|
||||
try {
|
||||
await this.handler(id, payload, async (state) => {
|
||||
// set event state
|
||||
await db.updateTable("product_syncs")
|
||||
.set({ syncing_p_product_ids: state.syncingPProductIds })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
await this.fail(id);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.fulfill(id);
|
||||
status: EventQueueService.status(latestSync),
|
||||
syncingPProductIds: latestSync.state?.pProductIds ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user