Skip to content

Commit dd1edbf

Browse files
committed
fix(db): measure the migration lock budget on a monotonic clock and never start an attempt past it
1 parent 3305fc2 commit dd1edbf

2 files changed

Lines changed: 35 additions & 9 deletions

File tree

‎packages/db/scripts/lock-timeout-retry.test.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ describe('retryOnLockTimeout', () => {
4444
expect(clock.sleep).toHaveBeenCalledTimes(19)
4545
})
4646

47-
it('throws the last lock timeout once the next retry would end past the budget', async () => {
47+
it('starts no attempt after the budget and throws the last lock timeout', async () => {
4848
const clock = fakeClock()
4949
const onRetry = vi.fn()
50+
const startedAt: number[] = []
5051
const attempt = vi.fn(async () => {
52+
startedAt.push(clock.now())
5153
clock.advance(5_000)
5254
throw pgError('55P03')
5355
})
@@ -62,13 +64,33 @@ describe('retryOnLockTimeout', () => {
6264
})
6365
).rejects.toMatchObject({ code: '55P03' })
6466

65-
expect(clock.now()).toBeLessThan(2 * 60_000)
66-
for (const [{ elapsedMs, delayMs }] of onRetry.mock.calls) {
67-
expect(elapsedMs + delayMs).toBeLessThan(2 * 60_000)
68-
}
67+
for (const start of startedAt) expect(start).toBeLessThan(2 * 60_000)
68+
/** The last attempt may run one lock timeout past the budget, never more. */
69+
expect(clock.now()).toBeLessThan(2 * 60_000 + 5_000)
6970
expect(attempt).toHaveBeenCalledTimes(onRetry.mock.calls.length + 1)
7071
})
7172

73+
it('does not start an attempt when a timer resolves after the budget', async () => {
74+
let nowMs = 0
75+
const attempt = vi.fn(async () => {
76+
nowMs += 1_000
77+
throw pgError('55P03')
78+
})
79+
80+
await expect(
81+
retryOnLockTimeout(attempt, {
82+
budgetMs: 60_000,
83+
backoff: BACKOFF,
84+
now: () => nowMs,
85+
/** The process stalls: the timer fires long after its delay. */
86+
sleep: async () => {
87+
nowMs += 120_000
88+
},
89+
})
90+
).rejects.toMatchObject({ code: '55P03' })
91+
expect(attempt).toHaveBeenCalledOnce()
92+
})
93+
7294
it('finds a lock timeout wrapped in a cause chain', async () => {
7395
const clock = fakeClock()
7496
let calls = 0

‎packages/db/scripts/lock-timeout-retry.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ export interface LockTimeoutRetryAttempt {
1515

1616
export interface LockTimeoutRetryOptions {
1717
/**
18-
* Total wall-clock time, measured from the first attempt, during which a lock
19-
* timeout is retried. A retry whose delay would end past the budget is not
20-
* started; the last lock timeout is thrown instead.
18+
* Time, measured on a monotonic clock from the first attempt, within which
19+
* attempts may start. No attempt starts once the budget has elapsed, whether
20+
* the backoff delay would end past it or a timer resolved late; the last lock
21+
* timeout is thrown instead. An attempt that starts in time can still run for
22+
* up to one `lock_timeout` past the budget.
2123
*/
2224
budgetMs: number
2325
backoff: { baseMs: number; maxMs: number }
2426
onRetry?: (attempt: LockTimeoutRetryAttempt) => void
27+
/** Monotonic milliseconds; defaults to `performance.now`, immune to wall-clock corrections. */
2528
now?: () => number
2629
sleep?: (ms: number) => Promise<void>
2730
}
@@ -43,7 +46,7 @@ export async function retryOnLockTimeout<T>(
4346
attempt: (attemptNumber: number) => Promise<T>,
4447
options: LockTimeoutRetryOptions
4548
): Promise<T> {
46-
const now = options.now ?? Date.now
49+
const now = options.now ?? (() => performance.now())
4750
const sleep = options.sleep ?? defaultSleep
4851
const startedAt = now()
4952
for (let attemptNumber = 1; ; attemptNumber++) {
@@ -61,6 +64,7 @@ export async function retryOnLockTimeout<T>(
6164
budgetMs: options.budgetMs,
6265
})
6366
await sleep(delayMs)
67+
if (now() - startedAt >= options.budgetMs) throw error
6468
}
6569
}
6670
}

0 commit comments

Comments
 (0)