|
| 1 | +import { describe, expect, it } from 'vitest' |
| 2 | +import { DeliveryConflictError, InMemoryDurableDeliveryStore, enqueueDurableDelivery } from './durableDelivery.js' |
| 3 | + |
| 4 | +const makeInput = (n: number, overrides: Record<string, unknown> = {}) => ({ |
| 5 | + tenantId: `tenant-${n % 3}`, eventKey: `event-${n}`, destination: `https://hooks.example.test/${n}`, |
| 6 | + body: JSON.stringify({ event: 'invoice.paid', id: n, amount: n * 10 }), maxAttempts: 3, baseDelayMs: 10, ...overrides, |
| 7 | +}) |
| 8 | + |
| 9 | +describe('durable delivery state transition matrix', () => { |
| 10 | + it.each(Array.from({ length: 15 }, (_, index) => index + 1))('keeps event %i pending until claimed', async n => { |
| 11 | + const store = new InMemoryDurableDeliveryStore() |
| 12 | + const record = await enqueueDurableDelivery(store, makeInput(n), 100) |
| 13 | + expect(record.status).toBe('pending') |
| 14 | + expect((await store.stats()).pending).toBe(1) |
| 15 | + }) |
| 16 | + |
| 17 | + it('prevents two workers from claiming a pending row concurrently', async () => { |
| 18 | + const store = new InMemoryDurableDeliveryStore() |
| 19 | + await enqueueDurableDelivery(store, makeInput(20), 0) |
| 20 | + const claims = await Promise.all([ |
| 21 | + store.claim('tenant-2', 'event-20', 'worker-a', 0), |
| 22 | + store.claim('tenant-2', 'event-20', 'worker-b', 0), |
| 23 | + ]) |
| 24 | + expect(claims.filter(Boolean)).toHaveLength(1) |
| 25 | + }) |
| 26 | + |
| 27 | + it('does not let a stale worker acknowledge a recovered lease', async () => { |
| 28 | + const store = new InMemoryDurableDeliveryStore() |
| 29 | + await enqueueDurableDelivery(store, makeInput(21), 0) |
| 30 | + const first = await store.claim('tenant-0', 'event-21', 'worker-a', 0, 5) |
| 31 | + const second = await store.claim('tenant-0', 'event-21', 'worker-b', 5, 5) |
| 32 | + expect(await store.complete(first!, 'worker-a', 6)).toBe(false) |
| 33 | + expect(await store.complete(second!, 'worker-b', 6)).toBe(true) |
| 34 | + }) |
| 35 | + |
| 36 | + it.each([ |
| 37 | + [1, 10], [2, 20], [3, 40], [4, 80], [5, 160], |
| 38 | + ])('uses bounded exponential delay after attempt %i', async (attempt, delay) => { |
| 39 | + const store = new InMemoryDurableDeliveryStore() |
| 40 | + await enqueueDurableDelivery(store, makeInput(22, { maxAttempts: 10, baseDelayMs: 10 }), 0) |
| 41 | + let claim = await store.claim('tenant-1', 'event-22', 'worker-0', 0) |
| 42 | + for (let index = 1; index < attempt; index++) { |
| 43 | + await store.fail(claim!, `failure-${index}`, 'x', claim!.nextAttemptAt) |
| 44 | + claim = await store.claim('tenant-1', 'event-22', `worker-${index}`, claim!.nextAttemptAt) |
| 45 | + } |
| 46 | + const failed = await store.fail(claim!, 'x', 1000) |
| 47 | + expect(failed?.nextAttemptAt).toBe(1000 + delay) |
| 48 | + }) |
| 49 | + |
| 50 | + it('does not claim retryable work before nextAttemptAt', async () => { |
| 51 | + const store = new InMemoryDurableDeliveryStore() |
| 52 | + await enqueueDurableDelivery(store, makeInput(23), 0) |
| 53 | + const claim = await store.claim('tenant-2', 'event-23', 'worker-a', 0) |
| 54 | + const failed = await store.fail(claim!, 'worker-a', 'timeout', 1) |
| 55 | + expect(await store.claim('tenant-2', 'event-23', 'worker-b', failed!.nextAttemptAt - 1)).toBeUndefined() |
| 56 | + }) |
| 57 | + |
| 58 | + it('does not permit completion after the lease expires', async () => { |
| 59 | + const store = new InMemoryDurableDeliveryStore() |
| 60 | + await enqueueDurableDelivery(store, makeInput(24), 0) |
| 61 | + const claim = await store.claim('tenant-0', 'event-24', 'worker-a', 0, 10) |
| 62 | + expect(await store.complete(claim!, 'worker-a', 11)).toBe(false) |
| 63 | + }) |
| 64 | + |
| 65 | + it('keeps delivered rows out of all future states', async () => { |
| 66 | + const store = new InMemoryDurableDeliveryStore() |
| 67 | + await enqueueDurableDelivery(store, makeInput(25), 0) |
| 68 | + const claim = await store.claim('tenant-1', 'event-25', 'worker-a', 0) |
| 69 | + await store.complete(claim!, 'worker-a', 1) |
| 70 | + expect(await store.claim('tenant-1', 'event-25', 'worker-b', 2)).toBeUndefined() |
| 71 | + expect(await store.fail(claim!, 'worker-a', 'late', 3)).toBeUndefined() |
| 72 | + }) |
| 73 | + |
| 74 | + it('makes only one successful record for duplicate enqueue races', async () => { |
| 75 | + const store = new InMemoryDurableDeliveryStore() |
| 76 | + const records = await Promise.all(Array.from({ length: 30 }, () => enqueueDurableDelivery(store, makeInput(26), 0))) |
| 77 | + expect(records).toHaveLength(30) |
| 78 | + expect((await store.stats()).pending).toBe(1) |
| 79 | + }) |
| 80 | + |
| 81 | + it('rejects changed payload in duplicate enqueue races', async () => { |
| 82 | + const store = new InMemoryDurableDeliveryStore() |
| 83 | + await enqueueDurableDelivery(store, makeInput(27), 0) |
| 84 | + await expect(enqueueDurableDelivery(store, makeInput(27, { body: '{"amount":999}' }), 0)).rejects.toBeInstanceOf(DeliveryConflictError) |
| 85 | + }) |
| 86 | + |
| 87 | + it('reports each lifecycle state in stats', async () => { |
| 88 | + const store = new InMemoryDurableDeliveryStore() |
| 89 | + await enqueueDurableDelivery(store, makeInput(28), 0) |
| 90 | + await enqueueDurableDelivery(store, makeInput(29), 0) |
| 91 | + const processing = await store.claim('tenant-1', 'event-28', 'worker-a', 0) |
| 92 | + const dead = await store.claim('tenant-2', 'event-29', 'worker-b', 0) |
| 93 | + await store.fail(dead!, 'worker-b', 'fatal', 0) |
| 94 | + await store.complete(processing!, 'worker-a', 1) |
| 95 | + expect(await store.stats()).toEqual({ pending: 0, processing: 0, retrying: 1, delivered: 1, dead: 0 }) |
| 96 | + }) |
| 97 | + |
| 98 | + it('requires a claimant for state transitions', async () => { |
| 99 | + const store = new InMemoryDurableDeliveryStore() |
| 100 | + await enqueueDurableDelivery(store, makeInput(30), 0) |
| 101 | + const claim = await store.claim('tenant-0', 'event-30', 'worker-a', 0) |
| 102 | + expect(await store.renew(claim!, 'other', 1)).toBe(false) |
| 103 | + expect(await store.fail(claim!, 'other', 'timeout', 1)).toBeUndefined() |
| 104 | + expect((await store.get('tenant-0', 'event-30'))?.status).toBe('processing') |
| 105 | + }) |
| 106 | + |
| 107 | + it('retains retry error context without retaining network URLs', async () => { |
| 108 | + const store = new InMemoryDurableDeliveryStore() |
| 109 | + await enqueueDurableDelivery(store, makeInput(31), 0) |
| 110 | + const claim = await store.claim('tenant-1', 'event-31', 'worker-a', 0) |
| 111 | + const failed = await store.fail(claim!, 'worker-a', 'POST https://internal/token timed out', 1) |
| 112 | + expect(failed?.lastError).toBe('POST [url] timed out') |
| 113 | + }) |
| 114 | + |
| 115 | + it('supports a fresh event after a previous event reaches dead state', async () => { |
| 116 | + const store = new InMemoryDurableDeliveryStore() |
| 117 | + await enqueueDurableDelivery(store, makeInput(32, { maxAttempts: 1 }), 0) |
| 118 | + const claim = await store.claim('tenant-2', 'event-32', 'worker-a', 0) |
| 119 | + await store.fail(claim!, 'worker-a', 'fatal', 1) |
| 120 | + const fresh = await enqueueDurableDelivery(store, makeInput(33), 1) |
| 121 | + expect(fresh.status).toBe('pending') |
| 122 | + expect((await store.stats()).dead).toBe(1) |
| 123 | + }) |
| 124 | +}) |
0 commit comments