Skip to content

Commit 0a195eb

Browse files
authored
feat: add failed query retries
1 parent a42d995 commit 0a195eb

File tree

4 files changed

+269
-44
lines changed

4 files changed

+269
-44
lines changed

README.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ Below is a table containing all of the possible configuration options for `serve
249249
| zombieMaxTimeout | `Integer` | The maximum number of seconds that a connection can stay idle before being recycled. | `900` |
250250
| zombieMinTimeout | `Integer` | The minimum number of *seconds* that a connection must be idle before the module will recycle it. | `3` |
251251
| returnFinalSqlQuery | `Boolean` | Flag indicating whether to attach the final SQL query (with substituted values) to the results. When enabled, the SQL query will be available as a non-enumerable `sql` property on array results or as a regular property on object results. | `false` |
252+
| maxQueryRetries | `Integer` | Maximum number of times to retry a query before giving up. | `0` |
253+
| queryRetryBackoff | `String` or `Function` | Backoff algorithm to be used when retrying queries. Possible values are `full` and `decorrelated`, or you can also specify your own algorithm. See [Connection Backoff](#connection-backoff) for more information. | `full` |
254+
| onQueryRetry | `function` | [Event](#events) callback when queries are retried. | |
252255

253256
### Connection Backoff
254257
If `manageConns` is not set to `false`, then this module will automatically kill idle connections or disconnect the current connection if the `connUtilization` limit is reached. Even with this aggressive strategy, it is possible that multiple functions will be competing for available connections. The `backoff` setting uses the strategy outlined [here](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) to use *Jitter* instead of *Exponential Backoff* when attempting connection retries.
@@ -323,6 +326,12 @@ The `onConnect` event recieves the MySQL `connection` object, `onKill` receives
323326
onRetry: (err,retries,delay,type) => { console.log('RETRY') }
324327
```
325328

329+
`onQueryRetry` also receives *four* arguments. The `error` object, the number of `retries`, the `delay` until the next retry, and the `backoff` algorithm used (`full`, `decorrelated` or `custom`).
330+
331+
```javascript
332+
onQueryRetry: (err,retries,delay,type) => { console.log('QUERY RETRY') }
333+
```
334+
326335
## MySQL Server Configuration
327336
There really isn't anything special that needs to be done in order for your MySQL server (including RDS, Aurora, and Aurora Serverless) to use `serverless-mysql`. You should just be aware of the following two scenarios.
328337

