@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Chavez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# @bitclaw/sqlite
|
|
2
|
+
|
|
3
|
+
High-performance SQLite optimization package for multi-app SaaS deployments.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Worker Thread Pool**: Multiple SQLite connections for concurrent read/write operations
|
|
8
|
+
- **Statement Caching**: Automatic prepared statement caching per worker
|
|
9
|
+
- **Write Concurrency**: Per-resource write mutex and retry with exponential backoff
|
|
10
|
+
- **Prisma Integration**: BEGIN IMMEDIATE transaction wrapper for Prisma + libsql
|
|
11
|
+
- **JSON File Cache**: levelsio-style file-based caching with TTL support
|
|
12
|
+
- **Zero External Dependencies**: Uses bun:sqlite (built into the Bun runtime)
|
|
13
|
+
- **TypeScript**: Full type safety and IDE autocomplete
|
|
14
|
+
|
|
15
|
+
## Modules
|
|
16
|
+
|
|
17
|
+
| Import | Purpose |
|
|
18
|
+
|--------|---------|
|
|
19
|
+
| `@bitclaw/sqlite/pool` | Worker thread pool for concurrent SQLite operations |
|
|
20
|
+
| `@bitclaw/sqlite/connection` | Single connection with optimized PRAGMAs |
|
|
21
|
+
| `@bitclaw/sqlite/json-cache` | File-based JSON cache with TTL |
|
|
22
|
+
| `@bitclaw/sqlite/write-mutex` | Promise-based per-resource write mutex |
|
|
23
|
+
| `@bitclaw/sqlite/retry` | Exponential backoff retry for SQLITE_BUSY errors |
|
|
24
|
+
| `@bitclaw/sqlite/prisma-immediate-tx` | BEGIN IMMEDIATE wrapper for Prisma transactions |
|
|
25
|
+
| `@bitclaw/sqlite/query-logger` | Dev-mode SQL logging for bun:sqlite (mirrors Prisma's `prisma:query`) |
|
|
26
|
+
| `@bitclaw/sqlite/ttl-cache` | In-memory TTL cache for server-side deduplication across HTTP requests |
|
|
27
|
+
|
|
28
|
+
## Performance
|
|
29
|
+
|
|
30
|
+
### Pool-Level Benchmarks (raw `pool.exec()`)
|
|
31
|
+
|
|
32
|
+
Based on benchmarks from SecureLogin project:
|
|
33
|
+
|
|
34
|
+
- **6,102 - 13,781 req/s** on Hetzner CPX21 (3 vCPU, 4GB RAM)
|
|
35
|
+
- **P95 latency**: 12-22ms
|
|
36
|
+
- **100% success rate** under load
|
|
37
|
+
- **Prepared statement cache hit rate**: 100%
|
|
38
|
+
|
|
39
|
+
These numbers reflect **direct SQLite pool operations** — no HTTP server, no ORM, no middleware. Application-level throughput (through TanStack Start + Prisma + SSR) will be lower. Use `bun run test:load` in each app for end-to-end numbers.
|
|
40
|
+
|
|
41
|
+
## Benchmarking Methodology
|
|
42
|
+
|
|
43
|
+
### Pool-Level (`scripts/benchmark.ts`)
|
|
44
|
+
|
|
45
|
+
Tests raw `pool.exec()` calls against an in-process SQLite database:
|
|
46
|
+
|
|
47
|
+
- Creates a temporary database with a `users` table
|
|
48
|
+
- Runs concurrent workers executing `SELECT COUNT(*)` (reads) and `INSERT` (writes)
|
|
49
|
+
- Measures latency per operation, calculates P50/P95/P99
|
|
50
|
+
- No HTTP, no ORM, no serialization overhead
|
|
51
|
+
|
|
52
|
+
This represents the **theoretical ceiling** for SQLite throughput on given hardware.
|
|
53
|
+
|
|
54
|
+
### Application-Level (`bun run test:load`)
|
|
55
|
+
|
|
56
|
+
Tests end-to-end HTTP throughput through the full stack:
|
|
57
|
+
|
|
58
|
+
- Uses native `fetch()` against a running application server
|
|
59
|
+
- Measures real response times including: HTTP parsing, middleware, Prisma ORM, SSR rendering, serialization
|
|
60
|
+
- Tests at multiple concurrency levels (10/50/100)
|
|
61
|
+
- Reports req/s, P50/P95/P99 latency, success rate
|
|
62
|
+
|
|
63
|
+
This represents **actual user-facing performance**.
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bun add @bitclaw/sqlite
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
### Worker Pool
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { createPool } from '@bitclaw/sqlite/pool';
|
|
77
|
+
|
|
78
|
+
const pool = createPool({
|
|
79
|
+
databasePath: './data/app.db',
|
|
80
|
+
poolSize: 4,
|
|
81
|
+
timeout: 30000
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Execute query
|
|
85
|
+
const users = await pool.exec('SELECT * FROM users WHERE id = ?', [userId]);
|
|
86
|
+
|
|
87
|
+
// Graceful shutdown
|
|
88
|
+
await pool.shutdown();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Write Mutex
|
|
92
|
+
|
|
93
|
+
Serialize writes per resource to prevent SQLITE_BUSY contention before it reaches SQLite:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { WriteMutex, WriteMutexMap } from '@bitclaw/sqlite/write-mutex';
|
|
97
|
+
|
|
98
|
+
// Single mutex
|
|
99
|
+
const mutex = new WriteMutex();
|
|
100
|
+
const result = await mutex.acquire(() => db.run('INSERT INTO ...'));
|
|
101
|
+
|
|
102
|
+
// Named mutexes (e.g. per-tenant)
|
|
103
|
+
const mutexes = new WriteMutexMap();
|
|
104
|
+
await mutexes.withLock('workspace-123', () => db.run('INSERT INTO ...'));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Retry with Backoff
|
|
108
|
+
|
|
109
|
+
Retry operations that fail with SQLITE_BUSY using exponential backoff + jitter:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { withRetry } from '@bitclaw/sqlite/retry';
|
|
113
|
+
|
|
114
|
+
const result = await withRetry(
|
|
115
|
+
() => db.run('INSERT INTO ...'),
|
|
116
|
+
{ maxAttempts: 3, baseDelayMs: 100 }
|
|
117
|
+
);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Prisma BEGIN IMMEDIATE
|
|
121
|
+
|
|
122
|
+
Prevent SQLITE_BUSY from Prisma's default deferred transactions:
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
import { immediateTransaction } from '@bitclaw/sqlite/prisma-immediate-tx';
|
|
126
|
+
import { withRetry } from '@bitclaw/sqlite/retry';
|
|
127
|
+
|
|
128
|
+
await withRetry(() =>
|
|
129
|
+
immediateTransaction(prisma, async () => {
|
|
130
|
+
await prisma.user.create({ data: { ... } });
|
|
131
|
+
await prisma.session.update({ where: { ... }, data: { ... } });
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### JSON Cache
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
import { createJsonCache } from '@bitclaw/sqlite/json-cache';
|
|
140
|
+
|
|
141
|
+
const cache = createJsonCache({
|
|
142
|
+
cacheDir: './cache',
|
|
143
|
+
defaultTtl: 300000 // 5 minutes
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Set value
|
|
147
|
+
await cache.set('user:123', userData, { ttl: 600000 });
|
|
148
|
+
|
|
149
|
+
// Get value
|
|
150
|
+
const user = await cache.get('user:123');
|
|
151
|
+
|
|
152
|
+
// Delete value
|
|
153
|
+
await cache.delete('user:123');
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Query Logger
|
|
157
|
+
|
|
158
|
+
Dev-mode SQL logging for bun:sqlite — mirrors Prisma's `prisma:query` output. Zero overhead in production.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { Database } from 'bun:sqlite';
|
|
162
|
+
import { wrapWithQueryLogging } from '@bitclaw/sqlite/query-logger';
|
|
163
|
+
|
|
164
|
+
const raw = new Database('./data/workspace.db');
|
|
165
|
+
const db = wrapWithQueryLogging(raw, { label: 'ws:abc123' });
|
|
166
|
+
|
|
167
|
+
// In development, every query/prepare/run/exec logs to stdout:
|
|
168
|
+
// sqlite:query [ws:abc123] SELECT * FROM servers WHERE id = ?
|
|
169
|
+
//
|
|
170
|
+
// In production (NODE_ENV !== 'development'), returns the database unchanged.
|
|
171
|
+
db.query('SELECT * FROM servers WHERE id = ?').get(serverId);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### TTL Cache
|
|
175
|
+
|
|
176
|
+
In-memory TTL cache for server-side deduplication across HTTP requests. Designed for caching expensive lookups (auth sessions, membership checks, bootstrap data) that repeat when frameworks like TanStack Router re-run loaders on client hydration.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { TTLCache } from '@bitclaw/sqlite/ttl-cache';
|
|
180
|
+
|
|
181
|
+
type BootstrapData = { user: User; workspaces: Workspace[] };
|
|
182
|
+
|
|
183
|
+
const bootstrapCache = new TTLCache<BootstrapData>({
|
|
184
|
+
ttl: 30_000, // 30s (default)
|
|
185
|
+
maxSize: 100 // auto-prune expired entries when exceeded (default)
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// In your server function:
|
|
189
|
+
const cached = bootstrapCache.get(sessionId);
|
|
190
|
+
if (cached) return cached;
|
|
191
|
+
|
|
192
|
+
const data = await expensiveQuery();
|
|
193
|
+
bootstrapCache.set(sessionId, data);
|
|
194
|
+
return data;
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Unlike `WeakMap` per-request caching (which deduplicates within a single SSR request), `TTLCache` deduplicates **across** HTTP requests — e.g. when TanStack Router replays `beforeLoad` on client hydration, the server returns the cached result instantly (0 DB queries).
|
|
198
|
+
|
|
199
|
+
## Configuration
|
|
200
|
+
|
|
201
|
+
### Environment Variables
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
SQLITE_POOL_SIZE=4 # Number of worker threads
|
|
205
|
+
SQLITE_WORKER_TIMEOUT=30000 # Query timeout in milliseconds
|
|
206
|
+
DATABASE_PATH=./data/app.db # Database file path
|
|
207
|
+
JSON_CACHE_DIR=./cache # Cache directory
|
|
208
|
+
JSON_CACHE_TTL=300000 # Default TTL in milliseconds
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Benchmarking
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# Quick benchmark (5 seconds, default)
|
|
215
|
+
bun run benchmark
|
|
216
|
+
|
|
217
|
+
# Quick benchmark (explicit)
|
|
218
|
+
bun run benchmark:quick
|
|
219
|
+
|
|
220
|
+
# CPX21 tier benchmark (matches production)
|
|
221
|
+
bun run benchmark -- --tier cpx21
|
|
222
|
+
|
|
223
|
+
# All Hetzner tiers
|
|
224
|
+
bun run benchmark -- --tiers
|
|
225
|
+
|
|
226
|
+
# Help
|
|
227
|
+
bun run benchmark -- --help
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Architecture
|
|
231
|
+
|
|
232
|
+
### Worker Pool
|
|
233
|
+
- Uses Node.js worker threads for true concurrency
|
|
234
|
+
- Round-robin query distribution
|
|
235
|
+
- Automatic connection health monitoring
|
|
236
|
+
- Graceful shutdown with connection cleanup
|
|
237
|
+
|
|
238
|
+
### Write Concurrency
|
|
239
|
+
- **Write Mutex**: Promise-based per-resource lock serializes writes before they hit SQLite, eliminating contention at near-zero overhead
|
|
240
|
+
- **Retry**: Exponential backoff with jitter catches any remaining SQLITE_BUSY errors as a safety net (default: 3 attempts, 100ms base delay)
|
|
241
|
+
- **BEGIN IMMEDIATE**: Prisma wrapper acquires write lock upfront instead of deferring, preventing the deadlock-prone lock upgrade path
|
|
242
|
+
|
|
243
|
+
### JSON Cache
|
|
244
|
+
- Atomic writes using temp files
|
|
245
|
+
- TTL-based expiration
|
|
246
|
+
- Stale-while-revalidate support
|
|
247
|
+
- No external dependencies
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"benchmark.d.ts","sourceRoot":"","sources":["../../scripts/benchmark.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
* Hetzner VPS Tiers (vs AWS Lambda)
|
|
10
|
+
* ------------------------------------------------------------------ */
|
|
11
|
+
const HETZNER_TIERS = [
|
|
12
|
+
{ name: 'CPX11', vcpu: 2, ram: 2048, pool: 2, monthly: 4.15 }, // Entry
|
|
13
|
+
{ name: 'CPX21', vcpu: 3, ram: 4096, pool: 4, monthly: 7.49 }, // DEFAULT
|
|
14
|
+
{ name: 'CPX31', vcpu: 4, ram: 8192, pool: 4, monthly: 15.49 }, // Mid
|
|
15
|
+
{ name: 'CPX41', vcpu: 8, ram: 16384, pool: 6, monthly: 30.99 } // High
|
|
16
|
+
];
|
|
17
|
+
/* ------------------------------------------------------------------
|
|
18
|
+
* Performance Targets
|
|
19
|
+
* ------------------------------------------------------------------ */
|
|
20
|
+
const TARGETS = {
|
|
21
|
+
p95LatencyMs: 40,
|
|
22
|
+
errorRatePct: 0.5,
|
|
23
|
+
throughputQps: 1000
|
|
24
|
+
};
|
|
25
|
+
/* ------------------------------------------------------------------
|
|
26
|
+
* Helper utilities
|
|
27
|
+
* ------------------------------------------------------------------ */
|
|
28
|
+
function percentile(sorted, p) {
|
|
29
|
+
if (!sorted.length)
|
|
30
|
+
return 0;
|
|
31
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
32
|
+
const lo = Math.floor(idx);
|
|
33
|
+
const hi = Math.ceil(idx);
|
|
34
|
+
if (lo === hi)
|
|
35
|
+
return sorted[lo];
|
|
36
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
|
|
37
|
+
}
|
|
38
|
+
/* ------------------------------------------------------------------
|
|
39
|
+
* Benchmark runner class
|
|
40
|
+
* ------------------------------------------------------------------ */
|
|
41
|
+
class PoolBench {
|
|
42
|
+
results = [];
|
|
43
|
+
failCount = 0;
|
|
44
|
+
async run(cfg) {
|
|
45
|
+
this.failCount = 0;
|
|
46
|
+
await PoolBench.initDatabase();
|
|
47
|
+
const pool = createPool({
|
|
48
|
+
databasePath: process.env.DATABASE_PATH,
|
|
49
|
+
poolSize: cfg.poolSize,
|
|
50
|
+
timeout: 30000
|
|
51
|
+
});
|
|
52
|
+
const lat = [];
|
|
53
|
+
const t0 = performance.now();
|
|
54
|
+
const end = t0 + cfg.durationSec * 1_000;
|
|
55
|
+
// Create concurrent workers
|
|
56
|
+
const workers = Array.from({ length: cfg.concurrency }, () => PoolBench.workerLoop(cfg.op, pool.exec.bind(pool), end, lat, this));
|
|
57
|
+
await Promise.allSettled(workers);
|
|
58
|
+
const durSec = (performance.now() - t0) / 1_000;
|
|
59
|
+
lat.sort((a, b) => a - b);
|
|
60
|
+
const total = lat.length;
|
|
61
|
+
const fail = this.failCount;
|
|
62
|
+
const ok = total - fail;
|
|
63
|
+
const r = {
|
|
64
|
+
op: cfg.op,
|
|
65
|
+
pool: cfg.poolSize,
|
|
66
|
+
tierName: cfg.tierName,
|
|
67
|
+
tierCost: cfg.tierCost,
|
|
68
|
+
total,
|
|
69
|
+
ok,
|
|
70
|
+
fail,
|
|
71
|
+
errPct: total ? (fail / total) * 100 : 0,
|
|
72
|
+
tput: total / durSec,
|
|
73
|
+
p50: percentile(lat, 50),
|
|
74
|
+
p95: percentile(lat, 95),
|
|
75
|
+
p99: percentile(lat, 99),
|
|
76
|
+
max: lat.at(-1) ?? 0,
|
|
77
|
+
min: lat[0] ?? 0
|
|
78
|
+
};
|
|
79
|
+
this.results.push(r);
|
|
80
|
+
await PoolBench.cleanupPool();
|
|
81
|
+
}
|
|
82
|
+
static async initDatabase() {
|
|
83
|
+
// Ensure data/ directory exists
|
|
84
|
+
const dataDir = path.resolve(import.meta.dir, '..', 'data');
|
|
85
|
+
if (!fs.existsSync(dataDir)) {
|
|
86
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
const timestamp = Date.now();
|
|
89
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
90
|
+
const pid = process.pid;
|
|
91
|
+
const dbFile = path.join(dataDir, `bench-${timestamp}-${pid}-${random}.db`);
|
|
92
|
+
process.env.DATABASE_PATH = dbFile;
|
|
93
|
+
// Clean up old test files
|
|
94
|
+
for (const ext of ['', '-shm', '-wal']) {
|
|
95
|
+
const file = dbFile + ext;
|
|
96
|
+
if (fs.existsSync(file)) {
|
|
97
|
+
fs.unlinkSync(file);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
101
|
+
// Create test table using bun:sqlite
|
|
102
|
+
const { Database } = await import('bun:sqlite');
|
|
103
|
+
const testDb = new Database(dbFile);
|
|
104
|
+
try {
|
|
105
|
+
// Pre-set WAL mode so pool workers don't race to set it
|
|
106
|
+
testDb.run('PRAGMA journal_mode = WAL');
|
|
107
|
+
testDb.run('PRAGMA busy_timeout = 5000');
|
|
108
|
+
testDb.run(`
|
|
109
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
110
|
+
id TEXT PRIMARY KEY,
|
|
111
|
+
primary_email TEXT NOT NULL UNIQUE,
|
|
112
|
+
name TEXT,
|
|
113
|
+
max_sessions INTEGER DEFAULT 5,
|
|
114
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
115
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
116
|
+
);
|
|
117
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(primary_email);
|
|
118
|
+
`);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
console.error('[db] CRITICAL: Tables not found:', error);
|
|
122
|
+
throw new Error('Migration verification failed');
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
testDb.close();
|
|
126
|
+
}
|
|
127
|
+
// Give the OS time to release the file lock after close
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
129
|
+
}
|
|
130
|
+
static async cleanupPool() {
|
|
131
|
+
try {
|
|
132
|
+
// shutdownPool can hang if workers are stuck; add a timeout
|
|
133
|
+
await Promise.race([
|
|
134
|
+
shutdownPool(),
|
|
135
|
+
new Promise(resolve => setTimeout(resolve, 5000))
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
const dbPath = process.env.DATABASE_PATH;
|
|
140
|
+
if (dbPath && dbPath !== ':memory:' && fs.existsSync(dbPath)) {
|
|
141
|
+
try {
|
|
142
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
143
|
+
for (const ext of ['', '-shm', '-wal']) {
|
|
144
|
+
const file = dbPath + ext;
|
|
145
|
+
if (fs.existsSync(file)) {
|
|
146
|
+
fs.unlinkSync(file);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.warn(`Warning: Could not clean up ${dbPath}:`, error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
delete process.env.DATABASE_PATH;
|
|
155
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
static async workerLoop(op, exec, endTs, lat, benchInstance) {
|
|
159
|
+
let localFailCount = 0;
|
|
160
|
+
while (performance.now() < endTs) {
|
|
161
|
+
const start = performance.now();
|
|
162
|
+
try {
|
|
163
|
+
await PoolBench.doOne(op, exec);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
localFailCount += 1;
|
|
167
|
+
benchInstance.failCount += 1;
|
|
168
|
+
if (localFailCount <= 3) {
|
|
169
|
+
console.error(`Worker failure ${localFailCount}:`, error instanceof Error ? error.message : String(error));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
lat.push(performance.now() - start);
|
|
174
|
+
}
|
|
175
|
+
await new Promise(res => setTimeout(res, 1));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
static async doOne(op, exec) {
|
|
179
|
+
const id = `u-${Math.random().toString(36).slice(2)}`;
|
|
180
|
+
const email = `${id}@bench.test`;
|
|
181
|
+
switch (op) {
|
|
182
|
+
case 'read':
|
|
183
|
+
return exec('SELECT COUNT(*) as c FROM users;');
|
|
184
|
+
case 'write':
|
|
185
|
+
return exec(`INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
|
|
186
|
+
VALUES (?, ?, 5, ?, ?)`, [id, email, new Date().toISOString(), new Date().toISOString()]);
|
|
187
|
+
default: // mixed
|
|
188
|
+
return Math.random() < 0.7
|
|
189
|
+
? exec('SELECT COUNT(*) as c FROM users;')
|
|
190
|
+
: exec(`INSERT INTO users (id, primary_email, max_sessions, created_at, updated_at)
|
|
191
|
+
VALUES (?, ?, 5, ?, ?)`, [id, email, new Date().toISOString(), new Date().toISOString()]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
print() {
|
|
195
|
+
console.info('\n═══════════════════════════════════════════════════════');
|
|
196
|
+
console.info(' SQLite Pool Benchmark Results');
|
|
197
|
+
console.info('═══════════════════════════════════════════════════════\n');
|
|
198
|
+
for (const r of this.results) {
|
|
199
|
+
const tierText = r.tierName ? ` tier=${r.tierName}` : '';
|
|
200
|
+
const costText = r.tierCost ? ` (€${r.tierCost}/mo)` : '';
|
|
201
|
+
console.info(` op=${r.op} pool=${r.pool}${tierText}${costText}`);
|
|
202
|
+
console.info(` total=${r.total} ok=${r.ok} fail=${r.fail} err=${r.errPct.toFixed(2)}%`);
|
|
203
|
+
console.info(` throughput=${r.tput.toFixed(0)} qps`);
|
|
204
|
+
console.info(` 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`);
|
|
205
|
+
console.info('');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
allPassed() {
|
|
209
|
+
return this.results.every(r => r.p95 <= TARGETS.p95LatencyMs &&
|
|
210
|
+
r.errPct <= TARGETS.errorRatePct &&
|
|
211
|
+
r.tput >= TARGETS.throughputQps);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/* ------------------------------------------------------------------
|
|
215
|
+
* CLI runner
|
|
216
|
+
* ------------------------------------------------------------------ */
|
|
217
|
+
async function main() {
|
|
218
|
+
const bench = new PoolBench();
|
|
219
|
+
const args = process.argv.slice(2);
|
|
220
|
+
const wantQuick = args.includes('--quick');
|
|
221
|
+
const wantTiers = args.includes('--tiers');
|
|
222
|
+
const wantCpx21 = args.includes('--tier') && args.includes('cpx21');
|
|
223
|
+
if (args.includes('--help')) {
|
|
224
|
+
console.info(`Usage: bun benchmark [options]
|
|
225
|
+
|
|
226
|
+
Options:
|
|
227
|
+
--quick Quick benchmark (pool=4, concurrency=20, 5s)
|
|
228
|
+
--tier cpx21 Simulate CPX21 tier (pool=4, concurrency=60, 10s)
|
|
229
|
+
--tiers Run all Hetzner tiers (CPX11/21/31/41)
|
|
230
|
+
--help Show this help
|
|
231
|
+
|
|
232
|
+
Targets: p95 < ${TARGETS.p95LatencyMs}ms, error < ${TARGETS.errorRatePct}%, throughput > ${TARGETS.throughputQps} qps
|
|
233
|
+
`);
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
if (wantTiers) {
|
|
238
|
+
for (const t of HETZNER_TIERS) {
|
|
239
|
+
await bench.run({
|
|
240
|
+
poolSize: t.pool,
|
|
241
|
+
concurrency: t.pool * 15,
|
|
242
|
+
durationSec: 10,
|
|
243
|
+
op: 'mixed',
|
|
244
|
+
tierName: t.name,
|
|
245
|
+
tierCost: t.monthly
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else if (wantCpx21) {
|
|
250
|
+
const cpx21 = HETZNER_TIERS.find(t => t.name === 'CPX21');
|
|
251
|
+
await bench.run({
|
|
252
|
+
poolSize: cpx21.pool,
|
|
253
|
+
concurrency: cpx21.pool * 15,
|
|
254
|
+
durationSec: 10,
|
|
255
|
+
op: 'mixed',
|
|
256
|
+
tierName: cpx21.name,
|
|
257
|
+
tierCost: cpx21.monthly
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
else if (wantQuick) {
|
|
261
|
+
await bench.run({
|
|
262
|
+
poolSize: 4,
|
|
263
|
+
concurrency: 20,
|
|
264
|
+
durationSec: 5,
|
|
265
|
+
op: 'mixed'
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// Default: Quick test
|
|
270
|
+
await bench.run({
|
|
271
|
+
poolSize: 4,
|
|
272
|
+
concurrency: 20,
|
|
273
|
+
durationSec: 5,
|
|
274
|
+
op: 'mixed'
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
bench.print();
|
|
278
|
+
const ok = bench.allPassed();
|
|
279
|
+
process.exit(ok ? 0 : 1);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
console.error('❌ Benchmark failed:', error);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Shared load test utilities for application-level HTTP throughput testing.
|
|
4
|
+
*
|
|
5
|
+
* Unlike benchmark.ts (which tests raw SQLite pool.exec() calls), these utilities
|
|
6
|
+
* measure end-to-end HTTP performance through the full stack: HTTP server, middleware,
|
|
7
|
+
* ORM (Prisma), SSR rendering, etc.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* Import into app-specific load tests:
|
|
11
|
+
* import { runLoadTest, formatResults } from '@bitclaw/sqlite/load-test-utils'
|
|
12
|
+
*
|
|
13
|
+
* Or run directly for a quick test:
|
|
14
|
+
* bun run packages/sqlite/scripts/load-test-utils.ts --url http://localhost:3001
|
|
15
|
+
*/
|
|
16
|
+
export type LoadTestConfig = {
|
|
17
|
+
/** Base URL of the application (e.g., http://localhost:3001) */
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/** Endpoints to test, relative to baseUrl */
|
|
20
|
+
endpoints: EndpointConfig[];
|
|
21
|
+
/** Concurrency levels to test */
|
|
22
|
+
concurrencyLevels: number[];
|
|
23
|
+
/** Duration per scenario in seconds */
|
|
24
|
+
durationSec: number;
|
|
25
|
+
/** Optional: warm-up requests before timing */
|
|
26
|
+
warmupRequests?: number;
|
|
27
|
+
};
|
|
28
|
+
export type EndpointConfig = {
|
|
29
|
+
/** Path relative to baseUrl (e.g., '/healthcheck') */
|
|
30
|
+
path: string;
|
|
31
|
+
/** HTTP method (default: GET) */
|
|
32
|
+
method?: string;
|
|
33
|
+
/** Request body for POST/PUT */
|
|
34
|
+
body?: string;
|
|
35
|
+
/** Additional headers */
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
/** Human-readable label */
|
|
38
|
+
label?: string;
|
|
39
|
+
};
|
|
40
|
+
export type RequestResult = {
|
|
41
|
+
statusCode: number;
|
|
42
|
+
latencyMs: number;
|
|
43
|
+
success: boolean;
|
|
44
|
+
bodySize: number;
|
|
45
|
+
};
|
|
46
|
+
export type ScenarioResult = {
|
|
47
|
+
endpoint: string;
|
|
48
|
+
label: string;
|
|
49
|
+
method: string;
|
|
50
|
+
concurrency: number;
|
|
51
|
+
durationSec: number;
|
|
52
|
+
totalRequests: number;
|
|
53
|
+
successCount: number;
|
|
54
|
+
failCount: number;
|
|
55
|
+
successRate: number;
|
|
56
|
+
throughput: number;
|
|
57
|
+
p50: number;
|
|
58
|
+
p95: number;
|
|
59
|
+
p99: number;
|
|
60
|
+
min: number;
|
|
61
|
+
max: number;
|
|
62
|
+
avg: number;
|
|
63
|
+
statusCodes: Record<number, number>;
|
|
64
|
+
avgBodySize: number;
|
|
65
|
+
via?: 'cdn' | 'direct';
|
|
66
|
+
};
|
|
67
|
+
export type LoadTestResults = {
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
startedAt: string;
|
|
70
|
+
completedAt: string;
|
|
71
|
+
scenarios: ScenarioResult[];
|
|
72
|
+
};
|
|
73
|
+
export declare function percentile(sorted: number[], p: number): number;
|
|
74
|
+
export declare function measureResponseTime(url: string, method?: string, body?: string, headers?: Record<string, string>): Promise<RequestResult>;
|
|
75
|
+
export declare function runLoadTest(config: LoadTestConfig): Promise<LoadTestResults>;
|
|
76
|
+
export declare function formatResults(results: LoadTestResults): string;
|
|
77
|
+
//# sourceMappingURL=load-test-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load-test-utils.d.ts","sourceRoot":"","sources":["../../scripts/load-test-utils.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;GAaG;AAKH,MAAM,MAAM,cAAc,GAAG;IAC3B,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,iCAAiC;IACjC,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gCAAgC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IAEpB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IAEpB,UAAU,EAAE,MAAM,CAAC;IAEnB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IAEZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,WAAW,EAAE,MAAM,CAAC;IAEpB,GAAG,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,cAAc,EAAE,CAAC;CAC7B,CAAC;AAKF,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAS9D;AAKD,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,MAAM,SAAQ,EACd,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC/B,OAAO,CAAC,aAAa,CAAC,CA6BxB;AA8FD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,eAAe,CAAC,CA0B1B;AAKD,wBAAgB,aAAa,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAoE9D"}
|