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
19 changes: 10 additions & 9 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1041,15 +1041,16 @@ try {

### PostgreSQL Connection Errors

| Connection Errors | Description |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ERR_POSTGRES_CONNECTION_CLOSED` | An established connection was terminated |
| `ERR_POSTGRES_CONNECTION_FAILED` | Connection could not be established (refused, or socket closed before the handshake completed, e.g. an intermediary accepted then closed); errors the server sends during startup, like `57P03`, surface as `ERR_POSTGRES_SERVER_ERROR` |
| `ERR_POSTGRES_CONNECTION_TIMEOUT` | Failed to establish connection within timeout period |
| `ERR_POSTGRES_IDLE_TIMEOUT` | Connection closed due to inactivity |
| `ERR_POSTGRES_LIFETIME_TIMEOUT` | Connection exceeded maximum lifetime |
| `ERR_POSTGRES_TLS_NOT_AVAILABLE` | SSL/TLS connection not available |
| `ERR_POSTGRES_TLS_UPGRADE_FAILED` | Failed to upgrade connection to SSL/TLS |
| Connection Errors | Description |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ERR_POSTGRES_CONNECTION_CLOSED` | An established connection was terminated |
| `ERR_POSTGRES_CONNECTION_FAILED` | Connection was accepted but closed before the handshake completed (e.g. the server is still starting up). Retried with backoff until `connectionTimeout` while queries are waiting. Note: errors the server sends during startup, like `57P03`, surface as `ERR_POSTGRES_SERVER_ERROR` |
| `ERR_POSTGRES_CONNECTION_REFUSED` | Connection was refused because nothing is listening at the address. Fails immediately and is not retried |
| `ERR_POSTGRES_CONNECTION_TIMEOUT` | Failed to establish connection within timeout period |
| `ERR_POSTGRES_IDLE_TIMEOUT` | Connection closed due to inactivity |
| `ERR_POSTGRES_LIFETIME_TIMEOUT` | Connection exceeded maximum lifetime |
| `ERR_POSTGRES_TLS_NOT_AVAILABLE` | SSL/TLS connection not available |
| `ERR_POSTGRES_TLS_UPGRADE_FAILED` | Failed to upgrade connection to SSL/TLS |

### Authentication Errors

Expand Down
100 changes: 99 additions & 1 deletion src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,12 @@ class PooledMySQLConnection {
flags: number = 0;
/// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries
queryCount: number = 0;
/// when the current connect cycle started; 0 when not connecting. Connect
/// failures (server not yet accepting connections) are retried until
/// connectionTimeout elapses from this point.
connectStartedAt: number = 0;
connectAttempts: number = 0;
retryTimer: ReturnType<typeof setTimeout> | null = null;

#onConnected(err, connection) {
if (err) {
Expand All @@ -318,6 +324,7 @@ class PooledMySQLConnection {
}
this.storedError = err;
if (!err) {
this.connectStartedAt = 0;
this.flags |= PooledConnectionFlags.canBeConnected;
}
this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected;
Expand All @@ -343,12 +350,40 @@ class PooledMySQLConnection {
if (err) {
err = wrapError(err);
}
this.connection = null;
this.storedError = err;
if (this.#shouldRetryConnecting(err)) {
// The server is not accepting connections yet (e.g. still starting
// up). Keep the slot pending and retry with backoff instead of
// failing the queries that are waiting for a connection. The user's
// onclose callback only fires when the slot actually closes.
this.connectAttempts++;
const delay = Math.min(20 * 2 ** this.connectAttempts, 1000);
this.retryTimer = setTimeout(PooledMySQLConnection.#retryTimerFired, delay, this);
return;
}
// this connect cycle is over; a later retry() starts a fresh one
this.connectStartedAt = 0;
this.#finishClose(err);
}

static #retryTimerFired(self: PooledMySQLConnection) {
self.retryTimer = null;
// conditions may have changed during the backoff (pool closing, waiters
// gone, retry budget elapsed), so re-check before dialing
if (self.#canKeepRetrying()) {
self.#startConnection();
} else {
self.#finishClose(self.storedError);
}
}

#finishClose(err) {
const connectionInfo = this.connectionInfo;
if (connectionInfo?.onclose) {
connectionInfo.onclose(err);
}
this.state = PooledConnectionState.closed;
this.connection = null;
this.storedError = err;

// remove from ready connections if its there
Expand Down Expand Up @@ -378,9 +413,60 @@ class PooledMySQLConnection {
}

#startConnection() {
if (this.connectStartedAt === 0) {
this.connectStartedAt = Date.now();
this.connectAttempts = 0;
}
PooledMySQLConnection.createConnection(this.connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this));
}

/// Connect failures (ERR_MYSQL_CONNECTION_FAILED) mean the server
/// accepted the TCP connection but closed it before the handshake
/// completed — typically it is still starting up, or an intermediary
/// (like a container port proxy) is up before the database is. Those are
/// retried until connectionTimeout elapses, as long as queries are
/// waiting on the pool. Refused connections
/// (ERR_MYSQL_CONNECTION_REFUSED) fail fast: nothing is listening,
/// and probes/healthchecks rely on the immediate error. Real server errors (authentication,
/// handshake errors) and closes of established connections are not
/// retried here.
#shouldRetryConnecting(err: Error | null): boolean {
// connect failures come from the native layer as options objects that
// wrapError turned into MySQLError instances with a typed code
if (!(err instanceof MySQLError) || err.code !== "ERR_MYSQL_CONNECTION_FAILED") {
return false;
}
return this.#canKeepRetrying();
}