@@ -391,5 +400,25 @@ Other tests that use larger configurations were extremely successful too, but I'
391400
## Contributions
392401
Contributions, ideas and bug reports are welcome and greatly appreciated. Please add [issues](https://github.com/jeremydaly/serverless-mysql/issues) for suggestions and bug reports or create a pull request.
393402

394-
## TODO
395-
- Add connection retries on failed queries
403+
## Query Retries
404+
The module supports automatic retries for transient query errors. When a query fails with a retryable error (such as deadlocks, timeouts, or connection issues), the module will automatically retry the query using the configured backoff strategy.
405+
406+
By default, query retries are disabled (maxQueryRetries = 0) for backward compatibility with previous versions. To enable this feature, set `maxQueryRetries` to a value greater than 0.
407+
408+
You can configure the maximum number of retries with the `maxQueryRetries` option (default: 0) and the backoff strategy with the `queryRetryBackoff` option (default: 'full'). The module will use the same backoff algorithms as for connection retries.
409+
410+
```javascript
411+
const mysql = require('serverless-mysql')({
412+
config: {
413+
host: process.env.ENDPOINT,
414+
database: process.env.DATABASE,
415+
user: process.env.USERNAME,
416+
password: process.env.PASSWORD
417+
},
418+
maxQueryRetries: 5, // Enable query retries with 5 maximum attempts
419+
queryRetryBackoff: 'decorrelated',
420+
onQueryRetry: (err, retries, delay, type) => {
421+
console.log(`Retrying query after error: ${err.code}, attempt: ${retries}, delay: ${delay}ms`)
422+
}
423+
})
424+
```

index.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ declare namespace serverlessMysql {
9292
* This also attaches the SQL query to error objects when a query fails, making it easier to debug. false
9393
*/
9494
returnFinalSqlQuery?: boolean;
95+
/**
96+
* Integer Maximum number of times to retry a query before giving up. 0
97+
*/
98+
maxQueryRetries?: number;
99+
/**
100+
* String or Function Backoff algorithm to be used when retrying queries. Possible values are full and decorrelated, or you can also specify your own algorithm. full
101+
*/
102+
queryRetryBackoff?: string | Function;
103+
/**
104+
* function Event callback when queries are retried.
105+
*/
106+
onQueryRetry?: Function;
95107
};
96108

97109
class Transaction {

index.js

Lines changed: 88 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,27 @@ module.exports = (params) => {
3333
'ETIMEDOUT' // if the connection times out
3434
]
3535

36+
// Common Transient Query Errors that can be retried
37+
const retryableQueryErrors = [
38+
'ER_LOCK_DEADLOCK', // Deadlock found when trying to get lock
39+
'ER_LOCK_WAIT_TIMEOUT', // Lock wait timeout exceeded
40+
'ER_QUERY_INTERRUPTED', // Query execution was interrupted
41+
'ER_QUERY_TIMEOUT', // Query execution time exceeded
42+
'ER_CONNECTION_KILLED', // Connection was killed
43+
'ER_LOCKING_SERVICE_TIMEOUT', // Locking service timeout
44+
'ER_LOCKING_SERVICE_DEADLOCK', // Locking service deadlock
45+
'ER_ABORTING_CONNECTION', // Aborted connection
46+
'PROTOCOL_CONNECTION_LOST', // Connection lost
47+
'PROTOCOL_SEQUENCE_TIMEOUT', // Connection timeout
48+
'ETIMEDOUT', // Connection timeout
49+
'ECONNRESET' // Connection reset
50+
]
51+
3652
// Init setting values
3753
let MYSQL, manageConns, cap, base, maxRetries, connUtilization, backoff,
3854
zombieMinTimeout, zombieMaxTimeout, maxConnsFreq, usedConnsFreq,
39-
onConnect, onConnectError, onRetry, onClose, onError, onKill, onKillError, PromiseLibrary, returnFinalSqlQuery
55+
onConnect, onConnectError, onRetry, onClose, onError, onKill, onKillError, PromiseLibrary, returnFinalSqlQuery,
56+
maxQueryRetries, onQueryRetry, queryRetryBackoff
4057

4158
/********************************************************************/
4259
/** HELPER/CONVENIENCE FUNCTIONS **/
@@ -209,57 +226,79 @@ module.exports = (params) => {
209226

210227
// Main query function
211228
const query = async function (...args) {
212-
213229
// Establish connection
214230
await connect()
215231

216-
// Run the query
217-
return new PromiseLibrary((resolve, reject) => {
218-
if (client !== null) {
219-
// If no args are passed in a transaction, ignore query
220-
if (this && this.rollback && args.length === 0) { return resolve([]) }
232+
// Track query retries
233+
let queryRetries = 0
221234

222-
const queryObj = client.query(...args, async (err, results) => {
223-
if (returnFinalSqlQuery && queryObj.sql && err) {
224-
err.sql = queryObj.sql
225-
}
235+
// Function to execute the query with retry logic
236+
const executeQuery = async () => {
237+
return new PromiseLibrary((resolve, reject) => {
238+
if (client !== null) {
239+
// If no args are passed in a transaction, ignore query
240+
if (this && this.rollback && args.length === 0) { return resolve([]) }
226241

227-
if (err && err.code === 'PROTOCOL_SEQUENCE_TIMEOUT') {
228-
client.destroy() // destroy connection on timeout
229-
resetClient() // reset the client
230-
reject(err) // reject the promise with the error
231-
} else if (
232-
err && (/^PROTOCOL_ENQUEUE_AFTER_/.test(err.code)
233-
|| err.code === 'PROTOCOL_CONNECTION_LOST'
234-
|| err.code === 'EPIPE'
235-
|| err.code === 'ECONNRESET')
236-
) {
237-
resetClient() // reset the client
238-
return resolve(query(...args)) // attempt the query again
239-
} else if (err) {
240-
if (this && this.rollback) {
241-
await query('ROLLBACK')
242-
this.rollback(err)
242+
const queryObj = client.query(...args, async (err, results) => {
243+
if (returnFinalSqlQuery && queryObj.sql && err) {
244+
err.sql = queryObj.sql
243245
}
244-
reject(err)
245-
}
246246

247-
if (returnFinalSqlQuery && queryObj.sql) {
248-
if (Array.isArray(results)) {
249-
Object.defineProperty(results, 'sql', {
250-
enumerable: false,
251-
value: queryObj.sql
252-
})
253-
} else if (results && typeof results === 'object') {
254-
results.sql = queryObj.sql
247+
if (err && err.code === 'PROTOCOL_SEQUENCE_TIMEOUT') {
248+
client.destroy() // destroy connection on timeout
249+
resetClient() // reset the client
250+
reject(err) // reject the promise with the error
251+
} else if (
252+
err && (/^PROTOCOL_ENQUEUE_AFTER_/.test(err.code)
253+
|| err.code === 'PROTOCOL_CONNECTION_LOST'
254+
|| err.code === 'EPIPE'
255+
|| err.code === 'ECONNRESET')
256+
) {
257+
resetClient() // reset the client
258+
return resolve(query(...args)) // attempt the query again
259+
} else if (err && retryableQueryErrors.includes(err.code) && queryRetries < maxQueryRetries) {
260+
// Increment retry counter
261+
queryRetries++
262+
263+
// Calculate backoff time
264+
let wait = 0
265+
let sleep = queryRetryBackoff === 'decorrelated' ? decorrelatedJitter(wait) :
266+
typeof queryRetryBackoff === 'function' ? queryRetryBackoff(wait, queryRetries) :
267+
fullJitter()
268+
269+
// Fire onQueryRetry event
270+
onQueryRetry(err, queryRetries, sleep, typeof queryRetryBackoff === 'function' ? 'custom' : queryRetryBackoff)
271+
272+
// Wait and retry
273+
await delay(sleep)
274+
return resolve(executeQuery())
275+
} else if (err) {
276+
if (this && this.rollback) {
277+
await query('ROLLBACK')
278+
this.rollback(err)
279+
}
280+
reject(err)
255281
}
256-
}
257282

258-
return resolve(results)
259-
})
260-
}
261-
})
283+
if (returnFinalSqlQuery && queryObj.sql) {
284+
if (Array.isArray(results)) {
285+
Object.defineProperty(results, 'sql', {
286+
enumerable: false,
287+
value: queryObj.sql
288+
})
289+
} else if (results && typeof results === 'object') {
290+
results.sql = queryObj.sql
291+
}
292+
}
262293

294+
return resolve(results)
295+
})
296+
}
297+
})
298+
}
299+
300+
// Execute the query with retry logic
301+
return executeQuery()
263302
} // end query
264303

265304
// Change user method
@@ -449,6 +488,12 @@ module.exports = (params) => {
449488
usedConnsFreq = Number.isInteger(cfg.usedConnsFreq) ? cfg.usedConnsFreq : 0 // default to 0 ms
450489
returnFinalSqlQuery = cfg.returnFinalSqlQuery === true // default to false
451490

491+
// Query retry settings
492+
maxQueryRetries = Number.isInteger(cfg.maxQueryRetries) ? cfg.maxQueryRetries : 0 // default to 0 attempts (disabled for backward compatibility)
493+
queryRetryBackoff = typeof cfg.queryRetryBackoff === 'function' ? cfg.queryRetryBackoff :
494+
cfg.queryRetryBackoff && ['full', 'decorrelated'].includes(cfg.queryRetryBackoff.toLowerCase()) ?
495+
cfg.queryRetryBackoff.toLowerCase() : 'full' // default to full Jitter
496+
452497
// Event handlers
453498
onConnect = typeof cfg.onConnect === 'function' ? cfg.onConnect : () => { }
454499
onConnectError = typeof cfg.onConnectError === 'function' ? cfg.onConnectError : () => { }
@@ -457,6 +502,7 @@ module.exports = (params) => {
457502
onError = typeof cfg.onError === 'function' ? cfg.onError : () => { }
458503
onKill = typeof cfg.onKill === 'function' ? cfg.onKill : () => { }
459504
onKillError = typeof cfg.onKillError === 'function' ? cfg.onKillError : () => { }
505+
onQueryRetry = typeof cfg.onQueryRetry === 'function' ? cfg.onQueryRetry : () => { }
460506

461507
let connCfg = {}
462508

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
'use strict';
2+
3+
const { expect } = require('chai');
4+
const sinon = require('sinon');
5+
const {
6+
createTestConnection,
7+
setupTestTable,
8+
cleanupTestTable,
9+
closeConnection
10+
} = require('./helpers/setup');
11+
12+
describe('Query Retries Integration Tests', function () {
13+
this.timeout(15000);
14+
15+
let db;
16+
let originalQuery;
17+
let queryStub;
18+
let onQueryRetrySpy;
19+
const TEST_TABLE = 'retry_test_table';
20+
const TABLE_SCHEMA = `
21+
id INT AUTO_INCREMENT PRIMARY KEY,
22+
name VARCHAR(255) NOT NULL,
23+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
24+
`;
25+
26+
beforeEach(async function () {
27+
onQueryRetrySpy = sinon.spy();
28+
29+
db = createTestConnection({
30+
maxQueryRetries: 3,
31+
onQueryRetry: onQueryRetrySpy
32+
});
33+
34+
await setupTestTable(db, TEST_TABLE, TABLE_SCHEMA);
35+
36+
// Store the original query method
37+
originalQuery = db.getClient().query;
38+
});
39+
40+
afterEach(async function () {
41+
// Restore the original query method if it was stubbed
42+
if (queryStub && queryStub.restore) {
43+
queryStub.restore();
44+
}
45+
46+
try {
47+
await cleanupTestTable(db, TEST_TABLE);
48+
49+
if (db) {
50+
await db.end({ timeout: 5000 });
51+
await closeConnection(db);
52+
}
53+
} catch (err) {
54+
console.error('Error during cleanup:', err);
55+
}
56+
});
57+
58+
it('should retry queries that fail with retryable errors', async function () {
59+
// Create a counter to track the number of query attempts
60+
let attempts = 0;
61+
62+
// Stub the client's query method to simulate a deadlock error on first attempt
63+
queryStub = sinon.stub(db.getClient(), 'query').callsFake(function (sql, values, callback) {
64+
attempts++;
65+
66+
// If this is the first or second attempt, simulate a deadlock error
67+
if (attempts <= 2) {
68+
const error = new Error('Deadlock found when trying to get lock');
69+
error.code = 'ER_LOCK_DEADLOCK';
70+
71+
// Call the callback with the error
72+
if (typeof values === 'function') {
73+
values(error);
74+
} else {
75+
callback(error);
76+
}
77+
78+
// Return a mock query object
79+
return { sql: typeof sql === 'string' ? sql : sql.sql };
80+
}
81+
82+
// On the third attempt, succeed
83+
return originalQuery.apply(this, arguments);
84+
});
85+
86+
// Execute a query that should be retried
87+
await db.query('INSERT INTO retry_test_table (name) VALUES (?)', ['Test Retry']);
88+
89+
// Verify the query was attempted multiple times
90+
expect(attempts).to.be.at.least(3);
91+
92+
// Verify the onQueryRetry callback was called
93+
expect(onQueryRetrySpy.callCount).to.equal(2);
94+
95+
// Verify the data was actually inserted
96+
const result = await db.query('SELECT * FROM retry_test_table WHERE name = ?', ['Test Retry']);
97+
expect(result).to.have.lengthOf(1);
98+
expect(result[0].name).to.equal('Test Retry');
99+
});
100+
101+
it('should give up after maxQueryRetries attempts', async function () {
102+
// Create a counter to track the number of query attempts
103+
let attempts = 0;
104+
105+
// Stub the client's query method to always fail with a retryable error
106+
queryStub = sinon.stub(db.getClient(), 'query').callsFake(function (sql, values, callback) {
107+
attempts++;
108+
109+
const error = new Error('Lock wait timeout exceeded');
110+
error.code = 'ER_LOCK_WAIT_TIMEOUT';
111+
112+
// Call the callback with the error
113+
if (typeof values === 'function') {
114+
values(error);
115+
} else {
116+
callback(error);
117+
}
118+
119+
// Return a mock query object
120+
return { sql: typeof sql === 'string' ? sql : sql.sql };
121+
});
122+
123+
// Execute a query that should be retried but eventually fail
124+
try {
125+
await db.query('INSERT INTO retry_test_table (name) VALUES (?)', ['Should Fail']);
126+
expect.fail('Query should have failed after max retries');
127+
} catch (error) {
128+
// Verify the error is the expected one
129+
expect(error.code).to.equal('ER_LOCK_WAIT_TIMEOUT');
130+
131+
// Verify the query was attempted the maximum number of times (initial + retries)
132+
expect(attempts).to.equal(4); // 1 initial + 3 retries
133+
134+
// Verify the onQueryRetry callback was called the expected number of times
135+
expect(onQueryRetrySpy.callCount).to.equal(3);
136+
}
137+
});
138+
});

0 commit comments

Comments
 (0)