| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { applyDraftLock, type DraftLockRequest, type DraftLockStorage } from "./draft-claim-lock"; |
| 3 | import { FakeDraftClaimLock } from "./draft-claim-lock.fake"; |
| 4 | |
| 5 | /** Storage that yields on every call, so unserialized callers would interleave. */ |
| 6 | class YieldingStorage implements DraftLockStorage { |
| 7 | readonly values = new Map<string, unknown>(); |
| 8 | private async tick() { |
| 9 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 10 | } |
| 11 | async get<T>(key: string) { |
| 12 | await this.tick(); |
| 13 | return this.values.get(key) as T | undefined; |
| 14 | } |
| 15 | async put<T>(key: string, value: T) { |
| 16 | await this.tick(); |
| 17 | this.values.set(key, value); |
| 18 | } |
| 19 | async delete(key: string) { |
| 20 | await this.tick(); |
| 21 | return this.values.delete(key); |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | const claim = (token: string, action: "post" | "discard" = "post", leaseMs = 60_000): DraftLockRequest => ({ |
| 26 | op: "claim", |
| 27 | token, |
| 28 | action, |
| 29 | leaseMs, |
| 30 | }); |
| 31 | |
| 32 | describe("DraftClaimLock", () => { |
| 33 | it("grants exactly one of many concurrent claims", async () => { |
| 34 | const ns = new FakeDraftClaimLock(); |
| 35 | const lock = ns.get(ns.idFromName("draft-claim:triage:42")); |
| 36 | const results = await Promise.all( |
| 37 | Array.from({ length: 8 }, (_, i) => lock.act(claim(`post:${i}`))) |
| 38 | ); |
| 39 | expect(results.filter((r) => r.ok)).toHaveLength(1); |
| 40 | expect(results.filter((r) => !r.ok)).toEqual(Array(7).fill({ ok: false, holder: "post" })); |
| 41 | }); |
| 42 | |
| 43 | it("without the object's one-event-at-a-time gate, the same logic would double-grant", async () => { |
| 44 | // Documents why the lock must run inside the Durable Object: the fake's |
| 45 | // serialization is what the input gate provides in production. |
| 46 | const storage = new YieldingStorage(); |
| 47 | const results = await Promise.all([ |
| 48 | applyDraftLock(storage, 0, claim("post:a")), |
| 49 | applyDraftLock(storage, 0, claim("post:b")), |
| 50 | ]); |
| 51 | expect(results.every((r) => r.ok)).toBe(true); |
| 52 | }); |
| 53 | |
| 54 | it("keeps one lock per draft identity", async () => { |
| 55 | const ns = new FakeDraftClaimLock(); |
| 56 | const a = ns.get(ns.idFromName("draft-claim:triage:1")); |
| 57 | const b = ns.get(ns.idFromName("draft-claim:triage:2")); |
| 58 | expect(await a.act(claim("post:a"))).toEqual({ ok: true }); |
| 59 | expect(await b.act(claim("post:b"))).toEqual({ ok: true }); |
| 60 | expect(await ns.get(ns.idFromName("draft-claim:triage:1")).act(claim("post:c"))).toEqual({ |
| 61 | ok: false, |
| 62 | holder: "post", |
| 63 | }); |
| 64 | }); |
| 65 | |
| 66 | it("lets a lease expire so a crashed action does not wedge the draft", async () => { |
| 67 | const ns = new FakeDraftClaimLock(); |
| 68 | const lock = ns.get(ns.idFromName("k")); |
| 69 | expect(await lock.act(claim("post:a", "post", 15 * 60_000))).toEqual({ ok: true }); |
| 70 | ns.now += 15 * 60_000 - 1; |
| 71 | expect(await lock.act(claim("discard:b", "discard"))).toEqual({ ok: false, holder: "post" }); |
| 72 | ns.now += 1; |
| 73 | expect(await lock.act(claim("discard:b", "discard"))).toEqual({ ok: true }); |
| 74 | // The expired holder can no longer release the new holder's lease. |
| 75 | await lock.act({ op: "release", token: "post:a", holdMs: 0 }); |
| 76 | expect(await lock.act(claim("post:c"))).toEqual({ ok: false, holder: "discard" }); |
| 77 | }); |
| 78 | |
| 79 | it("frees the draft on release, and only for the holder's own token", async () => { |
| 80 | const ns = new FakeDraftClaimLock(); |
| 81 | const lock = ns.get(ns.idFromName("k")); |
| 82 | await lock.act(claim("post:a")); |
| 83 | await lock.act({ op: "release", token: "post:other", holdMs: 0 }); |
| 84 | expect(await lock.act(claim("post:b"))).toEqual({ ok: false, holder: "post" }); |
| 85 | await lock.act({ op: "release", token: "post:a", holdMs: 0 }); |
| 86 | expect(await lock.act(claim("post:b"))).toEqual({ ok: true }); |
| 87 | }); |
| 88 | |
| 89 | it("holds a recorded decision for holdMs, then frees it", async () => { |
| 90 | const ns = new FakeDraftClaimLock(); |
| 91 | const lock = ns.get(ns.idFromName("k")); |
| 92 | await lock.act(claim("discard:a", "discard", 15 * 60_000)); |
| 93 | await lock.act({ op: "release", token: "discard:a", holdMs: 120_000 }); |
| 94 | ns.now += 119_999; |
| 95 | expect(await lock.act(claim("post:b"))).toEqual({ ok: false, holder: "discard" }); |
| 96 | ns.now += 1; |
| 97 | expect(await lock.act(claim("post:b"))).toEqual({ ok: true }); |
| 98 | }); |
| 99 | }); |
| 100 |