Skip to content

Commit 1b06656

Browse files
authored
Merge pull request #488 from pryv/fix/sqlite-busy
Fixing SQLITE_BUSY error thrown in multi-core configuration #487
2 parents 4608644 + f080ebb commit 1b06656

File tree

6 files changed

+152
-97
lines changed

6 files changed

+152
-97
lines changed

components/platform/src/DB.js

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const SQLite3 = require('better-sqlite3');
1010

1111
const { getLogger, getConfig } = require('@pryv/boiler');
1212
const logger = getLogger('platform:db');
13+
const concurrentSafeWrite = require('storage/src/sqliteUtils/concurrentSafeWrite');
1314

1415
class DB {
1516
db;
@@ -21,10 +22,11 @@ class DB {
2122
mkdirp.sync(basePath);
2223

2324
this.db = new SQLite3(basePath + '/platform-wide.db');
24-
this.db.pragma('journal_mode = WAL');
25-
26-
this.db.prepare('CREATE TABLE IF NOT EXISTS keyValue (key TEXT PRIMARY KEY, value TEXT NOT NULL);').run();
25+
await concurrentSafeWrite.initWALAndConcurrentSafeWriteCapabilities(this.db);
2726

27+
await concurrentSafeWrite.execute(() => {
28+
this.db.prepare('CREATE TABLE IF NOT EXISTS keyValue (key TEXT PRIMARY KEY, value TEXT NOT NULL);').run();
29+
});
2830
this.queries = {};
2931
this.queries.getValueWithKey = this.db.prepare('SELECT key, value FROM keyValue WHERE key = ?');
3032
this.queries.upsertUniqueKeyValue = this.db.prepare('INSERT OR REPLACE INTO keyValue (key, value) VALUES (@key, @value);');
@@ -52,46 +54,59 @@ class DB {
5254
}
5355

5456
/**
55-
*
5657
* @param {string} key
5758
* @param {string} value
5859
* @returns
5960
*/
60-
set (key, value) {
61+
async set (key, value) {
6162
logger.debug('set', key, value);
62-
return this.queries.upsertUniqueKeyValue.run({ key, value });
63+
let result;
64+
await concurrentSafeWrite.execute(() => {
65+
result = this.queries.upsertUniqueKeyValue.run({ key, value });
66+
});
67+
return result;
6368
}
6469

65-
delete (key) {
70+
/**
71+
* @param {string} key
72+
* @returns
73+
*/
74+
async delete (key) {
6675
logger.debug('delete', key);
67-
return this.queries.deleteWithKey.run(key);
76+
let result;
77+
await concurrentSafeWrite.execute(() => {
78+
result = this.queries.deleteWithKey.run(key);
79+
});
80+
return result;
6881
}
6982

70-
deleteAll () {
83+
async deleteAll () {
7184
logger.debug('deleteAll');
72-
this.queries.deleteAll.run();
85+
await concurrentSafeWrite.execute(() => {
86+
this.queries.deleteAll.run();
87+
});
7388
}
7489

7590
// ----- utilities ------- //
7691

7792
async setUserUniqueField (username, field, value) {
7893
const key = getUserUniqueKey(field, value);
79-
this.set(key, username);
94+
await this.set(key, username);
8095
}
8196

8297
async deleteUserUniqueField (field, value) {
8398
const key = getUserUniqueKey(field, value);
84-
this.delete(key);
99+
await this.delete(key);
85100
}
86101

87102
async setUserIndexedField (username, field, value) {
88103
const key = getUserIndexedKey(username, field);
89-
this.set(key, value);
104+
await this.set(key, value);
90105
}
91106

92107
async deleteUserIndexedField (username, field) {
93108
const key = getUserIndexedKey(username, field);
94-
this.delete(key);
109+
await this.delete(key);
95110
}
96111

97112
async getUserIndexedField (username, field) {

components/platform/src/Platform.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,15 @@ class Platform {
6161

6262
// for tests only - called by repository
6363
async deleteAll () {
64-
this.#db.deleteAll();
64+
await this.#db.deleteAll();
6565
}
6666

6767
/**
6868
* Get if value exists for this unique key (only test on local db)
6969
* Exposes directly a platform db method as it's needed by service_register in dnsLess mode
7070
*/
7171
async getLocalUsersUniqueField (field, value) {
72-
return this.#db.getUsersUniqueField(field, value);
72+
return await this.#db.getUsersUniqueField(field, value);
7373
}
7474

7575
/**
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright (C) 2012–2023 Pryv S.A. https://pryv.com - All Rights Reserved
4+
* Unauthorized copying of this file, via any medium is strictly prohibited
5+
* Proprietary and confidential
6+
*/
7+
8+
const WAIT_LIST_MS = [1, 2, 5, 10, 15, 20, 25, 25, 25, 50, 50, 100];
9+
const logger = require('@pryv/boiler').getLogger('sqliteConcurentWrites');
10+
const { setTimeout } = require('timers/promises');
11+
12+
module.exports = {
13+
execute,
14+
initWALAndConcurrentSafeWriteCapabilities
15+
};
16+
17+
/**
18+
* Init the given DB in WAL and unsafe mode, as we will take care of managing concurrent writes errors.
19+
*/
20+
async function initWALAndConcurrentSafeWriteCapabilities (db) {
21+
await execute(() => {
22+
db.pragma('journal_mode = WAL');
23+
});
24+
await execute(() => {
25+
db.pragma('busy_timeout = 0'); // We take care of busy timeout ourselves as long as current driver does not go below the second
26+
});
27+
await execute(() => {
28+
db.unsafeMode(true);
29+
});
30+
}
31+
32+
/**
33+
* Executes the given statement function, retrying `retries` times in case of `SQLITE_BUSY`.
34+
* This is CPU intensive, but tests have shown this solution to be efficient.
35+
*/
36+
async function execute (statement, retries = 100) {
37+
for (let i = 0; i < retries; i++) {
38+
try {
39+
statement();
40+
return;
41+
} catch (err) {
42+
if (err.code !== 'SQLITE_BUSY') {
43+
throw err;
44+
}
45+
const waitTime = i > (WAIT_LIST_MS.length - 1) ? 100 : WAIT_LIST_MS[i];
46+
await setTimeout(waitTime);
47+
logger.debug(`SQLITE_BUSY, retrying in ${waitTime} ms`);
48+
}
49+
}
50+
throw new Error(`Failed write action on SQLite after ${retries} retries`);
51+
}

components/storage/src/userSQLite/UserDatabase.js

Lines changed: 34 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Proprietary and confidential
66
*/
77

8+
const concurrentSafeWrite = require('../sqliteUtils/concurrentSafeWrite');
89
const SQLite3 = require('better-sqlite3');
910
const { Readable } = require('stream');
1011

@@ -22,8 +23,6 @@ const tables = {
2223

2324
const ALL_EVENTS_TAG = events.ALL_EVENTS_TAG;
2425

25-
const WAIT_LIST_MS = [1, 2, 5, 10, 15, 20, 25, 25, 25, 50, 50, 100];
26-
2726
/**
2827
* TODO: refactor the structure of tables and queries
2928
* (currently not consistent, either internally or with the Mongo code)
@@ -54,10 +53,7 @@ class UserDatabase {
5453
}
5554

5655
async init () {
57-
this.db.pragma('journal_mode = WAL');
58-
this.db.pragma('busy_timeout = 0'); // We take care of busy timeout ourselves as long as current driver does not go below the second
59-
this.db.unsafeMode(true);
60-
56+
await concurrentSafeWrite.initWALAndConcurrentSafeWriteCapabilities(this.db);
6157
// here we might want to skip DB initialization if version is not null
6258

6359
this.create = {};
@@ -66,7 +62,7 @@ class UserDatabase {
6662
this.delete = {};
6763

6864
// create all tables
69-
Object.keys(tables).forEach((tableName) => {
65+
for (const tableName of Object.keys(tables)) {
7066
const columnsTypes = [];
7167
const indexes = [];
7268
const columnNames = Object.keys(tables[tableName]);
@@ -76,20 +72,24 @@ class UserDatabase {
7672
if (column.index) { indexes.push(columnName); }
7773
});
7874

79-
this.db.prepare('CREATE TABLE IF NOT EXISTS events ( ' +
80-
columnsTypes.join(', ') +
81-
');').run();
82-
83-
indexes.forEach((columnName) => {
84-
this.db.prepare(`CREATE INDEX IF NOT EXISTS ${tableName}_${columnName} ON ${tableName}(${columnName})`).run();
75+
await concurrentSafeWrite.execute(() => {
76+
this.db.prepare('CREATE TABLE IF NOT EXISTS events ( ' +
77+
columnsTypes.join(', ') +
78+
');').run();
8579
});
8680

81+
for (const columnName of indexes) {
82+
await concurrentSafeWrite.execute(() => {
83+
this.db.prepare(`CREATE INDEX IF NOT EXISTS ${tableName}_${columnName} ON ${tableName}(${columnName})`).run();
84+
});
85+
}
86+
8787
this.create[tableName] = this.db.prepare(`INSERT INTO ${tableName} (` +
8888
columnNames.join(', ') + ') VALUES (@' +
8989
columnNames.join(', @') + ')');
9090

9191
this.getAll[tableName] = this.db.prepare(`SELECT * FROM ${tableName}`);
92-
});
92+
}
9393

9494
// -- create FTS for streamIds on events
9595
createFTSFor(this.db, 'events', tables.events, ['streamIds']);
@@ -114,9 +114,9 @@ class UserDatabase {
114114
eventForDb.eventid = eventId;
115115
const update = this.db.prepare(queryString);
116116

117-
await this.concurentSafeWriteStatement(() => {
117+
await concurrentSafeWrite.execute(() => {
118118
const res = update.run(eventForDb);
119-
this.logger.debug('UPDATE events changes:' + res.changes + ' eventId:' + eventId + ' event:' + JSON.stringify(eventForDb));
119+
this.logger.debug(`UPDATE events changes: ${res.changes} eventId: ${eventId} event: ${JSON.stringify(eventForDb)}`);
120120
if (res.changes !== 1) {
121121
throw new Error('Event not found');
122122
}
@@ -132,14 +132,14 @@ class UserDatabase {
132132
*/
133133
createEventSync (event) {
134134
const eventForDb = eventSchemas.eventToDB(event);
135-
this.logger.debug('(sync) CREATE event:' + JSON.stringify(eventForDb));
135+
this.logger.debug(`(sync) CREATE event: ${JSON.stringify(eventForDb)}`);
136136
this.create.events.run(eventForDb);
137137
}
138138

139139
async createEvent (event) {
140140
const eventForDb = eventSchemas.eventToDB(event);
141-
await this.concurentSafeWriteStatement(() => {
142-
this.logger.debug('(async) CREATE event:' + JSON.stringify(eventForDb));
141+
this.logger.debug(`(async) CREATE event: ${JSON.stringify(eventForDb)}`);
142+
await concurrentSafeWrite.execute(() => {
143143
this.create.events.run(eventForDb);
144144
});
145145
}
@@ -153,47 +153,47 @@ class UserDatabase {
153153
}
154154

155155
async deleteEventsHistory (eventId) {
156-
await this.concurentSafeWriteStatement(() => {
157-
this.logger.debug('(async) DELETE event history for eventId:' + eventId);
156+
this.logger.debug(`(async) DELETE event history for eventId: ${eventId}`);
157+
await concurrentSafeWrite.execute(() => {
158158
return this.delete.eventsByHeadId.run(eventId);
159159
});
160160
}
161161

162162
async minimizeEventHistory (eventId, fieldsToRemove) {
163163
const minimizeHistoryStatement = `UPDATE events SET ${fieldsToRemove.map(field => `${field} = ${field === 'streamIds' ? '\'' + ALL_EVENTS_TAG + '\'' : 'NULL'}`).join(', ')} WHERE headId = ?`;
164-
await this.concurentSafeWriteStatement(() => {
165-
this.logger.debug('(async) Minimize event history :' + minimizeHistoryStatement);
164+
this.logger.debug(`(async) Minimize event history: ${minimizeHistoryStatement}`);
165+
await concurrentSafeWrite.execute(() => {
166166
this.db.prepare(minimizeHistoryStatement).run(eventId);
167167
});
168168
}
169169

170170
async deleteEvents (params) {
171171
const queryString = prepareEventsDeleteQuery(params);
172172
if (queryString.indexOf('MATCH') > 0) {
173-
this.logger.debug('DELETE events one by one as queryString includes MATCH: ' + queryString);
173+
this.logger.debug(`DELETE events one by one as queryString includes MATCH: ${queryString}`);
174174
// SQLite does not know how to delete with "MATCH" statement
175175
// going by the doddgy task of getting events that matches the query and deleting them one by one
176176
const selectEventsToBeDeleted = prepareEventsGetQuery(params);
177177

178178
for (const event of this.db.prepare(selectEventsToBeDeleted).iterate()) {
179-
await this.concurentSafeWriteStatement(() => {
180-
this.logger.debug(' > DELETE event: ' + event.eventid);
179+
this.logger.debug(` > DELETE event: ${event.eventid}`);
180+
await concurrentSafeWrite.execute(() => {
181181
this.delete.eventById.run(event.eventid);
182182
});
183183
}
184184
return null;
185185
}
186186
// else
187187
let res = null;
188-
await this.concurentSafeWriteStatement(() => {
189-
this.logger.debug('DELETE events: ' + queryString);
188+
this.logger.debug(`DELETE events: ${queryString}`);
189+
await concurrentSafeWrite.execute(() => {
190190
res = this.db.prepare(queryString).run();
191191
});
192192
return res;
193193
}
194194

195195
getOneEvent (eventId) {
196-
this.logger.debug('GET ONE event: ' + eventId);
196+
this.logger.debug(`GET ONE event: ${eventId}`);
197197
const event = this.get.eventById.get(eventId);
198198
if (event == null) return null;
199199
return eventSchemas.eventFromDB(event);
@@ -202,7 +202,7 @@ class UserDatabase {
202202
getEvents (params) {
203203
const queryString = prepareEventsGetQuery(params);
204204

205-
this.logger.debug('GET Events:' + queryString);
205+
this.logger.debug(`GET Events: ${queryString}`);
206206
const res = this.db.prepare(queryString).all();
207207
if (res != null) {
208208
return res.map(eventSchemas.eventFromDB);
@@ -212,18 +212,18 @@ class UserDatabase {
212212

213213
getEventsStream (params) {
214214
const queryString = prepareEventsGetQuery(params);
215-
this.logger.debug('GET Events Stream: ' + queryString);
215+
this.logger.debug(`GET Events Stream: ${queryString}`);
216216
const query = this.db.prepare(queryString);
217217
return this.readableEventsStreamForIterator(query.iterate());
218218
}
219219

220220
getEventsDeletionsStream (deletedSince) {
221-
this.logger.debug('GET Events Deletions since: ' + deletedSince);
221+
this.logger.debug(`GET Events Deletions since: ${deletedSince}`);
222222
return this.readableEventsStreamForIterator(this.get.eventsDeletedSince.iterate(deletedSince));
223223
}
224224

225225
getEventsHistory (eventId) {
226-
this.logger.debug('GET Events History for: ' + eventId);
226+
this.logger.debug(`GET Events History for: ${eventId}`);
227227
return this.get.eventHistory.all(eventId).map(eventSchemas.historyEventFromDB);
228228
}
229229

@@ -254,31 +254,10 @@ class UserDatabase {
254254
close () {
255255
this.db.close();
256256
}
257-
258-
/**
259-
* Will look "retries" times, in case of "SQLITE_BUSY".
260-
* This is CPU intensive, but tests have shown this solution to be efficient
261-
*/
262-
async concurentSafeWriteStatement (statement, retries = 100) {
263-
for (let i = 0; i < retries; i++) {
264-
try {
265-
statement();
266-
return;
267-
} catch (error) {
268-
if (error.code !== 'SQLITE_BUSY') { // ignore
269-
throw error;
270-
}
271-
const waitTime = i > (WAIT_LIST_MS.length - 1) ? 100 : WAIT_LIST_MS[i];
272-
await new Promise((resolve) => setTimeout(resolve, waitTime));
273-
this.logger.debug('SQLITE_BUSY, retrying in ' + waitTime + 'ms');
274-
}
275-
}
276-
throw new Error('Failed write action on Audit after ' + retries + ' retries');
277-
}
278257
}
279258

280259
function prepareEventsDeleteQuery (params) {
281-
if (params.streams) { throw new Error('events DELETE with stream query not supported yet: ' + JSON.stringify(params)); }
260+
if (params.streams) { throw new Error(`Events DELETE with stream query not supported yet: ${JSON.stringify(params)}`); }
282261
return 'DELETE FROM events ' + prepareQuery(params, true);
283262
}
284263

0 commit comments

Comments
 (0)