167 lines
5.2 KiB
JavaScript
167 lines
5.2 KiB
JavaScript
import { test } from 'node:test';
|
|||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { createRequire } from 'node:module';
|
||
|
|
import { mkdtempSync } from 'node:fs';
|
||
|
|
import { tmpdir } from 'node:os';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
|
||
|
|
import { nodeSqliteTransactionAdapter, runWriteTransaction } from '../scripts/tx.ts';
|
||
|
|
import { hasUnusableTransaction, isBeginBusyFailure, isRetryableWriteFailure } from '../scripts/write-coordinator.ts';
|
||
|
|
|
||
|
|
const require = createRequire(import.meta.url);
|
||
|
|
const { DatabaseSync } = require('node:sqlite');
|
||
|
|
|
||
|
|
test('a failed rollback with an active transaction never retries or masks the primary error', () => {
|
||
|
|
const primary = Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
|
||
|
|
let active = false;
|
||
|
|
let workCalls = 0;
|
||
|
|
|
||
|
|
const db = {
|
||
|
|
exec(sql) {
|
||
|
|
if (sql.startsWith('BEGIN')) {
|
||
|
|
if (active) throw new Error('cannot start a transaction within a transaction');
|
||
|
|
active = true;
|
||
|
|
} else if (sql === 'ROLLBACK') {
|
||
|
|
throw new Error('rollback failed with I/O error');
|
||
|
|
} else if (sql === 'COMMIT') {
|
||
|
|
active = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
inTransaction() {
|
||
|
|
return active;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => runWriteTransaction(db, () => {
|
||
|
|
workCalls += 1;
|
||
|
|
throw primary;
|
||
|
|
}),
|
||
|
|
error => error === primary,
|
||
|
|
);
|
||
|
|
assert.equal(workCalls, 1);
|
||
|
|
assert.equal(active, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an automatic rollback rethrows the primary error without issuing another rollback', () => {
|
||
|
|
const primary = Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
|
||
|
|
let active = false;
|
||
|
|
let rollbackCalls = 0;
|
||
|
|
const db = {
|
||
|
|
exec(sql) {
|
||
|
|
if (sql.startsWith('BEGIN')) active = true;
|
||
|
|
if (sql === 'ROLLBACK') rollbackCalls += 1;
|
||
|
|
},
|
||
|
|
inTransaction() {
|
||
|
|
return active;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.throws(
|
||
|
|
() => runWriteTransaction(db, () => {
|
||
|
|
active = false;
|
||
|
|
throw primary;
|
||
|
|
}),
|
||
|
|
error => error === primary,
|
||
|
|
);
|
||
|
|
assert.equal(rollbackCalls, 0);
|
||
|
|
assert.equal(primary.obelisk.transactionActive, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an active transaction is rolled back once before the primary error is rethrown', () => {
|
||
|
|
const primary = new Error('persist failed');
|
||
|
|
let active = false;
|
||
|
|
let rollbackCalls = 0;
|
||
|
|
const db = {
|
||
|
|
exec(sql) {
|
||
|
|
if (sql.startsWith('BEGIN')) active = true;
|
||
|
|
if (sql === 'ROLLBACK') {
|
||
|
|
rollbackCalls += 1;
|
||
|
|
active = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
inTransaction() {
|
||
|
|
return active;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.throws(() => runWriteTransaction(db, () => { throw primary; }), error => error === primary);
|
||
|
|
assert.equal(rollbackCalls, 1);
|
||
|
|
assert.equal(primary.obelisk.rollbackSucceeded, true);
|
||
|
|
assert.equal(primary.obelisk.transactionActive, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an unknown post-error transaction state is unsafe for the next file', () => {
|
||
|
|
const primary = new Error('transaction state unavailable');
|
||
|
|
const db = {
|
||
|
|
exec() {},
|
||
|
|
inTransaction() {
|
||
|
|
throw new Error('binding cannot report transaction state');
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.throws(() => runWriteTransaction(db, () => { throw primary; }), error => error === primary);
|
||
|
|
assert.equal(primary.obelisk.transactionActive, null);
|
||
|
|
assert.equal(hasUnusableTransaction(primary), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('node:sqlite generic error codes preserve BUSY classification from the message', () => {
|
||
|
|
const primary = Object.assign(new Error('database is locked'), { code: 'ERR_SQLITE_ERROR' });
|
||
|
|
let active = false;
|
||
|
|
const db = {
|
||
|
|
exec(sql) {
|
||
|
|
if (sql === 'BEGIN IMMEDIATE') active = true;
|
||
|
|
},
|
||
|
|
inTransaction() { return active; },
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.throws(() => runWriteTransaction(db, () => {
|
||
|
|
active = false;
|
||
|
|
throw primary;
|
||
|
|
}), error => error === primary);
|
||
|
|
assert.equal(primary.obelisk.code, 'SQLITE_BUSY');
|
||
|
|
assert.equal(isRetryableWriteFailure(primary), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a real node:sqlite BEGIN lock is classified as a deferrable BUSY', () => {
|
||
|
|
const dbPath = join(mkdtempSync(join(tmpdir(), 'obelisk-node-sqlite-busy-')), 'index.sqlite');
|
||
|
|
const holder = new DatabaseSync(dbPath);
|
||
|
|
const contender = new DatabaseSync(dbPath);
|
||
|
|
holder.exec('PRAGMA busy_timeout=0; CREATE TABLE test (value TEXT); BEGIN IMMEDIATE');
|
||
|
|
contender.exec('PRAGMA busy_timeout=0');
|
||
|
|
|
||
|
|
try {
|
||
|
|
assert.throws(
|
||
|
|
() => runWriteTransaction(nodeSqliteTransactionAdapter(contender), () => {}),
|
||
|
|
error => isBeginBusyFailure(error) && error.obelisk.code === 'SQLITE_BUSY',
|
||
|
|
);
|
||
|
|
} finally {
|
||
|
|
holder.exec('ROLLBACK');
|
||
|
|
holder.close();
|
||
|
|
contender.close();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a BEGIN failure with an active transaction is not deferrable', () => {
|
||
|
|
const primary = Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
|
||
|
|
let rollbackCalls = 0;
|
||
|
|
const db = {
|
||
|
|
exec(sql) {
|
||
|
|
if (sql === 'BEGIN IMMEDIATE') throw primary;
|
||
|
|
if (sql === 'ROLLBACK') {
|
||
|
|
rollbackCalls += 1;
|
||
|
|
throw new Error('rollback failed with I/O error');
|
||
|
|
}
|
||
|
|
},
|
||
|
|
inTransaction() {
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
assert.throws(() => runWriteTransaction(db, () => {}), error => error === primary);
|
||
|
|
assert.equal(rollbackCalls, 1);
|
||
|
|
assert.equal(primary.obelisk.transactionActive, true);
|
||
|
|
assert.equal(hasUnusableTransaction(primary), true);
|
||
|
|
assert.equal(isBeginBusyFailure(primary), false);
|
||
|
|
});
|