#canKeepRetrying(): boolean {
if (this.adapter.closed || this.onFinish !== null) {
return false;
}
// only retry while queries are actually waiting for a connection
if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) {
return false;
}
// an explicit connectionTimeout of 0 disables the connect timer, and with
// it the retry budget
const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000;
if (connectionTimeout <= 0) {
return false;
}
return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout;
}

/// Returns true if a scheduled connect retry was cancelled — in that case
/// nothing is in flight and no onClose/onConnected callback will fire.
cancelRetry(): boolean {
if (this.retryTimer !== null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
return true;
}
return false;
}

onClose(onClose: (err: Error) => void) {
this.queries.add(onClose);
}
Expand All @@ -396,6 +482,7 @@ class PooledMySQLConnection {
}
// reset error and state
this.storedError = null;
this.connectStartedAt = 0;
this.state = PooledConnectionState.pending;
// retry connection
this.#startConnection();
Expand Down Expand Up @@ -672,6 +759,11 @@ class MySQLAdapter
for (const pending of reservedQueue) {
pending(connection.storedError, connection);
}
// draining the queues may have been the last pending work — a
// graceful close() is waiting on this callback
if (this.onAllQueriesFinished && !this.hasPendingQueries()) {
this.onAllQueriesFinished();
}
}
return;
}
Expand Down Expand Up @@ -766,6 +858,12 @@ class MySQLAdapter
switch (connection.state) {
case PooledConnectionState.pending:
{
if (connection.cancelRetry()) {
// a connect retry was scheduled; nothing is in flight so
// there is no onClose/onConnected to wait for
connection.state = PooledConnectionState.closed;
break;
}
const { promise, resolve } = Promise.withResolvers();
connection.onFinish = resolve;
promises.push(promise);
Expand Down
100 changes: 99 additions & 1 deletion src/js/internal/sql/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,12 @@ class PooledPostgresConnection {
flags: number = 0;
/// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries
queryCount: number = 0;
/// when the current connect cycle started; 0 when not connecting. Connect
/// failures (server not yet accepting connections) are retried until
/// connectionTimeout elapses from this point.
connectStartedAt: number = 0;
connectAttempts: number = 0;
retryTimer: ReturnType<typeof setTimeout> | null = null;

#onConnected(err, _) {
if (err) {
Expand All @@ -541,6 +547,7 @@ class PooledPostgresConnection {
}
this.storedError = err;
if (!err) {
this.connectStartedAt = 0;
this.flags |= PooledConnectionFlags.canBeConnected;
}
this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected;
Expand All @@ -566,12 +573,40 @@ class PooledPostgresConnection {
if (err) {
err = wrapPostgresError(err);
}
this.connection = null;
this.storedError = err;
if (this.#shouldRetryConnecting(err)) {
// The server is not accepting connections yet (e.g. still starting
// up). Keep the slot pending and retry with backoff instead of
// failing the queries that are waiting for a connection. The user's
// onclose callback only fires when the slot actually closes.
this.connectAttempts++;
const delay = Math.min(20 * 2 ** this.connectAttempts, 1000);
this.retryTimer = setTimeout(PooledPostgresConnection.#retryTimerFired, delay, this);
return;
}
// this connect cycle is over; a later retry() starts a fresh one
this.connectStartedAt = 0;
this.#finishClose(err);
}

static #retryTimerFired(self: PooledPostgresConnection) {
self.retryTimer = null;
// conditions may have changed during the backoff (pool closing, waiters
// gone, retry budget elapsed), so re-check before dialing
if (self.#canKeepRetrying()) {
self.#startConnection();
} else {
self.#finishClose(self.storedError);
}
}

#finishClose(err) {
const connectionInfo = this.connectionInfo;
if (connectionInfo?.onclose) {
connectionInfo.onclose(err);
}
this.state = PooledConnectionState.closed;
this.connection = null;
this.storedError = err;

// remove from ready connections if its there
Expand Down Expand Up @@ -601,13 +636,64 @@ class PooledPostgresConnection {
}

async #startConnection() {
if (this.connectStartedAt === 0) {
this.connectStartedAt = Date.now();
this.connectAttempts = 0;
}
this.connection = await PooledPostgresConnection.createConnection(
this.connectionInfo,
this.#onConnected.bind(this),
this.#onClose.bind(this),
);
}

/// Connect failures (ERR_POSTGRES_CONNECTION_FAILED) mean the server
/// accepted the TCP connection but closed it before the handshake
/// completed — typically it is still starting up, or an intermediary
/// (like a container port proxy) is up before the database is. Those are
/// retried until connectionTimeout elapses, as long as queries are
/// waiting on the pool. Refused connections
/// (ERR_POSTGRES_CONNECTION_REFUSED) fail fast: nothing is listening,
/// and probes/healthchecks rely on the immediate error. Real server errors (authentication,
/// ErrorResponse during startup) and closes of established connections are
/// not retried here.
#shouldRetryConnecting(err: Error | null): boolean {
// connect failures come from the native layer as options objects that
// wrapPostgresError turned into PostgresError instances with a typed code
if (!(err instanceof PostgresError) || err.code !== "ERR_POSTGRES_CONNECTION_FAILED") {
return false;
}
return this.#canKeepRetrying();
}

#canKeepRetrying(): boolean {
if (this.adapter.closed || this.onFinish !== null) {
return false;
}
// only retry while queries are actually waiting for a connection
if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) {
return false;
}
// an explicit connectionTimeout of 0 disables the connect timer, and with
// it the retry budget
const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000;
if (connectionTimeout <= 0) {
return false;
}
return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout;
}

