@bitclaw/sqlite 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/scripts/benchmark.d.ts +3 -0
- package/dist/scripts/benchmark.d.ts.map +1 -0
- package/dist/scripts/benchmark.js +286 -0
- package/dist/scripts/load-test-utils.d.ts +77 -0
- package/dist/scripts/load-test-utils.d.ts.map +1 -0
- package/dist/scripts/load-test-utils.js +235 -0
- package/dist/src/cache-lock.d.ts +25 -0
- package/dist/src/cache-lock.d.ts.map +1 -0
- package/dist/src/cache-lock.js +95 -0
- package/dist/src/connection.d.ts +26 -0
- package/dist/src/connection.d.ts.map +1 -0
- package/dist/src/connection.js +132 -0
- package/dist/src/json-cache.d.ts +89 -0
- package/dist/src/json-cache.d.ts.map +1 -0
- package/dist/src/json-cache.js +289 -0
- package/dist/src/pool.d.ts +98 -0
- package/dist/src/pool.d.ts.map +1 -0
- package/dist/src/pool.js +331 -0
- package/dist/src/prisma-immediate-tx.d.ts +23 -0
- package/dist/src/prisma-immediate-tx.d.ts.map +1 -0
- package/dist/src/prisma-immediate-tx.js +42 -0
- package/dist/src/query-logger.d.ts +21 -0
- package/dist/src/query-logger.d.ts.map +1 -0
- package/dist/src/query-logger.js +60 -0
- package/dist/src/retry.d.ts +14 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +49 -0
- package/dist/src/ttl-cache.d.ts +57 -0
- package/dist/src/ttl-cache.d.ts.map +1 -0
- package/dist/src/ttl-cache.js +92 -0
- package/dist/src/worker.d.ts +38 -0
- package/dist/src/worker.d.ts.map +1 -0
- package/dist/src/worker.js +294 -0
- package/dist/src/write-mutex.d.ts +33 -0
- package/dist/src/write-mutex.d.ts.map +1 -0
- package/dist/src/write-mutex.js +60 -0
- package/package.json +48 -0
- package/scripts/benchmark.ts +373 -0
- package/scripts/load-test-utils.ts +370 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// packages/sqlite/scripts/benchmark.ts
|
|
3
|
+
// SQLite performance benchmark for Hetzner VPS tiers
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
7
|
+
import { createPool, shutdownPool } from '../src/pool';
|
|
8
|
+
|
|
9
|
+
/* ------------------------------------------------------------------
|
|
10
|
+
* Hetzner VPS Tiers (vs AWS Lambda)
|
|
11
|
+
* ------------------------------------------------------------------ */
|
|
12
|
+
const HETZNER_TIERS = [
|
|
13
|
+
{ name: 'CPX11', vcpu: 2, ram: 2048, pool: 2, monthly: 4.15 }, // Entry
|
|
14
|
+
{ name: 'CPX21', vcpu: 3, ram: 4096, pool: 4, monthly: 7.49 }, // DEFAULT
|
|
15
|
+
{ name: 'CPX31', vcpu: 4, ram: 8192, pool: 4, monthly: 15.49 }, // Mid
|
|
16
|
+
{ name: 'CPX41', vcpu: 8, ram: 16384, pool: 6, monthly: 30.99 } // High
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
/* ------------------------------------------------------------------
|
|
20
|
+
* Performance Targets
|
|
21
|
+
* ------------------------------------------------------------------ */
|
|
22
|
+
const TARGETS = {
|
|
23
|
+
p95LatencyMs: 40,
|
|
24
|
+
errorRatePct: 0.5,
|
|
25
|
+
throughputQps: 1000
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/* ------------------------------------------------------------------
|
|
29
|
+
* Types
|
|
30
|
+
* ------------------------------------------------------------------ */
|
|
31
|
+
type BenchmarkConfig = {
|
|
32
|
+
poolSize: number;
|
|
33
|
+
concurrency: number;
|
|
34
|
+
durationSec: number;
|
|
35
|
+
op: 'read' | 'write' | 'mixed';
|
|
36
|
+
tierName?: string;
|
|
37
|
+
tierCost?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type BenchResult = {
|
|
41
|
+
op: string;
|
|
42
|
+
pool: number;
|
|
43
|
+
tierName?: string;
|
|
44
|
+
tierCost?: number;
|
|
45
|
+
|
|
46
|
+
total: number;
|
|
47
|
+
ok: number;
|
|
48
|
+
fail: number;
|
|
49
|
+
|
|
50
|
+
errPct: number;
|
|
51
|
+
tput: number;
|
|
52
|
+
|
|
53
|
+
p50: number;
|
|
54
|
+
p95: number;
|
|
55
|
+
p99: number;
|
|
56
|
+
max: number;
|
|
57
|
+
min: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/* ------------------------------------------------------------------
|
|
61
|
+
* Helper utilities
|
|
62
|
+
* ------------------------------------------------------------------ */
|
|
63
|
+
function percentile(sorted: number[], p: number): number {
|
|
64
|
+
if (!sorted.length) return 0;
|
|
65
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
66
|
+
const lo = Math.floor(idx);
|
|
67
|
+
const hi = Math.ceil(idx);
|
|
68
|
+
if (lo === hi) return sorted[lo]!;
|
|
69
|
+
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (idx - lo);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------------------------
|
|
73
|
+
* Benchmark runner class
|
|
74
|
+
* ------------------------------------------------------------------ */
|
|
75
|
+
class PoolBench {
|
|
76
|
+
private results: BenchResult[] = [];
|
|
77
|
+
private failCount = 0;
|
|
78
|
+
|
|
79
|
+
async run(cfg: BenchmarkConfig): Promise<void> {
|
|
80
|
+
this.failCount = 0;
|
|
81
|
+
|
|
82
|
+
await PoolBench.initDatabase();
|
|
83
|
+
|
|
84
|
+
const pool = createPool({
|
|
85
|
+
databasePath: process.env.DATABASE_PATH,
|
|
86
|
+
poolSize: cfg.poolSize,
|
|
87
|
+
timeout: 30000
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const lat: number[] = [];
|
|
91
|
+
const t0 = performance.now();
|
|
92
|
+
const end = t0 + cfg.durationSec * 1_000;
|
|
93
|
+
|
|
94
|
+
// Create concurrent workers
|
|
95
|
+
const workers = Array.from({ length: cfg.concurrency }, () =>
|
|
96
|
+
PoolBench.workerLoop(cfg.op, pool.exec.bind(pool), end, lat, this)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await Promise.allSettled(workers);
|
|
100
|
+
|
|
101
|
+
const durSec = (performance.now() - t0) / 1_000;
|
|
102
|
+
lat.sort((a, b) => a - b);
|
|
103
|
+
|
|
104
|
+
const total = lat.length;
|
|
105
|
+
const fail = this.failCount;
|
|
106
|
+
const ok = total - fail;
|
|
107
|
+
|
|
108
|
+
const r: BenchResult = {
|
|
109
|
+
op: cfg.op,
|
|
110
|
+
pool: cfg.poolSize,
|
|
111
|
+
tierName: cfg.tierName,
|
|
112
|
+
tierCost: cfg.tierCost,
|
|
113
|
+
|
|
114
|
+
total,
|
|
115
|
+
ok,
|
|
116
|
+
fail,
|
|
117
|
+
|
|
118
|
+
errPct: total ? (fail / total) * 100 : 0,
|
|
119
|
+
tput: total / durSec,
|
|
120
|
+
|
|
121
|
+
p50: percentile(lat, 50),
|
|
122
|
+
p95: percentile(lat, 95),
|
|
123
|
+
p99: percentile(lat, 99),
|
|
124
|
+
max: lat.at(-1) ?? 0,
|
|
125
|
+
min: lat[0] ?? 0
|
|
126
|
+
};
|
|
127
|
+
this.results.push(r);
|
|
128
|
+
|
|
129
|
+
await PoolBench.cleanupPool();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static async initDatabase(): Promise<void> {
|
|
133
|
+
// Ensure data/ directory exists
|
|
134
|
+
const dataDir = path.resolve(import.meta.dir, '..', 'data');
|
|
135
|
+
if (!fs.existsSync(dataDir)) {
|
|
136
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const timestamp = Date.now();
|
|
140
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
141
|
+
const pid = process.pid;
|
|
142
|
+
const dbFile = path.join(dataDir, `bench-${timestamp}-${pid}-${random}.db`);
|
|
143
|
+
process.env.DATABASE_PATH = dbFile;
|
|
144
|
+
|
|
145
|
+
// Clean up old test files
|
|
146
|
+
for (const ext of ['', '-shm', '-wal']) {
|
|
147
|
+
const file = dbFile + ext;
|
|
148
|
+
if (fs.existsSync(file)) {
|
|
149
|
+
fs.unlinkSync(file);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
154
|
+
|
|
155
|
+
// Create test table using bun:sqlite
|
|
156
|
+
const { Database } = await import('bun:sqlite');
|
|
157
|
+
const testDb = new Database(dbFile);
|
|
158
|
+
try {
|
|
159
|
+
// Pre-set WAL mode so pool workers don't race to set it
|
|
160
|
+
testDb.run('PRAGMA journal_mode = WAL');
|
|
161
|
+
testDb.run('PRAGMA busy_timeout = 5000');
|
|
162
|
+
testDb.run(`
|
|
163
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
164
|
+
id TEXT PRIMARY KEY,
|
|
165
|
+
primary_email TEXT NOT NULL UNIQUE,
|
|
166
|
+
name TEXT,
|
|
167
|
+
max_sessions INTEGER DEFAULT 5,
|
|
168
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
169
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
170
|
+
);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(primary_email);
|
|
172
|
+
`);
|
|
173
|
+
} catch (error: unknown) {
|
|
174
|
+
console.error('[db] CRITICAL: Tables not found:', error);
|
|
175
|
+
throw new Error('Migration verification failed');
|
|
176
|
+
} finally {
|
|
177
|
+
testDb.close();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Give the OS time to release the file lock after close
|
|
181
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private static async cleanupPool(): Promise<void> {
|
|
185
|
+
try {
|
|
186
|
+
// shutdownPool can hang if workers are stuck; add a timeout
|
|
187
|
+
await Promise.race([
|
|
188
|
+
shutdownPool(),
|
|
189
|
+
new Promise(resolve => setTimeout(resolve, 5000))
|
|
190
|
+
]);
|
|
191
|
+
} finally {
|
|
192
|
+
const dbPath = process.env.DATABASE_PATH;
|
|
193
|
+
if (dbPath && dbPath !== ':memory:' && fs.existsSync(dbPath)) {
|
|
194
|
+
try {
|
|
195
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
196
|
+
|
|
197
|
+
for (const ext of ['', '-shm', '-wal']) {
|
|
198
|
+
const file = dbPath + ext;
|
|
199
|
+
if (fs.existsSync(file)) {
|
|
200
|
+
fs.unlinkSync(file);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (error: unknown) {
|
|
204
|
+
console.warn(`Warning: Could not clean up ${dbPath}:`, error);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
delete process.env.DATABASE_PATH;
|
|
208
|
+
|
|
209
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private static async workerLoop(
|
|
214
|
+
op: BenchmarkConfig['op'],
|
|
215
|
+
exec: (sql: string, params?: unknown[]) => Promise<unknown>,
|
|
216
|
+
endTs: number,
|
|
217
|
+
lat: number[],
|
|
218
|
+
benchInstance: PoolBench
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
let localFailCount = 0;
|
|
221
|
+
|
|
222
|
+
while (performance.now() < endTs) {
|
|
223
|
+
const start = performance.now();
|
|
224
|
+
try {
|
|
225
|
+
await PoolBench.doOne(op, exec);
|
|
226
|
+
} catch (error: unknown) {
|
|
227
|
+
localFailCount += 1;
|
|
228
|
+
benchInstance.failCount += 1;
|
|
229
|
+
|
|
230
|
+
if (localFailCount <= 3) {
|
|
231
|
+
console.error(
|
|
232
|
+
`Worker failure ${localFailCount}:`,
|
|
233
|
+
error instanceof Error ? error.message : String(error)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
lat.push(performance.now() - start);
|
|
238
|
+
}
|
|
239
|
+
await new Promise(res => setTimeout(res, 1));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private static async doOne(
|
|
244
|
+
op: BenchmarkConfig['op'],
|
|
245
|
+
exec: (sql: string, params?: unknown[]) => Promise<unknown>
|
|
246
|
+
) {
|
|
247
|
+
const id = `u-${Math.random().toString(36).slice(2)}`;
|
|
248
|
+
const email = `${id}@bench.test`;
|
|
249
|
+
|
|
250
|
+
switch (op) {
|
|
251
|
+
case 'read':
|
|
252
|
+
return exec('SELECT COUNT(*) as c FROM users;');
|
|
253
|
+
case 'write':
|
|
254
|
+
return exec(
|
|
255
|
+
`INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
|
|
256
|
+
VALUES (?, ?, 5, ?, ?)`,
|
|
257
|
+
[id, email, new Date().toISOString(), new Date().toISOString()]
|
|
258
|
+
);
|
|
259
|
+
default: // mixed
|
|
260
|
+
return Math.random() < 0.7
|
|
261
|
+
? exec('SELECT COUNT(*) as c FROM users;')
|
|
262
|
+
: exec(
|
|
263
|
+
`INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
|
|
264
|
+
VALUES (?, ?, 5, ?, ?)`,
|
|
265
|
+
[id, email, new Date().toISOString(), new Date().toISOString()]
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
print(): void {
|
|
271
|
+
console.info('\n═══════════════════════════════════════════════════════');
|
|
272
|
+
console.info(' SQLite Pool Benchmark Results');
|
|
273
|
+
console.info('═══════════════════════════════════════════════════════\n');
|
|
274
|
+
for (const r of this.results) {
|
|
275
|
+
const tierText = r.tierName ? ` tier=${r.tierName}` : '';
|
|
276
|
+
const costText = r.tierCost ? ` (€${r.tierCost}/mo)` : '';
|
|
277
|
+
console.info(` op=${r.op} pool=${r.pool}${tierText}${costText}`);
|
|
278
|
+
console.info(
|
|
279
|
+
` total=${r.total} ok=${r.ok} fail=${r.fail} err=${r.errPct.toFixed(2)}%`
|
|
280
|
+
);
|
|
281
|
+
console.info(` throughput=${r.tput.toFixed(0)} qps`);
|
|
282
|
+
console.info(
|
|
283
|
+
` latency: p50=${r.p50.toFixed(1)}ms p95=${r.p95.toFixed(1)}ms p99=${r.p99.toFixed(1)}ms max=${r.max.toFixed(1)}ms`
|
|
284
|
+
);
|
|
285
|
+
console.info('');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
allPassed(): boolean {
|
|
290
|
+
return this.results.every(
|
|
291
|
+
r =>
|
|
292
|
+
r.p95 <= TARGETS.p95LatencyMs &&
|
|
293
|
+
r.errPct <= TARGETS.errorRatePct &&
|
|
294
|
+
r.tput >= TARGETS.throughputQps
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ------------------------------------------------------------------
|
|
300
|
+
* CLI runner
|
|
301
|
+
* ------------------------------------------------------------------ */
|
|
302
|
+
async function main(): Promise<void> {
|
|
303
|
+
const bench = new PoolBench();
|
|
304
|
+
const args = process.argv.slice(2);
|
|
305
|
+
|
|
306
|
+
const wantQuick = args.includes('--quick');
|
|
307
|
+
const wantTiers = args.includes('--tiers');
|
|
308
|
+
const wantCpx21 = args.includes('--tier') && args.includes('cpx21');
|
|
309
|
+
|
|
310
|
+
if (args.includes('--help')) {
|
|
311
|
+
console.info(`Usage: bun benchmark [options]
|
|
312
|
+
|
|
313
|
+
Options:
|
|
314
|
+
--quick Quick benchmark (pool=4, concurrency=20, 5s)
|
|
315
|
+
--tier cpx21 Simulate CPX21 tier (pool=4, concurrency=60, 10s)
|
|
316
|
+
--tiers Run all Hetzner tiers (CPX11/21/31/41)
|
|
317
|
+
--help Show this help
|
|
318
|
+
|
|
319
|
+
Targets: p95 < ${TARGETS.p95LatencyMs}ms, error < ${TARGETS.errorRatePct}%, throughput > ${TARGETS.throughputQps} qps
|
|
320
|
+
`);
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
if (wantTiers) {
|
|
326
|
+
for (const t of HETZNER_TIERS) {
|
|
327
|
+
await bench.run({
|
|
328
|
+
poolSize: t.pool,
|
|
329
|
+
concurrency: t.pool * 15,
|
|
330
|
+
durationSec: 10,
|
|
331
|
+
op: 'mixed',
|
|
332
|
+
tierName: t.name,
|
|
333
|
+
tierCost: t.monthly
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} else if (wantCpx21) {
|
|
337
|
+
const cpx21 = HETZNER_TIERS.find(t => t.name === 'CPX21')!;
|
|
338
|
+
await bench.run({
|
|
339
|
+
poolSize: cpx21.pool,
|
|
340
|
+
concurrency: cpx21.pool * 15,
|
|
341
|
+
durationSec: 10,
|
|
342
|
+
op: 'mixed',
|
|
343
|
+
tierName: cpx21.name,
|
|
344
|
+
tierCost: cpx21.monthly
|
|
345
|
+
});
|
|
346
|
+
} else if (wantQuick) {
|
|
347
|
+
await bench.run({
|
|
348
|
+
poolSize: 4,
|
|
349
|
+
concurrency: 20,
|
|
350
|
+
durationSec: 5,
|
|
351
|
+
op: 'mixed'
|
|
352
|
+
});
|
|
353
|
+
} else {
|
|
354
|
+
// Default: Quick test
|
|
355
|
+
await bench.run({
|
|
356
|
+
poolSize: 4,
|
|
357
|
+
concurrency: 20,
|
|
358
|
+
durationSec: 5,
|
|
359
|
+
op: 'mixed'
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
bench.print();
|
|
364
|
+
const ok = bench.allPassed();
|
|
365
|
+
|
|
366
|
+
process.exit(ok ? 0 : 1);
|
|
367
|
+
} catch (error: unknown) {
|
|
368
|
+
console.error('❌ Benchmark failed:', error);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
main().catch(console.error);
|