Skip to content

Commit eb6aa7c

Browse files
committed
perf: back up directly into a preallocated export buffer
1 parent 5adeaa9 commit eb6aa7c

4 files changed

Lines changed: 100 additions & 6 deletions

File tree

‎.github/workflows/CI.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on: [push, pull_request]
44

55
permissions:
66
contents: read
7+
actions: read
78

89
jobs:
910
build:
@@ -24,6 +25,14 @@ jobs:
2425
if-no-files-found: error
2526
- name: test WebAssembly builds and distribution behavior
2627
run: npm ci && npm test
28+
# One-off comparison; remove after recording the optimization results.
29+
- name: Compare optimized backup export with both baselines
30+
env:
31+
GH_TOKEN: ${{ github.token }}
32+
run: |
33+
gh run download 40301139618 --repo sql-js/sql.js --name dist --dir "$RUNNER_TEMP/serialize-dist"
34+
gh run download 40304818305 --repo sql-js/sql.js --name dist --dir "$RUNNER_TEMP/backup-before-dist"
35+
node test/compare_exports.cjs "$RUNNER_TEMP/serialize-dist" dist "$RUNNER_TEMP/backup-before-dist"
2736
- name: generate documentation
2837
run: npm run doc
2938
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2

‎src/api.js‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,23 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
11441144
var destination = NULL;
11451145
var backup = NULL;
11461146
try {
1147+
var file = FS.lookupPath(filename).node;
1148+
var binaryDb = null;
1149+
var write = file.stream_ops.write;
1150+
file.stream_ops = Object.create(file.stream_ops);
1151+
file.stream_ops.write = function writeExport(
1152+
stream, buffer, offset, length, position
1153+
) {
1154+
if (binaryDb === null) {
1155+
return write(stream, buffer, offset, length, position);
1156+
}
1157+
// Write directly into the final JS-owned result buffer.
1158+
// MEMFS would otherwise replace it on the first write.
1159+
file.contents.set(buffer.subarray(offset, offset + length),
1160+
position);
1161+
file.usedBytes = Math.max(file.usedBytes, position + length);
1162+
return length;
1163+
};
11471164
var rc = sqlite3_open(filename, apiTemp);
11481165
destination = getValue(apiTemp, "i32");
11491166
if (rc !== SQLITE_OK) {
@@ -1153,7 +1170,18 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
11531170
if (backup === NULL) {
11541171
throw new Error(sqlite3_errmsg(destination));
11551172
}
1156-
rc = sqlite3_backup_step(backup, -1);
1173+
// Initialize the backup and reject writers before allocating.
1174+
rc = sqlite3_backup_step(backup, 0);
1175+
if (rc === SQLITE_OK) {
1176+
var sizes = this.exec(
1177+
"PRAGMA main.page_count; PRAGMA main.page_size"
1178+
);
1179+
binaryDb = new Uint8Array(
1180+
sizes[0].values[0][0] * sizes[1].values[0][0]
1181+
);
1182+
file.contents = binaryDb;
1183+
rc = sqlite3_backup_step(backup, -1);
1184+
}
11571185
// Do not retry: this synchronous call cannot let a writer progress.
11581186
if (rc === SQLITE_BUSY || rc === SQLITE_LOCKED) {
11591187
throw new Error("Unable to export: SQLite is busy or locked. "
@@ -1172,7 +1200,9 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
11721200
throw new Error(sqlite3_errmsg(destination));
11731201
}
11741202
destination = NULL;
1175-
var binaryDb = FS.readFile(filename, { encoding: "binary" });
1203+
// The private file is closed and unlinked below; hand its buffer
1204+
// to the caller rather than making a full-size FS.readFile copy.
1205+
binaryDb = file.contents.subarray(0, file.usedBytes);
11761206
if (binaryDb.length > 0) {
11771207
// All pages, including WAL pages, are in the backup.
11781208
// Make the exported copy independent of WAL support.

‎test/compare_exports.cjs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
// CI-only comparison: node test/compare_exports.cjs SERIALIZE_DIST BACKUP_DIST
1+
// CI-only comparison:
2+
// node test/compare_exports.cjs SERIALIZE_DIST BACKUP_DIST [PREVIOUS_BACKUP_DIST]
23
// Each measurement uses a fresh child process; both implementations run on
34
// the same machine. Timing is informational, never a pass/fail threshold.
45
const assert = require('node:assert/strict');
@@ -95,7 +96,7 @@ async function worker(strategy, directory, mode, rows) {
9596
let bytes;
9697
let error;
9798
try { bytes = db.export(); } catch (e) { error = e; }
98-
if (strategy === 'backup' && name !== 'read_transaction') {
99+
if (strategy !== 'serialize' && name !== 'read_transaction') {
99100
assert.match(error && error.message, /busy or locked/);
100101
outcomes[name] = 'rejected; source transaction intact';
101102
} else {
@@ -217,6 +218,10 @@ function compare() {
217218
serialize: path.resolve(process.argv[2]),
218219
backup: path.resolve(process.argv[3])
219220
};
221+
if (process.argv[4]) {
222+
directories.backup_before = path.resolve(process.argv[4]);
223+
}
224+
const strategies = Object.keys(directories);
220225
const behavior = {};
221226
const constrainedMemory = {};
222227
const bundle = {};
@@ -231,9 +236,9 @@ function compare() {
231236
}
232237
const measurements = [];
233238
for (const rows of [256, 4096, 16384, 32768, 131072]) {
234-
const trials = { serialize: [], backup: [] };
239+
const trials = Object.fromEntries(strategies.map(strategy => [strategy, []]));
235240
for (let repeat = 0; repeat < 3; repeat++) {
236-
const order = repeat % 2 ? ['backup', 'serialize'] : ['serialize', 'backup'];
241+
const order = repeat % 2 ? strategies.slice().reverse() : strategies;
237242
for (const strategy of order) {
238243
trials[strategy].push(child(strategy, directories[strategy], 'timing', rows));
239244
}

‎test/test_export.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,56 @@ exports.test = function(SQL, assert) {
110110
}
111111
assert.throws(() => db.export(), /Database closed/);
112112

113+
// Export fills one result buffer page by page, without recopying the file.
114+
const large = new SQL.Database();
115+
let largeBytes;
116+
try {
117+
large.run('CREATE TABLE t(data); INSERT INTO t VALUES (zeroblob(3145728))');
118+
const set = Uint8Array.prototype.set;
119+
let largestCopy = 0;
120+
Uint8Array.prototype.set = function(source, offset) {
121+
largestCopy = Math.max(largestCopy, source.length);
122+
return set.call(this, source, offset);
123+
};
124+
try {
125+
largeBytes = large.export();
126+
} finally {
127+
Uint8Array.prototype.set = set;
128+
}
129+
assert.ok(largestCopy <= 65536,
130+
'Export never copies a growing file or a full-size result array');
131+
assert.strictEqual(largeBytes.buffer.byteLength, largeBytes.byteLength,
132+
'The result owns an exactly sized buffer, not spare MEMFS capacity');
133+
} finally {
134+
large.close();
135+
}
136+
const largeCopy = new SQL.Database(largeBytes);
137+
try {
138+
assert.deepStrictEqual(values(largeCopy, 'PRAGMA integrity_check'), [['ok']]);
139+
assert.deepStrictEqual(values(largeCopy, 'SELECT length(data) FROM t'),
140+
[[3145728]], 'The result remains valid after closing the source');
141+
} finally {
142+
largeCopy.close();
143+
}
144+
145+
for (const pageSize of [512, 4096, 65536]) {
146+
const paged = new SQL.Database();
147+
let pagedCopy;
148+
try {
149+
paged.run('PRAGMA page_size = ' + pageSize + ';'
150+
+ 'CREATE TABLE t(x); INSERT INTO t VALUES (42);');
151+
const bytes = paged.export();
152+
assert.strictEqual(bytes.length,
153+
values(paged, 'PRAGMA page_count')[0][0] * pageSize);
154+
assert.strictEqual(bytes.buffer.byteLength, bytes.length);
155+
pagedCopy = new SQL.Database(bytes);
156+
assert.deepStrictEqual(values(pagedCopy, 'SELECT x FROM t'), [[42]]);
157+
} finally {
158+
if (pagedCopy) pagedCopy.close();
159+
paged.close();
160+
}
161+
}
162+
113163
// Write transactions fail promptly without losing writes or savepoints.
114164
const transactional = new SQL.Database();
115165
let committed;

0 commit comments

Comments
 (0)