/// Returns true if a scheduled connect retry was cancelled — in that case
/// nothing is in flight and no onClose/onConnected callback will fire.
cancelRetry(): boolean {
if (this.retryTimer !== null) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
return true;
}
return false;
}

onClose(onClose: (err: Error) => void) {
this.queries.add(onClose);
}
Expand All @@ -623,6 +709,7 @@ class PooledPostgresConnection {
}
// reset error and state
this.storedError = null;
this.connectStartedAt = 0;
this.state = PooledConnectionState.pending;
// retry connection
this.#startConnection();
Expand Down Expand Up @@ -909,6 +996,11 @@ class PostgresAdapter
for (const pending of reservedQueue) {
pending(connection.storedError, connection);
}
// draining the queues may have been the last pending work — a
// graceful close() is waiting on this callback
if (this.onAllQueriesFinished && !this.hasPendingQueries()) {
this.onAllQueriesFinished();
}
}
return;
}
Expand Down Expand Up @@ -1003,6 +1095,12 @@ class PostgresAdapter
switch (connection.state) {
case PooledConnectionState.pending:
{
if (connection.cancelRetry()) {
// a connect retry was scheduled; nothing is in flight so
// there is no onClose/onConnected to wait for
connection.state = PooledConnectionState.closed;
break;
}
const { promise, resolve } = Promise.withResolvers();
connection.onFinish = resolve;
promises.push(promise);
Expand Down
Loading
Loading