mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
- Add two-phase idempotency with acquireLease and markProcessed using a single-key SET NX fast path
- Return { ack: false } on lock collisions to leave messages pending in the stream instead of acknowledging them
- Bound provider dispatch timeout within the lock lease window to prevent duplicate delivery under slow responses
- Replace per-message Redis MULTI/INCR retry keys with Redis Stream PEL delivery counts
- Handle LockHeldError in BaseWorker to skip message acknowledgement when another worker holds the lock
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { IdempotencyGuard } from "@/idempotency/index.js";
|
|
|
|
describe("IdempotencyGuard (src/idempotency/index.ts)", () => {
|
|
let mockRedis: any;
|
|
|
|
beforeEach(() => {
|
|
mockRedis = {
|
|
set: vi.fn(),
|
|
get: vi.fn(),
|
|
del: vi.fn(),
|
|
};
|
|
});
|
|
|
|
it("checkAndMark returns true on first call and sets key with NX and default TTL", async () => {
|
|
mockRedis.set.mockResolvedValueOnce("OK");
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const result = await guard.checkAndMark("msg-100");
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-100", "1", "EX", 86_400, "NX");
|
|
});
|
|
|
|
it("checkAndMark returns false when Redis SET NX returns null (already seen)", async () => {
|
|
mockRedis.set.mockResolvedValueOnce(null);
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const result = await guard.checkAndMark("msg-100");
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-100", "1", "EX", 86_400, "NX");
|
|
});
|
|
|
|
it("checkAndMark applies custom TTL when provided", async () => {
|
|
mockRedis.set.mockResolvedValueOnce("OK");
|
|
const guard = new IdempotencyGuard({
|
|
redis: mockRedis,
|
|
keyPrefix: "notif:idem",
|
|
ttlSeconds: 3600,
|
|
});
|
|
|
|
const result = await guard.checkAndMark("msg-200", 60);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-200", "1", "EX", 60, "NX");
|
|
});
|
|
|
|
it("markProcessed sets the key unconditionally with given or default TTL", async () => {
|
|
mockRedis.set.mockResolvedValueOnce("OK");
|
|
const guard = new IdempotencyGuard({
|
|
redis: mockRedis,
|
|
keyPrefix: "notif:idem",
|
|
ttlSeconds: 7200,
|
|
});
|
|
|
|
await guard.markProcessed("msg-300");
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-300", "1", "EX", 7200);
|
|
|
|
await guard.markProcessed("msg-300", 120);
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-300", "1", "EX", 120);
|
|
});
|
|
|
|
it("isProcessed returns true when key exists in Redis", async () => {
|
|
mockRedis.get.mockResolvedValueOnce("1");
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const processed = await guard.isProcessed("msg-400");
|
|
expect(processed).toBe(true);
|
|
expect(mockRedis.get).toHaveBeenCalledWith("notif:idem:msg-400");
|
|
});
|
|
|
|
it("isProcessed returns false when key does not exist (null)", async () => {
|
|
mockRedis.get.mockResolvedValueOnce(null);
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const processed = await guard.isProcessed("msg-missing");
|
|
expect(processed).toBe(false);
|
|
expect(mockRedis.get).toHaveBeenCalledWith("notif:idem:msg-missing");
|
|
});
|
|
|
|
it("unmark deletes the key from Redis", async () => {
|
|
mockRedis.del.mockResolvedValueOnce(1);
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
await guard.unmark("msg-rollback");
|
|
expect(mockRedis.del).toHaveBeenCalledWith("notif:idem:msg-rollback");
|
|
});
|
|
|
|
describe("acquireLease two-phase state", () => {
|
|
it("returns 'acquired' on first attempt via fast-path SET NX", async () => {
|
|
mockRedis.set.mockResolvedValueOnce("OK");
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const state = await guard.acquireLease("task-1", 30);
|
|
expect(state).toBe("acquired");
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:task-1", "L", "EX", 30, "NX");
|
|
});
|
|
|
|
it("returns 'completed' when key already marked completed ('S')", async () => {
|
|
mockRedis.set.mockResolvedValueOnce(null); // SET NX fails (key exists)
|
|
mockRedis.get.mockImplementation(async (key: string) => {
|
|
if (key === "notif:idem:task-2") return "S";
|
|
return null;
|
|
});
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const state = await guard.acquireLease("task-2", 30);
|
|
expect(state).toBe("completed");
|
|
});
|
|
|
|
it("returns 'locked' when lock lease is currently held ('L')", async () => {
|
|
mockRedis.set.mockResolvedValueOnce(null); // SET NX fails (held)
|
|
mockRedis.get.mockImplementation(async (key: string) => {
|
|
if (key === "notif:idem:task-3") return "L";
|
|
return null;
|
|
});
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
const state = await guard.acquireLease("task-3", 30);
|
|
expect(state).toBe("locked");
|
|
});
|
|
|
|
it("markProcessed records completed marker '1' on main key with given TTL", async () => {
|
|
mockRedis.set.mockResolvedValue("OK");
|
|
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
|
|
|
|
await guard.markProcessed("task-4", 86400);
|
|
|
|
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:task-4", "1", "EX", 86400);
|
|
});
|
|
});
|
|
});
|