Cleanup terminology

This commit is contained in:
Dominic Ferrando
2026-07-12 01:43:55 -04:00
parent 80f8962a3b
commit 8b81156f6c
4 changed files with 46 additions and 37 deletions
+11 -9
View File
@@ -20,11 +20,11 @@ import { randomUUIDv7, sleep } from "bun";
import { CalculatorService, CalculatorUnavailableError } from "./services/calculator"; import { CalculatorService, CalculatorUnavailableError } from "./services/calculator";
import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator";
import { StandardsService } from "./services/standards"; import { StandardsService } from "./services/standards";
import type { EventQueueService } from "./services/event-queue";
// CONSTANTS // CONSTANTS
// ----------------------- // -----------------------
const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec const EVENT_QUEUE_MANAGE_DELAY_MS = 1000 // 1 sec
const EVENT_QUEUE_CLEAN_DELAY_MS = 600000 // 10 min
// SERVICES // SERVICES
// ----------------------- // -----------------------
@@ -194,9 +194,9 @@ export const app = new Elysia()
.group("/sync", (app) => app .group("/sync", (app) => app
// Sync status // Sync status
.get("/", async ({ sessionId }) => { .get("/", async ({ sessionId }) => {
const syncState = await s.Commerce.ProductSync.getSessionSyncState(sessionId); const latestSyncState = await s.Commerce.ProductSync.getLatestSyncState(sessionId);
if (!syncState) throw new NotFoundError("No product sync found for the provided session"); if (!latestSyncState) throw new NotFoundError("No product sync found for the provided session");
return syncState; return latestSyncState;
}) })
// Run sync // Run sync
.post("/:pProductId?", async ({ params: { pProductId }, sessionId }) => { .post("/:pProductId?", async ({ params: { pProductId }, sessionId }) => {
@@ -317,17 +317,19 @@ app.listen(3000, async () => {
log.info({ port: 3000 }, "server started") log.info({ port: 3000 }, "server started")
// MANAGE QUEUES // MANAGE QUEUES
const queues = [s.Commerce.ProductSync.Queue]; const queues: EventQueueService<any, any>[] = [s.Commerce.ProductSync.Queue];
while (true) {
for (const queue of queues) { for (const queue of queues) {
(async () => {
while (true) {
// clean, if ready // clean, if ready
if ((Date.now() - queue.lastCleanDate.getTime()) >= EVENT_QUEUE_CLEAN_DELAY_MS) if ((Date.now() - queue.lastCleanDate.getTime()) >= queue.cfg.concurrency.timeoutMs)
await queue.clean(); await queue.clean().catch((err) => log.error(err));
// drain // drain
await queue.drain().catch((err) => log.error(err)); await queue.drain().catch((err) => log.error(err));
}
await sleep(EVENT_QUEUE_MANAGE_DELAY_MS); await sleep(EVENT_QUEUE_MANAGE_DELAY_MS);
} }
})();
}
} }
}); });
+3 -2
View File
@@ -59,7 +59,7 @@ class ProductSyncService {
retry: { limit: 3, delayMs: 10000 }, retry: { limit: 3, delayMs: 10000 },
concurrency: { limit: 1, timeoutMs: 600000 } concurrency: { limit: 1, timeoutMs: 600000 }
}, },
handler: async (id, payload, setState) => { processor: async (id, payload, setState) => {
await this.ProductSyncer.run({ await this.ProductSyncer.run({
filter: payload.filter, filter: payload.filter,
beforeStep: async (pProductIds: number[]) => { beforeStep: async (pProductIds: number[]) => {
@@ -70,11 +70,12 @@ class ProductSyncService {
}); });
} }
async getSessionSyncState(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", "ended_at", "has_failed", "state"])
.where("type", "=", ProductSyncService.EVENT_TYPE) .where("type", "=", ProductSyncService.EVENT_TYPE)
.where(sql<string>`payload->'session'->>'id'`, "=", sessionId) .where(sql<string>`payload->'session'->>'id'`, "=", sessionId)
.orderBy(sql`(started_at is not null and ended_at is null)`, "desc")
.orderBy("created_at", "desc") .orderBy("created_at", "desc")
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
+22 -16
View File
@@ -9,9 +9,9 @@ import type { TSchema } from "@sinclair/typebox";
export type EventType = "product_sync" | "product_order_created" /* TODO: more events... */; export type EventType = "product_sync" | "product_order_created" /* TODO: more events... */;
export type EventSource = "portal" | "printful" | "webflow"; export type EventSource = "portal" | "printful" | "webflow";
type EventHandler<Payload extends JsonValue, State extends JsonValue> = type EventProcessor<Payload extends JsonValue, State extends JsonValue> =
(id: string, payload: Payload, setState: (state: State) => Promise<void>) => Promise<void>; (id: string, payload: Payload, setState: (state: State) => Promise<void>) => Promise<void>;
type EventStatus = "queued" | "active" | "failed" | "fulfilled"; type EventStatus = "pending" | "processing" | "failed" | "fulfilled";
class AlreadyClaimedEventError extends Error { } class AlreadyClaimedEventError extends Error { }
class ConcurrencyLimitReachedError extends Error { } class ConcurrencyLimitReachedError extends Error { }
@@ -32,19 +32,22 @@ type EventQueueServiceOptions<Payload extends JsonValue, State extends JsonValue
timeoutMs: number, timeoutMs: number,
}, },
}, },
handler: EventHandler<Payload, State>, processor: EventProcessor<Payload, State>,
} }
export class EventQueueService<Payload extends JsonValue, State extends JsonValue> { export class EventQueueService<Payload extends JsonValue, State extends JsonValue> {
private _lastCleanDate: Date = new Date(0); private _lastCleanDate: Date = new Date(0);
readonly cfg: EventQueueServiceOptions<Payload, State>["cfg"];
constructor(private opt: EventQueueServiceOptions<Payload, State>) { } constructor(private readonly opt: EventQueueServiceOptions<Payload, State>) {
this.cfg = opt.cfg;
}
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"]>, "started_at" | "ended_at" | "has_failed">): EventStatus {
if (!event.started_at) return "queued"; if (!event.started_at) return "pending";
if (!event.ended_at) return "active"; if (!event.ended_at) return "processing";
return event.has_failed ? "failed" : "fulfilled"; return event.has_failed ? "failed" : "fulfilled";
} }
@@ -143,18 +146,21 @@ export class EventQueueService<Payload extends JsonValue, State extends JsonValu
} }
async clean() { async clean() {
// Find and fail any timed out active events try {
const activeEvents = await this.activeEvents(); // Find and fail any timed out processing events
for (const activeEvent of activeEvents) { const processingEvents = await this.processingEvents();
const activeEventTimedOut = (Date.now() - (activeEvent.started_at?.getTime() ?? 0) > this.opt.cfg.concurrency.timeoutMs); for (const processingEvent of processingEvents) {
if (activeEventTimedOut) { const processingEventTimedOut = (Date.now() - (processingEvent.started_at?.getTime() ?? 0) > this.opt.cfg.concurrency.timeoutMs);
log.warn({ name: activeEvent.id }, "active event has timed out. failing it and continuing..."); if (processingEventTimedOut) {
await this.fail(activeEvent.id); log.warn({ name: processingEvent.id }, "processing event has timed out. failing it and continuing...");
await this.fail(processingEvent.id);
} }
} }
}
finally {
this._lastCleanDate = new Date(); this._lastCleanDate = new Date();
} }
}
async front(trx?: Transaction<DB>) { async front(trx?: Transaction<DB>) {
const result = await (trx ?? db).selectFrom("events") const result = await (trx ?? db).selectFrom("events")
@@ -167,7 +173,7 @@ export class EventQueueService<Payload extends JsonValue, State extends JsonValu
return result; return result;
} }
async activeEvents(trx?: Transaction<DB>) { async processingEvents(trx?: Transaction<DB>) {
return await (trx ?? db).selectFrom("events") return await (trx ?? db).selectFrom("events")
.select(["started_at", "id"]) .select(["started_at", "id"])
.where("type", "=", this.opt.type) .where("type", "=", this.opt.type)
@@ -180,7 +186,7 @@ export class EventQueueService<Payload extends JsonValue, State extends JsonValu
log.info({ id, payload }, "handling event"); log.info({ id, payload }, "handling event");
try { try {
Value.Assert(this.opt.schemas.payload, payload); Value.Assert(this.opt.schemas.payload, payload);
await this.opt.handler(id, payload, async (state) => { await this.opt.processor(id, payload, async (state) => {
await db.updateTable("events") await db.updateTable("events")
.set({ state }) .set({ state })
.where("id", "=", id) .where("id", "=", id)
@@ -3,15 +3,15 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { api } from "$lib/api.js"; import { api } from "$lib/api.js";
type SyncStatus = "queued" | "active" | "failed" | "fulfilled" | "none"; type SyncStatus = "pending" | "processing" | "failed" | "fulfilled";
const { data } = $props(); const { data } = $props();
const synchronizer = $state({ const synchronizer = $state({
status: "none" as SyncStatus, status: "none" as SyncStatus | "none",
syncingPProductIds: [] as number[], syncingPProductIds: [] as number[],
get isInProgress() { get isInProgress() {
return this.status === "active" || this.status === "queued"; return this.status === "processing" || this.status === "pending";
}, },
poll: async function () { poll: async function () {
const res = await api.commerce.products.sync.get(); const res = await api.commerce.products.sync.get();
@@ -118,7 +118,7 @@
</td> </td>
<td> <td>
<button <button
disabled={synchronizer.status === "active"} disabled={synchronizer.status === "processing"}
onclick={() => onclick={() =>
synchronizer.sync(pProduct.id)} synchronizer.sync(pProduct.id)}
class="btn" class="btn"