feat(tasks): implement long-term delayed tasks with KV and Cron
This commit is contained in:
240
src/libs/tasks/processDelayedTasks.spec.ts
Normal file
240
src/libs/tasks/processDelayedTasks.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
|
||||
import { processDelayedTasks } from "./processDelayedTasks";
|
||||
|
||||
describe("processDelayedTasks", () => {
|
||||
let mockEnv: Cloudflare.Env;
|
||||
let mockCtx: ExecutionContext;
|
||||
let kvGetSpy: ReturnType<typeof mock>;
|
||||
let kvDeleteSpy: ReturnType<typeof mock>;
|
||||
let kvPutSpy: ReturnType<typeof mock>;
|
||||
let queueSendSpy: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
kvGetSpy = mock(() => Promise.resolve(null));
|
||||
kvDeleteSpy = mock(() => Promise.resolve());
|
||||
kvPutSpy = mock(() => Promise.resolve());
|
||||
queueSendSpy = mock(() => Promise.resolve());
|
||||
|
||||
mockEnv = {
|
||||
DELAYED_TASKS: {
|
||||
get: kvGetSpy,
|
||||
delete: kvDeleteSpy,
|
||||
put: kvPutSpy,
|
||||
list: mock(() => Promise.resolve({ keys: [], list_complete: true })),
|
||||
getWithMetadata: mock(() =>
|
||||
Promise.resolve({ value: null, metadata: null }),
|
||||
),
|
||||
} as any,
|
||||
NEW_EPISODE: {
|
||||
send: queueSendSpy,
|
||||
} as any,
|
||||
ANILIST_UPDATES: {
|
||||
send: mock(() => Promise.resolve()),
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
mockCtx = {
|
||||
waitUntil: mock(() => {}),
|
||||
passThroughOnException: mock(() => {}),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it("handles empty KV namespace", async () => {
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
expect(kvDeleteSpy).not.toHaveBeenCalled();
|
||||
expect(queueSendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues tasks within 12 hours of scheduled time", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const scheduledTime = now + 6 * 3600; // 6 hours from now
|
||||
|
||||
const taskMetadata = {
|
||||
queueName: "NEW_EPISODE",
|
||||
body: { aniListId: 123, episodeNumber: 1 },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
scheduledEpochTime: scheduledTime,
|
||||
taskId: "task-1",
|
||||
createdAt: now - 18 * 3600,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
mockEnv.DELAYED_TASKS.list = mock(() =>
|
||||
Promise.resolve({
|
||||
keys: [{ name: `delayed-task:${scheduledTime}:task-1` }],
|
||||
list_complete: true,
|
||||
}),
|
||||
);
|
||||
|
||||
kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata)));
|
||||
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
expect(queueSendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(kvDeleteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(kvDeleteSpy).toHaveBeenCalledWith(
|
||||
`delayed-task:${scheduledTime}:task-1`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not queue tasks beyond 12 hours", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const scheduledTime = now + 24 * 3600; // 24 hours from now
|
||||
|
||||
const taskMetadata = {
|
||||
queueName: "NEW_EPISODE",
|
||||
body: { aniListId: 456, episodeNumber: 2 },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
scheduledEpochTime: scheduledTime,
|
||||
taskId: "task-2",
|
||||
createdAt: now,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
mockEnv.DELAYED_TASKS.list = mock(() =>
|
||||
Promise.resolve({
|
||||
keys: [{ name: `delayed-task:${scheduledTime}:task-2` }],
|
||||
list_complete: true,
|
||||
}),
|
||||
);
|
||||
|
||||
kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata)));
|
||||
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
expect(queueSendSpy).not.toHaveBeenCalled();
|
||||
expect(kvDeleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("increments retry count on queue failure", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const scheduledTime = now + 1 * 3600; // 1 hour from now
|
||||
|
||||
const taskMetadata = {
|
||||
queueName: "NEW_EPISODE",
|
||||
body: { aniListId: 789, episodeNumber: 3 },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
scheduledEpochTime: scheduledTime,
|
||||
taskId: "task-3",
|
||||
createdAt: now - 23 * 3600,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
mockEnv.DELAYED_TASKS.list = mock(() =>
|
||||
Promise.resolve({
|
||||
keys: [{ name: `delayed-task:${scheduledTime}:task-3` }],
|
||||
list_complete: true,
|
||||
}),
|
||||
);
|
||||
|
||||
kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata)));
|
||||
queueSendSpy.mockRejectedValue(new Error("Queue error"));
|
||||
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
expect(kvPutSpy).toHaveBeenCalledTimes(1);
|
||||
const updatedMetadata = JSON.parse(kvPutSpy.mock.calls[0][1]);
|
||||
expect(updatedMetadata.retryCount).toBe(1);
|
||||
expect(kvDeleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs alert after 3 failed attempts", async () => {
|
||||
const consoleErrorSpy = mock(() => {});
|
||||
const originalConsoleError = console.error;
|
||||
console.error = consoleErrorSpy as any;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const scheduledTime = now + 1 * 3600;
|
||||
|
||||
const taskMetadata = {
|
||||
queueName: "NEW_EPISODE",
|
||||
body: { aniListId: 999, episodeNumber: 4 },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
scheduledEpochTime: scheduledTime,
|
||||
taskId: "task-4",
|
||||
createdAt: now - 23 * 3600,
|
||||
retryCount: 2, // Will become 3 after this failure
|
||||
};
|
||||
|
||||
mockEnv.DELAYED_TASKS.list = mock(() =>
|
||||
Promise.resolve({
|
||||
keys: [{ name: `delayed-task:${scheduledTime}:task-4` }],
|
||||
list_complete: true,
|
||||
}),
|
||||
);
|
||||
|
||||
kvGetSpy.mockReturnValue(Promise.resolve(JSON.stringify(taskMetadata)));
|
||||
queueSendSpy.mockRejectedValue(new Error("Queue error"));
|
||||
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
// Check that alert was logged
|
||||
const alertCalls = consoleErrorSpy.mock.calls.filter((call: any) =>
|
||||
call[0]?.includes("🚨 ALERT"),
|
||||
);
|
||||
expect(alertCalls.length).toBeGreaterThan(0);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it("handles multiple tasks in single cron run", async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const task1Metadata = {
|
||||
queueName: "NEW_EPISODE",
|
||||
body: { aniListId: 100, episodeNumber: 1 },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
scheduledEpochTime: now + 2 * 3600,
|
||||
taskId: "task-1",
|
||||
createdAt: now - 20 * 3600,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
const task2Metadata = {
|
||||
queueName: "NEW_EPISODE",
|
||||
body: { aniListId: 200, episodeNumber: 2 },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
scheduledEpochTime: now + 5 * 3600,
|
||||
taskId: "task-2",
|
||||
createdAt: now - 19 * 3600,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
mockEnv.DELAYED_TASKS.list = mock(() =>
|
||||
Promise.resolve({
|
||||
keys: [
|
||||
{ name: `delayed-task:${task1Metadata.scheduledEpochTime}:task-1` },
|
||||
{ name: `delayed-task:${task2Metadata.scheduledEpochTime}:task-2` },
|
||||
],
|
||||
list_complete: true,
|
||||
}),
|
||||
);
|
||||
|
||||
kvGetSpy
|
||||
.mockReturnValueOnce(Promise.resolve(JSON.stringify(task1Metadata)))
|
||||
.mockReturnValueOnce(Promise.resolve(JSON.stringify(task2Metadata)));
|
||||
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
expect(queueSendSpy).toHaveBeenCalledTimes(2);
|
||||
expect(kvDeleteSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips tasks with null values in KV", async () => {
|
||||
mockEnv.DELAYED_TASKS.list = mock(() =>
|
||||
Promise.resolve({
|
||||
keys: [{ name: "delayed-task:123:invalid" }],
|
||||
list_complete: true,
|
||||
}),
|
||||
);
|
||||
|
||||
kvGetSpy.mockReturnValue(Promise.resolve(null));
|
||||
|
||||
await processDelayedTasks(mockEnv, mockCtx);
|
||||
|
||||
expect(queueSendSpy).not.toHaveBeenCalled();
|
||||
expect(kvDeleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user