Continue product sync queue on startup
This commit is contained in:
@@ -4,9 +4,10 @@ import process from 'node:process'
|
|||||||
import { log } from "./util";
|
import { log } from "./util";
|
||||||
|
|
||||||
if (cluster.isPrimary) {
|
if (cluster.isPrimary) {
|
||||||
for (let i = 0; i < os.availableParallelism(); i++)
|
const n = os.availableParallelism();
|
||||||
|
log.info(`Starting ${n} worker processes`)
|
||||||
|
for (let i = 0; i < n; i++)
|
||||||
cluster.fork()
|
cluster.fork()
|
||||||
} else {
|
} else {
|
||||||
await import('./server')
|
await import('./server')
|
||||||
log.info(`Worker ${process.pid} started`)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { env, log } from "./util";
|
|||||||
import serverTiming from "@elysia/server-timing";
|
import serverTiming from "@elysia/server-timing";
|
||||||
import jwt from "@elysia/jwt";
|
import jwt from "@elysia/jwt";
|
||||||
import { SyncService } from "./services/sync";
|
import { SyncService } from "./services/sync";
|
||||||
|
import cluster from "node:cluster";
|
||||||
|
|
||||||
// SERVICES
|
// SERVICES
|
||||||
// -----------
|
// -----------
|
||||||
@@ -248,6 +249,11 @@ export const app = new Elysia()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(3000, () => log.info({ port: 3000 }, "server started"));
|
app.listen(3000, async () => {
|
||||||
|
log.info({ port: 3000 }, "server started")
|
||||||
|
if (cluster.worker?.id === 1) {
|
||||||
|
await syncService.nextSync().catch((err) => log.error({ err }, "failed to resume product sync queue"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export type API = typeof app
|
export type API = typeof app
|
||||||
|
|||||||
@@ -3,62 +3,42 @@ import { log } from "../util";
|
|||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { DatabaseError } from "pg";
|
import { DatabaseError } from "pg";
|
||||||
|
|
||||||
class SyncQueue {
|
type QueuedSync = {
|
||||||
async enqueue(item: SyncServiceOptions) {
|
|
||||||
const values = { printful_product_id_filter: item.filter?.printfulProductIds };
|
|
||||||
if (item.id) {
|
|
||||||
await db.updateTable("product_syncs")
|
|
||||||
.set(values)
|
|
||||||
.where("id", "=", item.id)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await db.insertInto("product_syncs")
|
|
||||||
.values(values)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async front() {
|
|
||||||
return await db.selectFrom("product_syncs")
|
|
||||||
.selectAll()
|
|
||||||
.where("started_at", "is", null)
|
|
||||||
.orderBy("created_at", "asc")
|
|
||||||
.limit(1)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SyncServiceOptions = {
|
|
||||||
id?: string | null,
|
id?: string | null,
|
||||||
filter: ProductSyncerOptions["filter"]
|
filter: ProductSyncerOptions["filter"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AlreadyClaimedError extends Error { }
|
||||||
|
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
private readonly productSyncer: ProductSyncer;
|
private readonly productSyncer: ProductSyncer;
|
||||||
private queue: SyncQueue;
|
readonly queue: SyncQueue;
|
||||||
|
|
||||||
constructor(printful: PrintfulClient, webflow: WebflowClient) {
|
constructor(printful: PrintfulClient, webflow: WebflowClient) {
|
||||||
this.productSyncer = new ProductSyncer(printful, webflow, log);
|
this.productSyncer = new ProductSyncer(printful, webflow, log);
|
||||||
this.queue = new SyncQueue();
|
this.queue = new SyncQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async sync(opt: SyncServiceOptions) {
|
async sync(queuedSync: QueuedSync) {
|
||||||
let id = opt.id ?? null;
|
let id = queuedSync.id ?? null;
|
||||||
try {
|
try {
|
||||||
log.info("starting sync run");
|
log.info("starting sync run");
|
||||||
await this.productSyncer.sync({
|
await this.productSyncer.sync({
|
||||||
filter: opt.filter,
|
filter: queuedSync.filter,
|
||||||
onStart: async (startDate) => {
|
onStart: async (startDate) => {
|
||||||
const values = {
|
const values = {
|
||||||
started_at: startDate,
|
started_at: startDate,
|
||||||
printful_product_id_filter: opt.filter?.printfulProductIds ?? null,
|
printful_product_id_filter: queuedSync.filter?.printfulProductIds ?? null,
|
||||||
};
|
};
|
||||||
if (id) {
|
if (id) {
|
||||||
await db.updateTable("product_syncs")
|
// Guard against another worker/machine having already claimed
|
||||||
|
// this queued row between `queue.front()` and now.
|
||||||
|
const result = await db.updateTable("product_syncs")
|
||||||
.set(values)
|
.set(values)
|
||||||
.where("id", "=", id)
|
.where("id", "=", id)
|
||||||
.execute();
|
.where("started_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (result.numUpdatedRows === 0n) throw new AlreadyClaimedError();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const result = await db.insertInto("product_syncs")
|
const result = await db.insertInto("product_syncs")
|
||||||
@@ -83,17 +63,16 @@ export class SyncService {
|
|||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Sync next item in queue
|
// Sync next item in queue
|
||||||
const nextSync = await this.queue.front();
|
await this.nextSync();
|
||||||
if (nextSync) {
|
|
||||||
await this.sync({
|
|
||||||
id: nextSync.id,
|
|
||||||
filter: { printfulProductIds: nextSync.printful_product_id_filter }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
if (err instanceof AlreadyClaimedError) {
|
||||||
|
log.info("sync already claimed by another worker, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeSync = await this.getActiveSync();
|
const activeSync = await this.getActiveSync();
|
||||||
|
|
||||||
const activeSyncConflict = activeSync && err instanceof DatabaseError && err.code === "23505";
|
const activeSyncConflict = activeSync && err instanceof DatabaseError && err.code === "23505";
|
||||||
@@ -106,11 +85,11 @@ export class SyncService {
|
|||||||
.set({ syncing_printful_product_ids: [], ended_at: new Date(), has_failed: true })
|
.set({ syncing_printful_product_ids: [], ended_at: new Date(), has_failed: true })
|
||||||
.where("id", "=", activeSync.id)
|
.where("id", "=", activeSync.id)
|
||||||
.execute();
|
.execute();
|
||||||
await this.sync(opt);
|
await this.sync(queuedSync);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log.info("sync already active, enqueuing next sync");
|
log.info("sync already active, enqueuing next sync");
|
||||||
await this.queue.enqueue(opt);
|
await this.queue.enqueue(queuedSync);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -126,6 +105,16 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async nextSync() {
|
||||||
|
const nextSync = await this.queue.front();
|
||||||
|
if (nextSync) {
|
||||||
|
await this.sync({
|
||||||
|
id: nextSync.id,
|
||||||
|
filter: { printfulProductIds: nextSync.printful_product_id_filter }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getActiveSync() {
|
async getActiveSync() {
|
||||||
return await db.selectFrom("product_syncs")
|
return await db.selectFrom("product_syncs")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -135,3 +124,29 @@ export class SyncService {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SyncQueue {
|
||||||
|
async enqueue(queuedSync: QueuedSync) {
|
||||||
|
const values = { printful_product_id_filter: queuedSync.filter?.printfulProductIds };
|
||||||
|
if (queuedSync.id) {
|
||||||
|
await db.updateTable("product_syncs")
|
||||||
|
.set(values)
|
||||||
|
.where("id", "=", queuedSync.id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await db.insertInto("product_syncs")
|
||||||
|
.values(values)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async front() {
|
||||||
|
return await db.selectFrom("product_syncs")
|
||||||
|
.selectAll()
|
||||||
|
.where("started_at", "is", null)
|
||||||
|
.orderBy("created_at", "asc")
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import cluster from 'node:cluster';
|
||||||
import { pino } from 'pino';
|
import { pino } from 'pino';
|
||||||
|
|
||||||
export const log = pino({
|
export const log = pino({
|
||||||
@@ -24,6 +25,7 @@ export const env = {
|
|||||||
function requireEnv(key: string): string {
|
function requireEnv(key: string): string {
|
||||||
const val = Bun.env[key];
|
const val = Bun.env[key];
|
||||||
if (!val) {
|
if (!val) {
|
||||||
|
if (cluster.worker?.id === 1)
|
||||||
log.error({ name: key }, "Missing required environment variable");
|
log.error({ name: key }, "Missing required environment variable");
|
||||||
throw new Error(`Missing required environment variable: ${key}`)
|
throw new Error(`Missing required environment variable: ${key}`)
|
||||||
};
|
};
|
||||||
@@ -33,6 +35,7 @@ function requireEnv(key: string): string {
|
|||||||
function optionEnv(key: string, fallback: string = ""): string {
|
function optionEnv(key: string, fallback: string = ""): string {
|
||||||
const val = Bun.env[key];
|
const val = Bun.env[key];
|
||||||
if (!val) {
|
if (!val) {
|
||||||
|
if (cluster.worker?.id === 1)
|
||||||
log.warn({ name: key }, `Missing optional environmental variable, falling back to "${fallback}"`)
|
log.warn({ name: key }, `Missing optional environmental variable, falling back to "${fallback}"`)
|
||||||
return fallback
|
return fallback
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user