-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(db): retry migration lock timeouts within a time budget instead of eight attempts #8190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
|
|
||
| import { retryOnLockTimeout } from '@sim/db/scripts/lock-timeout-retry' | ||
| import { describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const BACKOFF = { baseMs: 2_000, maxMs: 30_000 } as const | ||
|
|
||
| function pgError(code: string): Error { | ||
| return Object.assign(new Error(`postgres error ${code}`), { code }) | ||
| } | ||
|
|
||
| /** A fake clock that only advances when the retry loop sleeps. */ | ||
| function fakeClock() { | ||
| let nowMs = 0 | ||
| return { | ||
| now: () => nowMs, | ||
| sleep: vi.fn(async (ms: number) => { | ||
| nowMs += ms | ||
| }), | ||
| advance: (ms: number) => { | ||
| nowMs += ms | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| describe('retryOnLockTimeout', () => { | ||
| it('keeps retrying lock timeouts well past eight attempts while the budget lasts', async () => { | ||
| const clock = fakeClock() | ||
| let calls = 0 | ||
| const result = await retryOnLockTimeout( | ||
| async () => { | ||
| calls++ | ||
| clock.advance(5_000) | ||
| if (calls < 20) throw pgError('55P03') | ||
| return 'applied' | ||
| }, | ||
| { budgetMs: 20 * 60_000, backoff: BACKOFF, now: clock.now, sleep: clock.sleep } | ||
| ) | ||
|
|
||
| expect(result).toBe('applied') | ||
| expect(calls).toBe(20) | ||
| expect(clock.sleep).toHaveBeenCalledTimes(19) | ||
| }) | ||
|
|
||
| it('starts no attempt after the budget and throws the last lock timeout', async () => { | ||
| const clock = fakeClock() | ||
| const onRetry = vi.fn() | ||
| const startedAt: number[] = [] | ||
| const attempt = vi.fn(async () => { | ||
| startedAt.push(clock.now()) | ||
| clock.advance(5_000) | ||
| throw pgError('55P03') | ||
| }) | ||
|
|
||
| await expect( | ||
| retryOnLockTimeout(attempt, { | ||
| budgetMs: 2 * 60_000, | ||
| backoff: BACKOFF, | ||
| now: clock.now, | ||
| sleep: clock.sleep, | ||
| onRetry, | ||
| }) | ||
| ).rejects.toMatchObject({ code: '55P03' }) | ||
|
|
||
| for (const start of startedAt) expect(start).toBeLessThan(2 * 60_000) | ||
| /** The last attempt may run one lock timeout past the budget, never more. */ | ||
| expect(clock.now()).toBeLessThan(2 * 60_000 + 5_000) | ||
| expect(attempt).toHaveBeenCalledTimes(onRetry.mock.calls.length + 1) | ||
| }) | ||
|
|
||
| it('does not start an attempt when a timer resolves after the budget', async () => { | ||
| let nowMs = 0 | ||
| const attempt = vi.fn(async () => { | ||
| nowMs += 1_000 | ||
| throw pgError('55P03') | ||
| }) | ||
|
|
||
| await expect( | ||
| retryOnLockTimeout(attempt, { | ||
| budgetMs: 60_000, | ||
| backoff: BACKOFF, | ||
| now: () => nowMs, | ||
| /** The process stalls: the timer fires long after its delay. */ | ||
| sleep: async () => { | ||
| nowMs += 120_000 | ||
| }, | ||
| }) | ||
| ).rejects.toMatchObject({ code: '55P03' }) | ||
| expect(attempt).toHaveBeenCalledOnce() | ||
| }) | ||
|
|
||
| it('finds a lock timeout wrapped in a cause chain', async () => { | ||
| const clock = fakeClock() | ||
| let calls = 0 | ||
| await retryOnLockTimeout( | ||
| async () => { | ||
| calls++ | ||
| if (calls === 1) throw new Error('Failed query', { cause: pgError('55P03') }) | ||
| }, | ||
| { budgetMs: 60_000, backoff: BACKOFF, now: clock.now, sleep: clock.sleep } | ||
| ) | ||
|
|
||
| expect(calls).toBe(2) | ||
| }) | ||
|
|
||
| it('does not retry any other error', async () => { | ||
| const clock = fakeClock() | ||
| const attempt = vi.fn(async () => { | ||
| throw pgError('42P07') | ||
| }) | ||
|
|
||
| await expect( | ||
| retryOnLockTimeout(attempt, { | ||
| budgetMs: 60_000, | ||
| backoff: BACKOFF, | ||
| now: clock.now, | ||
| sleep: clock.sleep, | ||
| }) | ||
| ).rejects.toMatchObject({ code: '42P07' }) | ||
| expect(attempt).toHaveBeenCalledTimes(1) | ||
| expect(clock.sleep).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('passes the attempt number to each attempt', async () => { | ||
| const clock = fakeClock() | ||
| const seen: number[] = [] | ||
| await retryOnLockTimeout( | ||
| async (attemptNumber) => { | ||
| seen.push(attemptNumber) | ||
| if (attemptNumber < 3) throw pgError('55P03') | ||
| }, | ||
| { budgetMs: 60_000, backoff: BACKOFF, now: clock.now, sleep: clock.sleep } | ||
| ) | ||
|
|
||
| expect(seen).toEqual([1, 2, 3]) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { getPostgresErrorCode } from '@sim/utils/errors' | ||
| import { sleep as defaultSleep } from '@sim/utils/helpers' | ||
| import { backoffWithJitter } from '@sim/utils/retry' | ||
|
|
||
| /** SQLSTATE `lock_not_available`, raised when `lock_timeout` expires. */ | ||
| const LOCK_NOT_AVAILABLE = '55P03' | ||
|
|
||
| export interface LockTimeoutRetryAttempt { | ||
| /** The attempt that just failed, starting at 1. */ | ||
| attempt: number | ||
| delayMs: number | ||
| elapsedMs: number | ||
| budgetMs: number | ||
| } | ||
|
|
||
| export interface LockTimeoutRetryOptions { | ||
| /** | ||
| * Time, measured on a monotonic clock from the first attempt, within which | ||
| * attempts may start. No attempt starts once the budget has elapsed, whether | ||
| * the backoff delay would end past it or a timer resolved late; the last lock | ||
| * timeout is thrown instead. An attempt that starts in time can still run for | ||
| * up to one `lock_timeout` past the budget. | ||
| */ | ||
| budgetMs: number | ||
| backoff: { baseMs: number; maxMs: number } | ||
| onRetry?: (attempt: LockTimeoutRetryAttempt) => void | ||
| /** Monotonic milliseconds; defaults to `performance.now`, immune to wall-clock corrections. */ | ||
| now?: () => number | ||
| sleep?: (ms: number) => Promise<void> | ||
| } | ||
|
|
||
| /** | ||
| * Run `attempt` until it succeeds, retrying only lock timeouts (55P03, found | ||
| * anywhere in the wrapped `cause` chain) for up to `budgetMs`. | ||
| * | ||
| * DDL on a hot table needs an ACCESS EXCLUSIVE lock, which it can only take in | ||
| * a moment when no transaction holds any lock on the table. Each attempt must | ||
| * keep a short `lock_timeout`, because a queued ACCESS EXCLUSIVE request blocks | ||
| * every later query on the table for as long as it waits. Many short attempts | ||
| * spread over a long budget find such a moment without ever stalling traffic | ||
| * for more than one `lock_timeout`; a fixed attempt count gives up after a few | ||
| * minutes whenever the table is continuously held by transactions that each | ||
| * outlive the timeout. Any other error is thrown immediately. | ||
| */ | ||
| export async function retryOnLockTimeout<T>( | ||
| attempt: (attemptNumber: number) => Promise<T>, | ||
| options: LockTimeoutRetryOptions | ||
| ): Promise<T> { | ||
| const now = options.now ?? (() => performance.now()) | ||
| const sleep = options.sleep ?? defaultSleep | ||
| const startedAt = now() | ||
| for (let attemptNumber = 1; ; attemptNumber++) { | ||
| try { | ||
| return await attempt(attemptNumber) | ||
| } catch (error) { | ||
| if (getPostgresErrorCode(error) !== LOCK_NOT_AVAILABLE) throw error | ||
| const delayMs = backoffWithJitter(attemptNumber, null, options.backoff) | ||
| const elapsedMs = now() - startedAt | ||
| if (elapsedMs + delayMs >= options.budgetMs) throw error | ||
| options.onRetry?.({ | ||
| attempt: attemptNumber, | ||
| delayMs, | ||
| elapsedMs, | ||
| budgetMs: options.budgetMs, | ||
| }) | ||
| await sleep(delayMs) | ||
| if (now() - startedAt >= options.budgetMs) throw error | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.