|
3 | 3 | */ |
4 | 4 | import { sleep } from '@sim/utils/helpers' |
5 | 5 | import { afterEach, describe, expect, it, vi } from 'vitest' |
6 | | -import { __resetCoalesceLocallyForTests, coalesceLocally } from '@/lib/concurrency/singleflight' |
| 6 | +import { |
| 7 | + __resetCoalesceLocallyForTests, |
| 8 | + CoalesceSettleTimeoutError, |
| 9 | + coalesceLocally, |
| 10 | +} from '@/lib/concurrency/singleflight' |
7 | 11 |
|
8 | 12 | afterEach(() => { |
9 | 13 | __resetCoalesceLocallyForTests() |
@@ -57,9 +61,103 @@ describe('coalesceLocally', () => { |
57 | 61 | await expect(coalesceLocally('rejection', fn)).rejects.toThrow('fail 2') |
58 | 62 | }) |
59 | 63 |
|
| 64 | + it('surfaces a synchronously-thrown fn error and evicts the entry', async () => { |
| 65 | + const fn = vi.fn((): Promise<string> => { |
| 66 | + throw new Error('sync boom') |
| 67 | + }) |
| 68 | + |
| 69 | + // The real error must surface (not a TDZ ReferenceError from the evict |
| 70 | + // closure) and the entry must be evicted so the next call retries. |
| 71 | + await expect(coalesceLocally('sync-throw', fn)).rejects.toThrow('sync boom') |
| 72 | + await expect(coalesceLocally('sync-throw', fn)).rejects.toThrow('sync boom') |
| 73 | + expect(fn).toHaveBeenCalledTimes(2) |
| 74 | + }) |
| 75 | + |
60 | 76 | it('does not coalesce across distinct keys', async () => { |
61 | 77 | const fn = vi.fn(async () => 'value') |
62 | 78 | await Promise.all([coalesceLocally('a', fn), coalesceLocally('b', fn)]) |
63 | 79 | expect(fn).toHaveBeenCalledTimes(2) |
64 | 80 | }) |
| 81 | + |
| 82 | + it('rejects all awaiters and evicts the entry when the producer misses the settle deadline', async () => { |
| 83 | + vi.useFakeTimers() |
| 84 | + try { |
| 85 | + let resolveHung: (value: string) => void |
| 86 | + const hung = vi.fn( |
| 87 | + () => |
| 88 | + new Promise<string>((resolve) => { |
| 89 | + resolveHung = resolve |
| 90 | + }) |
| 91 | + ) |
| 92 | + |
| 93 | + const a = coalesceLocally('wedged', hung) |
| 94 | + const b = coalesceLocally('wedged', hung) |
| 95 | + const aAssertion = expect(a).rejects.toBeInstanceOf(CoalesceSettleTimeoutError) |
| 96 | + const bAssertion = expect(b).rejects.toBeInstanceOf(CoalesceSettleTimeoutError) |
| 97 | + |
| 98 | + await vi.advanceTimersByTimeAsync(30_000) |
| 99 | + await aAssertion |
| 100 | + await bAssertion |
| 101 | + expect(hung).toHaveBeenCalledTimes(1) |
| 102 | + |
| 103 | + const fresh = vi.fn(async () => 'recovered') |
| 104 | + await expect(coalesceLocally('wedged', fresh)).resolves.toBe('recovered') |
| 105 | + expect(fresh).toHaveBeenCalledTimes(1) |
| 106 | + |
| 107 | + resolveHung!('late') |
| 108 | + } finally { |
| 109 | + vi.useRealTimers() |
| 110 | + } |
| 111 | + }) |
| 112 | + |
| 113 | + it('a timed-out producer settling late does not evict its successor', async () => { |
| 114 | + vi.useFakeTimers() |
| 115 | + try { |
| 116 | + let resolveOld: (value: string) => void |
| 117 | + const old = coalesceLocally( |
| 118 | + 'late-settle', |
| 119 | + () => |
| 120 | + new Promise<string>((resolve) => { |
| 121 | + resolveOld = resolve |
| 122 | + }), |
| 123 | + 1_000 |
| 124 | + ) |
| 125 | + const oldAssertion = expect(old).rejects.toBeInstanceOf(CoalesceSettleTimeoutError) |
| 126 | + await vi.advanceTimersByTimeAsync(1_000) |
| 127 | + await oldAssertion |
| 128 | + |
| 129 | + let resolveNew: (value: string) => void |
| 130 | + const successor = coalesceLocally( |
| 131 | + 'late-settle', |
| 132 | + () => |
| 133 | + new Promise<string>((resolve) => { |
| 134 | + resolveNew = resolve |
| 135 | + }) |
| 136 | + ) |
| 137 | + |
| 138 | + resolveOld!('late') |
| 139 | + await vi.advanceTimersByTimeAsync(0) |
| 140 | + |
| 141 | + const joined = coalesceLocally('late-settle', async () => 'should-not-run') |
| 142 | + expect(joined).toBe(successor) |
| 143 | + |
| 144 | + resolveNew!('new-value') |
| 145 | + await expect(successor).resolves.toBe('new-value') |
| 146 | + } finally { |
| 147 | + vi.useRealTimers() |
| 148 | + } |
| 149 | + }) |
| 150 | + |
| 151 | + it('does not fire the deadline for producers that settle in time', async () => { |
| 152 | + vi.useFakeTimers() |
| 153 | + try { |
| 154 | + const value = await coalesceLocally('prompt', async () => 'ok', 1_000) |
| 155 | + expect(value).toBe('ok') |
| 156 | + |
| 157 | + await vi.advanceTimersByTimeAsync(2_000) |
| 158 | + await expect(coalesceLocally('prompt', async () => 'again', 1_000)).resolves.toBe('again') |
| 159 | + } finally { |
| 160 | + vi.useRealTimers() |
| 161 | + } |
| 162 | + }) |
65 | 163 | }) |
0 commit comments