Fix redis race condition

This commit is contained in:
Dominic Ferrando
2026-06-24 23:22:02 -04:00
parent 968afb5789
commit 51abbbc00c
3 changed files with 47 additions and 41 deletions
+6 -9
View File
@@ -126,22 +126,19 @@ export const app = new Elysia()
.group("/sync", (app) => .group("/sync", (app) =>
app app
// Sync status // Sync status
.get("/", async ({ }) => await productSyncer.getState()) .get("/", async ({ }) => ({
state: await productSyncer.getState(),
isRunning: await productSyncer.isRunning()
}))
// Full sync // Full sync
.post("/", async ({ }) => { .post("/", async ({ }) => {
const syncState = await productSyncer.getState(); if (!await productSyncer.sync())
if (syncState.isSyncing)
throw status(409, "Sync already in progress"); throw status(409, "Sync already in progress");
await productSyncer.sync();
}) })
// Per-product sync // Per-product sync
.post("/:printfulProductId", async ({ params }) => { .post("/:printfulProductId", async ({ params }) => {
const syncState = await productSyncer.getState(); if (!await productSyncer.sync(params.printfulProductId))
if (syncState.isSyncing)
throw status(409, "Sync already in progress"); throw status(409, "Sync already in progress");
await productSyncer.sync(params.printfulProductId);
}, { params: z.object({ printfulProductId: z.coerce.number() }) }), }, { params: z.object({ printfulProductId: z.coerce.number() }) }),
) )
.get("/:printfulProductId", async ({ params }) => { .get("/:printfulProductId", async ({ params }) => {
+11 -10
View File
@@ -6,21 +6,22 @@
const { data } = $props(); const { data } = $props();
const synchronizer = $state({ const synchronizer = $state({
isSyncing: false, isRunning: false,
syncingIds: [] as number[], printfulProductIds: [] as number[],
poll: async function () { poll: async function () {
const res = (await ( const res = (await (
await fetch(`${PUBLIC_API_URL}/products/sync`) await fetch(`${PUBLIC_API_URL}/products/sync`)
).json()) as any; ).json()) as any;
if (res.data) { if (res.data) {
synchronizer.isSyncing = res.data.isSyncing; synchronizer.isRunning = res.data.isRunning;
synchronizer.syncingIds = res.data.syncingIds; synchronizer.printfulProductIds =
res.data.state.printfulProductIds;
} }
}, },
startPolling: () => { startPolling: () => {
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
await synchronizer.poll(); await synchronizer.poll();
if (!synchronizer.isSyncing) clearInterval(intervalId); if (!synchronizer.isRunning) clearInterval(intervalId);
}, 100); }, 100);
}, },
syncAll: async () => { syncAll: async () => {
@@ -40,7 +41,7 @@
onMount(async () => { onMount(async () => {
await synchronizer.poll(); await synchronizer.poll();
if (synchronizer.isSyncing) { if (synchronizer.isRunning) {
synchronizer.startPolling(); synchronizer.startPolling();
} }
}); });
@@ -59,11 +60,11 @@
<p>Loading...</p> <p>Loading...</p>
{:then printfulProducts} {:then printfulProducts}
<button <button
disabled={synchronizer.isSyncing} disabled={synchronizer.isRunning}
onclick={() => synchronizer.syncAll()} onclick={() => synchronizer.syncAll()}
class="btn btn-xl mb-5" class="btn btn-xl mb-5"
> >
{#if synchronizer.isSyncing} {#if synchronizer.isRunning}
Syncing... Syncing...
{:else} {:else}
Sync all Sync all
@@ -98,7 +99,7 @@
> >
<td> <td>
{#await isProductSynced(printfulProduct) then isSynced} {#await isProductSynced(printfulProduct) then isSynced}
{#if synchronizer.syncingIds.includes(printfulProduct.id)} {#if synchronizer.printfulProductIds.includes(printfulProduct.id)}
<div class="badge badge-warning"> <div class="badge badge-warning">
Syncing... Syncing...
</div> </div>
@@ -115,7 +116,7 @@
</td> </td>
<td> <td>
<button <button
disabled={synchronizer.isSyncing} disabled={synchronizer.isRunning}
onclick={() => onclick={() =>
synchronizer.sync(printfulProduct.id)} synchronizer.sync(printfulProduct.id)}
class="btn" class="btn"
+30 -22
View File
@@ -7,7 +7,7 @@ import pino from "pino";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type MainProduct = { type MetaPrintfulProduct = {
name: string; name: string;
externalId: string; externalId: string;
colors: Set<string>; colors: Set<string>;
@@ -18,8 +18,7 @@ type MainProduct = {
}; };
type SyncState = { type SyncState = {
isSyncing: boolean; printfulProductIds: number[];
syncingIds: number[];
}; };
export class ProductSyncer { export class ProductSyncer {
@@ -33,14 +32,15 @@ export class ProductSyncer {
this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" }); this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" });
} }
async sync(printfulProductId?: number) { async sync(printfulProductId?: number): Promise<boolean> {
const syncStart = Date.now(); const canRun = await this.run();
if (!canRun) return false;
try { try {
const syncStart = Date.now();
if (printfulProductId) this.log.info({ printfulProductId }, "sync started"); if (printfulProductId) this.log.info({ printfulProductId }, "sync started");
else this.log.info("sync started"); else this.log.info("sync started");
await this.setState({ isSyncing: true, syncingIds: [] });
this.log.debug("populating webflow products"); this.log.debug("populating webflow products");
const webflowProducts: Webflow.Products.ProductAndSkus[] = await this.webflow.Products.list({ forceAll: true }); const webflowProducts: Webflow.Products.ProductAndSkus[] = await this.webflow.Products.list({ forceAll: true });
@@ -59,7 +59,7 @@ export class ProductSyncer {
} }
this.log.info({ count: printfulProducts.length }, "generating meta printful products"); this.log.info({ count: printfulProducts.length }, "generating meta printful products");
const metaPrintfulProducts: MainProduct[] = []; const metaPrintfulProducts: MetaPrintfulProduct[] = [];
for (const printfulProduct of printfulProducts) { for (const printfulProduct of printfulProducts) {
const mainProductName = this.getMainProductName( const mainProductName = this.getMainProductName(
printfulProduct.name, printfulProduct.name,
@@ -93,8 +93,7 @@ export class ProductSyncer {
for (const metaPrintfulProduct of metaPrintfulProducts) { for (const metaPrintfulProduct of metaPrintfulProducts) {
this.log.info({ name: metaPrintfulProduct.name }, "syncing meta printful product"); this.log.info({ name: metaPrintfulProduct.name }, "syncing meta printful product");
await this.setState({ await this.setState({
isSyncing: true, printfulProductIds: metaPrintfulProduct.variants.map(
syncingIds: metaPrintfulProduct.variants.map(
(v) => v.product.sync_product.id, (v) => v.product.sync_product.id,
), ),
}); });
@@ -291,22 +290,13 @@ export class ProductSyncer {
this.log.info({ durationMs: Date.now() - syncStart }, "sync complete"); this.log.info({ durationMs: Date.now() - syncStart }, "sync complete");
} finally { } finally {
try { try {
await this.setState({ isSyncing: false, syncingIds: [] }); await redis.del("commerce:sync:lock");
await redis.del("commerce:sync:state");
} catch { } catch {
this.log.error("failed to reset sync state"); this.log.error("failed to reset sync state");
} }
} }
} return true;
async setState(state: SyncState) {
await redis.set("commerce:sync:state", JSON.stringify(state));
await redis.expire("commerce:sync:state", 1800);
}
async getState(): Promise<SyncState> {
const val = await redis.get("commerce:sync:state");
if (val) return JSON.parse(val);
return { isSyncing: false, syncingIds: [] };
} }
findColorInProductName(productName: string): string { findColorInProductName(productName: string): string {
@@ -322,4 +312,22 @@ export class ProductSyncer {
return productName; return productName;
} }
} }
async isRunning(): Promise<boolean> {
return !!await redis.get("commerce:sync:lock");
}
async getState(): Promise<SyncState> {
const val = await redis.get("commerce:sync:state");
if (val) return JSON.parse(val);
return { printfulProductIds: [] };
}
private async setState(state: SyncState) {
await redis.set("commerce:sync:state", JSON.stringify(state), "EX", 1800);
}
private async run(): Promise<boolean> {
return !!await redis.set("commerce:sync:lock", "1", "NX", "EX", "1800");
}
} }