Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/all-dingos-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@medusajs/modules-sdk": patch
"@medusajs/framework": patch
"@medusajs/utils": patch
---

fix(modules-sdk, framework): fail early when can't connect to the database
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,39 @@ describe("pgConnectionLoader", () => {
buildFakeConnection({ realError, rawError: timeoutError() })
)

await expect(pgConnectionLoader()).rejects.toMatchObject({
code: "ECONNREFUSED",
message: "Failed to connect to the database: connect ECONNREFUSED",
const error = await pgConnectionLoader().catch((e) => e)
expect(error.code).toBe("ECONNREFUSED")
expect(error.message).toContain(
"Failed to connect to the database: connect ECONNREFUSED"
)
expect(error.message).toContain(
"https://docs.medusajs.com/resources/troubleshooting/database-errors"
)
expect(mockContainer.register).not.toHaveBeenCalled()
})

it("fails fast on a fatal connection error without retrying", async () => {
const realError = Object.assign(
new Error(`database "medusa" does not exist`),
{ code: "3D000" }
)
const connection = buildFakeConnection({
realError,
rawError: timeoutError(),
})
mockCreatePgConnection.mockReturnValue(connection)

const error = await pgConnectionLoader().catch((e) => e)
expect(error.code).toBe("3D000")
expect(error.message).toContain(
`Failed to connect to the database: database "medusa" does not exist`
)
expect(error.message).toContain(
"https://docs.medusajs.com/resources/troubleshooting/database-errors"
)
// A permanent misconfiguration must not be retried.
expect(connection.raw).toHaveBeenCalledTimes(1)
expect(mockLogger.warn).not.toHaveBeenCalled()
expect(mockContainer.register).not.toHaveBeenCalled()
})

Expand All @@ -129,10 +158,13 @@ describe("pgConnectionLoader", () => {
mockCreatePgConnection.mockReturnValue(buildFakeConnection({ rawError }))

await expect(pgConnectionLoader()).rejects.toBe(rawError)
// The original error is untouched (no "Failed to connect" prefix).
expect(rawError.message).toBe(
// No "Failed to connect" prefix, but the troubleshooting link is appended.
expect(rawError.message).toContain(
"Knex: Timeout acquiring a connection. The pool is probably full."
)
expect(rawError.message).toContain(
"https://docs.medusajs.com/resources/troubleshooting/database-errors"
)
})

it("registers and returns the connection on success", async () => {
Expand Down
94 changes: 78 additions & 16 deletions packages/core/framework/src/database/pg-connection-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,29 @@ import {
ModulesSdkUtils,
retryExecution,
stringifyCircular,
withDbTroubleshootingLink,
} from "@medusajs/utils"
import { asValue } from "../deps/awilix"
import { configManager } from "../config"
import { container } from "../container"
import { logger } from "../logger"

/**
* PostgreSQL error codes that indicate a permanent misconfiguration. Retrying
* these is pointless and only delays surfacing the actual problem.
*/
const FATAL_PG_CONNECTION_ERROR_CODES = new Set([
"3D000", // database does not exist
"28P01", // invalid password
"28000", // invalid authorization specification
"42501", // insufficient privilege
])

const isFatalConnectionError = (error: unknown): boolean => {
const code = (error as { code?: string })?.code
return !!code && FATAL_PG_CONNECTION_ERROR_CODES.has(code)
}

/**
* Initialize a knex connection that can then be shared to any resources if needed
*/
Expand Down Expand Up @@ -70,10 +87,14 @@ export async function pgConnectionLoader(): Promise<
: 1000

let lastConnectionError: Error | undefined
let fatalConnectionError: Error | undefined
pgConnection.client?.pool?.on?.(
"createFail",
(_eventId: unknown, error: Error) => {
lastConnectionError = error
if (isFatalConnectionError(error)) {
fatalConnectionError = error
}
}
)

Expand All @@ -86,28 +107,69 @@ export async function pgConnectionLoader(): Promise<
return stringifyCircular(lastConnectionError ?? error)
}

/**
* A permanent misconfiguration (for example, a non-existent database or
* invalid credentials) surfaces on the pool's `createFail` event almost
* immediately, while `raw` stays pending until knex's acquire timeout (~60s)
* because of `propagateCreateError: false`. Reject as soon as such a fatal
* error is observed so the migrator/runtime doesn't wait for the full acquire
* timeout on every retry.
*/
const probeConnection = () => {
if (fatalConnectionError) {
return Promise.reject(fatalConnectionError)
}

return new Promise<void>((resolve, reject) => {
let settled = false
const settle = (fn: () => void) => {
if (settled) {
return
}
settled = true
pgConnection.client?.pool?.removeListener?.("createFail", onFatal)
fn()
}

const onFatal = (_eventId: unknown, error: Error) => {
if (isFatalConnectionError(error)) {
settle(() => reject(error))
}
}

pgConnection.client?.pool?.on?.("createFail", onFatal)

Promise.resolve(pgConnection.raw("SELECT 1"))
.then(() => settle(resolve))
.catch((error) => settle(() => reject(error)))
})
}

try {
await retryExecution(
async () => {
await pgConnection.raw("SELECT 1")
await retryExecution(probeConnection, {
maxRetries,
retryDelay,
shouldRetry: (error) =>
!isFatalConnectionError(error) && !fatalConnectionError,
onRetry: (error) => {
logger.warn(
`Pg connection failed to connect to the database. Retrying...\n${formatConnectionError(
error
)}`
)
},
{
maxRetries,
retryDelay,
onRetry: (error) => {
logger.warn(
`Pg connection failed to connect to the database. Retrying...\n${formatConnectionError(
error
)}`
)
},
}
)
})
} catch (error) {
if (lastConnectionError) {
lastConnectionError.message = `Failed to connect to the database: ${lastConnectionError.message}`
lastConnectionError.message = withDbTroubleshootingLink(
`Failed to connect to the database: ${lastConnectionError.message}`
)
throw lastConnectionError
}

if (error instanceof Error) {
error.message = withDbTroubleshootingLink(error.message)
}
throw error
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { MedusaError } from "@medusajs/utils"
import { verifyMigrationConnection } from "../medusa-app"

describe("verifyMigrationConnection", () => {
afterEach(() => {
delete process.env.MEDUSA_DB_MIGRATION_CONNECTION_TIMEOUT
})

it("resolves when the database responds", async () => {
const knex = {
raw: jest.fn().mockResolvedValue([{ "?column?": 1 }]),
}

await expect(
verifyMigrationConnection(knex as any)
).resolves.toBeUndefined()
expect(knex.raw).toHaveBeenCalledWith("SELECT 1")
})

it("throws an actionable error with a troubleshooting link when the connection hangs", async () => {
process.env.MEDUSA_DB_MIGRATION_CONNECTION_TIMEOUT = "50"

const knex = {
// Simulates a stalled connection that never resolves.
raw: jest.fn().mockReturnValue(new Promise(() => {})),
}

const error = await verifyMigrationConnection(knex as any).catch((e) => e)

expect(error).toBeInstanceOf(MedusaError)
expect(error.type).toEqual(MedusaError.Types.DB_ERROR)
expect(error.message).toContain("timed out")
expect(error.message).toContain(
"https://docs.medusajs.com/resources/troubleshooting/database-errors"
)
})

it("wraps a connection error with a troubleshooting link", async () => {
const knex = {
raw: jest
.fn()
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:5432")),
}

const error = await verifyMigrationConnection(knex as any).catch((e) => e)

expect(error).toBeInstanceOf(MedusaError)
expect(error.type).toEqual(MedusaError.Types.DB_ERROR)
expect(error.message).toContain("ECONNREFUSED")
expect(error.message).toContain(
"https://docs.medusajs.com/resources/troubleshooting/database-errors"
)
})
})
62 changes: 62 additions & 0 deletions packages/core/modules-sdk/src/medusa-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
ModulesSdkUtils,
promiseAll,
registerFeatureFlag,
withDbTroubleshootingLink,
DBTroubleshootingSection,
} from "@medusajs/utils"
import { Link } from "./link"
import {
Expand All @@ -45,6 +47,64 @@ import { MODULE_SCOPE } from "./types"

const LinkModulePackage = MODULE_PACKAGE_NAMES[Modules.LINK]

function getMigrationConnectionTimeout(): number {
return process.env.MEDUSA_DB_MIGRATION_CONNECTION_TIMEOUT
? parseInt(process.env.MEDUSA_DB_MIGRATION_CONNECTION_TIMEOUT)
: 10000
}

/**
* Verify that a database connection can be established before running
* migrations. Without this check, a stalled connection (for example, a wrong
* database URL or an SSL handshake that never completes) hangs the migrator
* indefinitely with no error. This fails fast with an actionable message
* instead.
*/
export async function verifyMigrationConnection(
knex: ReturnType<typeof ModulesSdkUtils.createPgConnection>
): Promise<void> {
const connectionTimeout = getMigrationConnectionTimeout()
let timeoutHandle: ReturnType<typeof setTimeout> | undefined

const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(
new MedusaError(
MedusaError.Types.DB_ERROR,
withDbTroubleshootingLink(
`Could not connect to the database while running migrations. The connection timed out after ${
connectionTimeout / 1000
} seconds, which usually indicates an incorrect database URL or an SSL configuration issue.`,
DBTroubleshootingSection.MIGRATIONS
)
)
)
}, connectionTimeout)
})

try {
await Promise.race([knex.raw("SELECT 1"), timeout])
} catch (error) {
if (error instanceof MedusaError) {
throw error
}

throw new MedusaError(
MedusaError.Types.DB_ERROR,
withDbTroubleshootingLink(
`Could not connect to the database while running migrations: ${
error?.message ?? error
}. This usually indicates an incorrect database URL or an SSL configuration issue.`,
DBTroubleshootingSection.MIGRATIONS
)
)
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
}
}

export type RunMigrationFn = (options?: {
allOrNothing?: boolean
}) => Promise<void>
Expand Down Expand Up @@ -589,6 +649,8 @@ async function MedusaApp_({

const concurrency = parseInt(process.env.DB_MIGRATION_CONCURRENCY ?? "1")
try {
await verifyMigrationConnection(lockKnex)

const results = await executeWithConcurrency(
moduleResolutions.map((a) => () => run(a)),
concurrency
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
DB_TROUBLESHOOTING_URL,
DBTroubleshootingSection,
withDbTroubleshootingLink,
} from "../db-troubleshooting"

describe("withDbTroubleshootingLink", () => {
it("appends the troubleshooting guide link to the message", () => {
const result = withDbTroubleshootingLink("Something went wrong")

expect(result).toContain("Something went wrong")
expect(result).toContain(
`See ${DB_TROUBLESHOOTING_URL} for troubleshooting steps.`
)
})

it("links to a specific section when provided", () => {
const result = withDbTroubleshootingLink(
"Migration failed",
DBTroubleshootingSection.MIGRATIONS
)

expect(result).toContain(
`${DB_TROUBLESHOOTING_URL}#${DBTroubleshootingSection.MIGRATIONS}`
)
})
})
30 changes: 30 additions & 0 deletions packages/core/utils/src/common/db-troubleshooting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Base URL of the database troubleshooting guide in the Medusa documentation.
*/
export const DB_TROUBLESHOOTING_URL =
"https://docs.medusajs.com/resources/troubleshooting/database-errors"

/**
* Anchors to specific sections of the database troubleshooting guide.
*/
export const DBTroubleshootingSection = {
MIGRATIONS: "error-while-running-migrations",
} as const

/**
* Append a link to the database troubleshooting guide to an error message, so
* connection failures point users to actionable next steps.
*
* @param message - The error message to append the link to.
* @param section - An optional anchor to a specific section of the guide.
*/
export function withDbTroubleshootingLink(
message: string,
section?: string
): string {
const url = section
? `${DB_TROUBLESHOOTING_URL}#${section}`
: DB_TROUBLESHOOTING_URL

return `${message}\n\nSee ${url} for troubleshooting steps.`
}
Loading
Loading