@rulvar/store-sqlite 1.55.0 → 1.57.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/dist/index.d.ts +56 -2
- package/dist/index.js +223 -9
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JournalEntry, LeasableStore, Lease, MetaLookupStore, RunFilter, RunMeta, TranscriptStore } from "@rulvar/core";
|
|
1
|
+
import { JournalEntry, LeasableStore, Lease, MetaLookupStore, QuotaDecision, QuotaLimiter, QuotaReservationRequest, QuotaRule, RunFilter, RunMeta, TranscriptStore, Usage } from "@rulvar/core";
|
|
2
2
|
|
|
3
3
|
//#region src/store.d.ts
|
|
4
4
|
/** Appendix A interim reference for the sqlite store. */
|
|
@@ -113,4 +113,58 @@ declare class SqliteStore implements MetaLookupStore, LeasableStore {
|
|
|
113
113
|
release(l: Lease): Promise<void>;
|
|
114
114
|
}
|
|
115
115
|
//#endregion
|
|
116
|
-
|
|
116
|
+
//#region src/quota.d.ts
|
|
117
|
+
/**
|
|
118
|
+
* How long a runtime reserve/reconcile transaction waits for a
|
|
119
|
+
* sibling process's transaction before the driver reports busy. Quota
|
|
120
|
+
* admissions are short single-writer transactions; queueing here IS
|
|
121
|
+
* the cross-process serialization working.
|
|
122
|
+
*/
|
|
123
|
+
declare const QUOTA_BUSY_TIMEOUT_MS = 2e3;
|
|
124
|
+
interface SqliteQuotaLimiterOptions {
|
|
125
|
+
/** Database file path shared by every coordinating process. */
|
|
126
|
+
path: string;
|
|
127
|
+
/** The shared rule set; must be identical across processes. */
|
|
128
|
+
rules: readonly QuotaRule[];
|
|
129
|
+
/** Injectable clock for window tests. */
|
|
130
|
+
now?: () => number;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The cross-process reference implementation of the core QuotaLimiter
|
|
134
|
+
* SPI: engine processes pointing instances at ONE database file (this
|
|
135
|
+
* store's file or its own) enforce one global provider quota.
|
|
136
|
+
* Admission consumes the window counters inside a single
|
|
137
|
+
* `BEGIN IMMEDIATE` transaction, so two processes can never both take
|
|
138
|
+
* the last slot; reservations are rows, so `reconcile` settles a
|
|
139
|
+
* grant from any process; both tables are lazily pruned to the
|
|
140
|
+
* current and previous accounting window. The rule model, the fixed
|
|
141
|
+
* epoch-aligned one-minute windows, and the admission decision are
|
|
142
|
+
* the core's own exported functions, so this limiter and
|
|
143
|
+
* `memoryQuotaLimiter` agree on every verdict. The `rules` MUST be
|
|
144
|
+
* identical across coordinating processes (buckets key on rule
|
|
145
|
+
* content). Runtime contention queues briefly on the connection's
|
|
146
|
+
* busy_timeout (a hot limiter is EXPECTED to serialize); a call still
|
|
147
|
+
* busy past the bound throws, and the engine's `onLimiterError`
|
|
148
|
+
* policy decides what that means. Call `close()` when done.
|
|
149
|
+
*/
|
|
150
|
+
declare class SqliteQuotaLimiter implements QuotaLimiter {
|
|
151
|
+
private readonly db;
|
|
152
|
+
private readonly rules;
|
|
153
|
+
private readonly now;
|
|
154
|
+
constructor(options: SqliteQuotaLimiterOptions);
|
|
155
|
+
reserve(request: QuotaReservationRequest): Promise<QuotaDecision>;
|
|
156
|
+
reconcile(reservationId: string, usage: Usage): Promise<void>;
|
|
157
|
+
/** Current-window counters per rule, for telemetry and referees. */
|
|
158
|
+
snapshot(): Array<{
|
|
159
|
+
rule: QuotaRule;
|
|
160
|
+
windowStart: number;
|
|
161
|
+
requests: number;
|
|
162
|
+
tokens: number;
|
|
163
|
+
}>;
|
|
164
|
+
close(): void;
|
|
165
|
+
/** Both tables stay bounded to the current and previous window. */
|
|
166
|
+
private prune;
|
|
167
|
+
private rollbackQuietly;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
export { BOOT_BUSY_TIMEOUT_MS, DEFAULT_LEASE_TTL_MS, QUOTA_BUSY_TIMEOUT_MS, SqliteQuotaLimiter, type SqliteQuotaLimiterOptions, SqliteStore, type SqliteStoreOptions, type SqliteTranscriptStore };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import { ConfigError, JournalOrderViolation, LeaseHeldError, metaMatchesFilter } from "@rulvar/core";
|
|
2
|
+
import { ConfigError, JournalOrderViolation, LeaseHeldError, QUOTA_WINDOW_MS, mergeQuotaDenial, metaMatchesFilter, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleMatches, validateQuotaRules } from "@rulvar/core";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
//#region src/store.ts
|
|
4
5
|
/**
|
|
5
6
|
* SqliteStore (M5-T02): JournalStore plus LeasableStore with fencing
|
|
@@ -60,7 +61,7 @@ const BOOT_BUSY_TIMEOUT_MS = 5e3;
|
|
|
60
61
|
* also yields SQLITE_BUSY_RECOVERY 261 while a sibling process is
|
|
61
62
|
* recovering the fresh WAL), so the primary code is the low byte.
|
|
62
63
|
*/
|
|
63
|
-
function isSqliteBusy(thrown) {
|
|
64
|
+
function isSqliteBusy$1(thrown) {
|
|
64
65
|
const errcode = thrown?.errcode;
|
|
65
66
|
return errcode !== void 0 && (errcode & 255) === 5;
|
|
66
67
|
}
|
|
@@ -69,10 +70,10 @@ function isSqliteBusy(thrown) {
|
|
|
69
70
|
* synchronous by SPI shape): Atomics.wait blocks the thread for the few
|
|
70
71
|
* milliseconds another process needs to finish the shared bootstrap.
|
|
71
72
|
*/
|
|
72
|
-
function sleepSync(ms) {
|
|
73
|
+
function sleepSync$1(ms) {
|
|
73
74
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
74
75
|
}
|
|
75
|
-
const wallClock = Date.now.bind(globalThis);
|
|
76
|
+
const wallClock$1 = Date.now.bind(globalThis);
|
|
76
77
|
var SqliteStore = class {
|
|
77
78
|
/**
|
|
78
79
|
* The fenced writes promise (fenced run state RFC, phase 2): every
|
|
@@ -91,7 +92,7 @@ var SqliteStore = class {
|
|
|
91
92
|
if (!Number.isInteger(ttlMs) || ttlMs < 1 || ttlMs > 2147483647) throw new ConfigError(`SqliteStoreOptions.ttlMs must be an integer between 1 and 2147483647 ms (workers renew on Node timers at ttl/3); got ${String(ttlMs)}`);
|
|
92
93
|
this.db = new DatabaseSync(options.path);
|
|
93
94
|
this.ttlMs = ttlMs;
|
|
94
|
-
this.now = options.now ?? wallClock;
|
|
95
|
+
this.now = options.now ?? wallClock$1;
|
|
95
96
|
const schema = `
|
|
96
97
|
PRAGMA journal_mode = WAL;
|
|
97
98
|
CREATE TABLE IF NOT EXISTS entries (
|
|
@@ -127,13 +128,13 @@ var SqliteStore = class {
|
|
|
127
128
|
);
|
|
128
129
|
CREATE INDEX IF NOT EXISTS blobs_by_run ON blobs (run_id);
|
|
129
130
|
`;
|
|
130
|
-
const bootDeadline = wallClock() + BOOT_BUSY_TIMEOUT_MS;
|
|
131
|
+
const bootDeadline = wallClock$1() + BOOT_BUSY_TIMEOUT_MS;
|
|
131
132
|
for (let attempt = 0;; attempt += 1) try {
|
|
132
133
|
this.db.exec(schema);
|
|
133
134
|
break;
|
|
134
135
|
} catch (thrown) {
|
|
135
|
-
if (!isSqliteBusy(thrown) || wallClock() > bootDeadline) throw thrown;
|
|
136
|
-
sleepSync(2 + attempt % 7);
|
|
136
|
+
if (!isSqliteBusy$1(thrown) || wallClock$1() > bootDeadline) throw thrown;
|
|
137
|
+
sleepSync$1(2 + attempt % 7);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
close() {
|
|
@@ -354,4 +355,217 @@ var SqliteStore = class {
|
|
|
354
355
|
}
|
|
355
356
|
};
|
|
356
357
|
//#endregion
|
|
357
|
-
|
|
358
|
+
//#region src/quota.ts
|
|
359
|
+
/**
|
|
360
|
+
* SqliteQuotaLimiter (RV-215): the cross-process reference
|
|
361
|
+
* implementation of the core QuotaLimiter SPI over the builtin
|
|
362
|
+
* node:sqlite driver. Engine processes sharing one database file
|
|
363
|
+
* enforce ONE global quota: admission consumes window counters inside
|
|
364
|
+
* a single BEGIN IMMEDIATE transaction (the fenced-run-state lesson:
|
|
365
|
+
* checking in one autocommit statement and mutating in the next
|
|
366
|
+
* leaves a cross-process window where two admitters both read the
|
|
367
|
+
* last slot), so two processes can never both take it.
|
|
368
|
+
*
|
|
369
|
+
* Contract highlights:
|
|
370
|
+
* - The rule model, window math, and admission decision are the
|
|
371
|
+
* core's own (`quotaRuleAdmission` over fixed epoch-aligned
|
|
372
|
+
* one-minute windows), so this limiter and `memoryQuotaLimiter`
|
|
373
|
+
* agree byte-for-byte on every verdict.
|
|
374
|
+
* - Buckets key on the rule CONTENT (a canonical fixed-order JSON of
|
|
375
|
+
* the rule), not on array position: every process sharing the file
|
|
376
|
+
* must configure the same rules, and equal rules land on the same
|
|
377
|
+
* bucket regardless of order.
|
|
378
|
+
* - Reservations are rows, so reconcile works from any process. A
|
|
379
|
+
* crashed process that never reconciles leaves its estimate in the
|
|
380
|
+
* window until the window ages out; the lazy prune keeps both
|
|
381
|
+
* tables bounded to two windows.
|
|
382
|
+
* - Runtime contention queues briefly on the connection's
|
|
383
|
+
* busy_timeout instead of failing raw: a hot limiter is EXPECTED to
|
|
384
|
+
* serialize admissions. A still-busy call past the bound throws,
|
|
385
|
+
* and the engine's `onLimiterError` policy decides what that means.
|
|
386
|
+
*
|
|
387
|
+
* Docs: https://docs.rulvar.com/guide/model-routing
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* How long a runtime reserve/reconcile transaction waits for a
|
|
391
|
+
* sibling process's transaction before the driver reports busy. Quota
|
|
392
|
+
* admissions are short single-writer transactions; queueing here IS
|
|
393
|
+
* the cross-process serialization working.
|
|
394
|
+
*/
|
|
395
|
+
const QUOTA_BUSY_TIMEOUT_MS = 2e3;
|
|
396
|
+
/** The SQLITE_BUSY family; the primary code is the low byte. */
|
|
397
|
+
function isSqliteBusy(thrown) {
|
|
398
|
+
const errcode = thrown?.errcode;
|
|
399
|
+
return errcode !== void 0 && (errcode & 255) === 5;
|
|
400
|
+
}
|
|
401
|
+
/** Synchronous bounded sleep for the boot retry loop. */
|
|
402
|
+
function sleepSync(ms) {
|
|
403
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
404
|
+
}
|
|
405
|
+
const wallClock = Date.now.bind(globalThis);
|
|
406
|
+
/**
|
|
407
|
+
* The canonical bucket key of one rule: a fixed-field-order JSON of
|
|
408
|
+
* its content, identical across processes for identical rules.
|
|
409
|
+
*/
|
|
410
|
+
function ruleKey(rule) {
|
|
411
|
+
return JSON.stringify({
|
|
412
|
+
provider: rule.provider ?? null,
|
|
413
|
+
model: rule.model ?? null,
|
|
414
|
+
tenant: rule.tenant ?? null,
|
|
415
|
+
requestsPerMinute: rule.requestsPerMinute ?? null,
|
|
416
|
+
tokensPerMinute: rule.tokensPerMinute ?? null
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The cross-process reference implementation of the core QuotaLimiter
|
|
421
|
+
* SPI: engine processes pointing instances at ONE database file (this
|
|
422
|
+
* store's file or its own) enforce one global provider quota.
|
|
423
|
+
* Admission consumes the window counters inside a single
|
|
424
|
+
* `BEGIN IMMEDIATE` transaction, so two processes can never both take
|
|
425
|
+
* the last slot; reservations are rows, so `reconcile` settles a
|
|
426
|
+
* grant from any process; both tables are lazily pruned to the
|
|
427
|
+
* current and previous accounting window. The rule model, the fixed
|
|
428
|
+
* epoch-aligned one-minute windows, and the admission decision are
|
|
429
|
+
* the core's own exported functions, so this limiter and
|
|
430
|
+
* `memoryQuotaLimiter` agree on every verdict. The `rules` MUST be
|
|
431
|
+
* identical across coordinating processes (buckets key on rule
|
|
432
|
+
* content). Runtime contention queues briefly on the connection's
|
|
433
|
+
* busy_timeout (a hot limiter is EXPECTED to serialize); a call still
|
|
434
|
+
* busy past the bound throws, and the engine's `onLimiterError`
|
|
435
|
+
* policy decides what that means. Call `close()` when done.
|
|
436
|
+
*/
|
|
437
|
+
var SqliteQuotaLimiter = class {
|
|
438
|
+
db;
|
|
439
|
+
rules;
|
|
440
|
+
now;
|
|
441
|
+
constructor(options) {
|
|
442
|
+
if (typeof options.path !== "string" || options.path === "") throw new ConfigError("SqliteQuotaLimiterOptions.path must be a nonempty string");
|
|
443
|
+
validateQuotaRules(options.rules, "SqliteQuotaLimiterOptions.rules");
|
|
444
|
+
this.rules = options.rules;
|
|
445
|
+
this.now = options.now ?? wallClock;
|
|
446
|
+
this.db = new DatabaseSync(options.path);
|
|
447
|
+
const schema = `
|
|
448
|
+
PRAGMA busy_timeout = ${String(QUOTA_BUSY_TIMEOUT_MS)};
|
|
449
|
+
PRAGMA journal_mode = WAL;
|
|
450
|
+
CREATE TABLE IF NOT EXISTS quota_buckets (
|
|
451
|
+
rule_key TEXT NOT NULL,
|
|
452
|
+
window_start INTEGER NOT NULL,
|
|
453
|
+
requests INTEGER NOT NULL,
|
|
454
|
+
tokens INTEGER NOT NULL,
|
|
455
|
+
PRIMARY KEY (rule_key, window_start)
|
|
456
|
+
);
|
|
457
|
+
CREATE TABLE IF NOT EXISTS quota_reservations (
|
|
458
|
+
id TEXT PRIMARY KEY,
|
|
459
|
+
window_start INTEGER NOT NULL,
|
|
460
|
+
estimate_tokens INTEGER NOT NULL,
|
|
461
|
+
rule_keys TEXT NOT NULL
|
|
462
|
+
);
|
|
463
|
+
`;
|
|
464
|
+
const bootDeadline = wallClock() + BOOT_BUSY_TIMEOUT_MS;
|
|
465
|
+
for (;;) try {
|
|
466
|
+
this.db.exec(schema);
|
|
467
|
+
break;
|
|
468
|
+
} catch (thrown) {
|
|
469
|
+
if (!isSqliteBusy(thrown) || wallClock() > bootDeadline) throw thrown;
|
|
470
|
+
sleepSync(25);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
reserve(request) {
|
|
474
|
+
const at = this.now();
|
|
475
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
476
|
+
const estimateTokens = quotaEstimateTokens(request);
|
|
477
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
478
|
+
try {
|
|
479
|
+
this.prune(windowStart);
|
|
480
|
+
const read = this.db.prepare("SELECT requests, tokens FROM quota_buckets WHERE rule_key = ? AND window_start = ?");
|
|
481
|
+
const matched = [];
|
|
482
|
+
let denial;
|
|
483
|
+
for (const rule of this.rules) {
|
|
484
|
+
if (!quotaRuleMatches(rule, request)) continue;
|
|
485
|
+
const key = ruleKey(rule);
|
|
486
|
+
matched.push(key);
|
|
487
|
+
const verdict = quotaRuleAdmission(rule, read.get(key, windowStart) ?? {
|
|
488
|
+
requests: 0,
|
|
489
|
+
tokens: 0
|
|
490
|
+
}, {
|
|
491
|
+
requests: request.estimate.requests,
|
|
492
|
+
tokens: estimateTokens
|
|
493
|
+
}, windowStart + QUOTA_WINDOW_MS - at);
|
|
494
|
+
if (!verdict.admit) denial = mergeQuotaDenial(denial, verdict);
|
|
495
|
+
}
|
|
496
|
+
if (denial !== void 0) {
|
|
497
|
+
this.db.exec("COMMIT");
|
|
498
|
+
return Promise.resolve({
|
|
499
|
+
granted: false,
|
|
500
|
+
...denial
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const consume = this.db.prepare("INSERT INTO quota_buckets (rule_key, window_start, requests, tokens) VALUES (?, ?, ?, ?) ON CONFLICT (rule_key, window_start) DO UPDATE SET requests = requests + excluded.requests, tokens = tokens + excluded.tokens");
|
|
504
|
+
for (const key of matched) consume.run(key, windowStart, request.estimate.requests, estimateTokens);
|
|
505
|
+
const reservationId = randomUUID();
|
|
506
|
+
this.db.prepare("INSERT INTO quota_reservations (id, window_start, estimate_tokens, rule_keys) VALUES (?, ?, ?, ?)").run(reservationId, windowStart, estimateTokens, JSON.stringify(matched));
|
|
507
|
+
this.db.exec("COMMIT");
|
|
508
|
+
return Promise.resolve({
|
|
509
|
+
granted: true,
|
|
510
|
+
reservationId
|
|
511
|
+
});
|
|
512
|
+
} catch (thrown) {
|
|
513
|
+
this.rollbackQuietly();
|
|
514
|
+
throw thrown;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
reconcile(reservationId, usage) {
|
|
518
|
+
const at = this.now();
|
|
519
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
520
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
521
|
+
try {
|
|
522
|
+
const row = this.db.prepare("SELECT window_start, estimate_tokens, rule_keys FROM quota_reservations WHERE id = ?").get(reservationId);
|
|
523
|
+
if (row === void 0) {
|
|
524
|
+
this.db.exec("COMMIT");
|
|
525
|
+
return Promise.resolve();
|
|
526
|
+
}
|
|
527
|
+
this.db.prepare("DELETE FROM quota_reservations WHERE id = ?").run(reservationId);
|
|
528
|
+
if (row.window_start === windowStart) {
|
|
529
|
+
const delta = quotaActualTokens(usage) - row.estimate_tokens;
|
|
530
|
+
const adjust = this.db.prepare("UPDATE quota_buckets SET tokens = MAX(0, tokens + ?) WHERE rule_key = ? AND window_start = ?");
|
|
531
|
+
for (const key of JSON.parse(row.rule_keys)) adjust.run(delta, key, windowStart);
|
|
532
|
+
}
|
|
533
|
+
this.db.exec("COMMIT");
|
|
534
|
+
return Promise.resolve();
|
|
535
|
+
} catch (thrown) {
|
|
536
|
+
this.rollbackQuietly();
|
|
537
|
+
throw thrown;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Current-window counters per rule, for telemetry and referees. */
|
|
541
|
+
snapshot() {
|
|
542
|
+
const at = this.now();
|
|
543
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
544
|
+
const read = this.db.prepare("SELECT requests, tokens FROM quota_buckets WHERE rule_key = ? AND window_start = ?");
|
|
545
|
+
return this.rules.map((rule) => {
|
|
546
|
+
const row = read.get(ruleKey(rule), windowStart);
|
|
547
|
+
return {
|
|
548
|
+
rule,
|
|
549
|
+
windowStart,
|
|
550
|
+
requests: row?.requests ?? 0,
|
|
551
|
+
tokens: row?.tokens ?? 0
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
close() {
|
|
556
|
+
this.db.close();
|
|
557
|
+
}
|
|
558
|
+
/** Both tables stay bounded to the current and previous window. */
|
|
559
|
+
prune(windowStart) {
|
|
560
|
+
const cutoff = windowStart - QUOTA_WINDOW_MS;
|
|
561
|
+
this.db.prepare("DELETE FROM quota_buckets WHERE window_start < ?").run(cutoff);
|
|
562
|
+
this.db.prepare("DELETE FROM quota_reservations WHERE window_start < ?").run(cutoff);
|
|
563
|
+
}
|
|
564
|
+
rollbackQuietly() {
|
|
565
|
+
try {
|
|
566
|
+
this.db.exec("ROLLBACK");
|
|
567
|
+
} catch {}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
//#endregion
|
|
571
|
+
export { BOOT_BUSY_TIMEOUT_MS, DEFAULT_LEASE_TTL_MS, QUOTA_BUSY_TIMEOUT_MS, SqliteQuotaLimiter, SqliteStore };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/store-sqlite",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.57.0",
|
|
4
4
|
"description": "Rulvar SQLite store implementing JournalStore and LeasableStore with a fencing epoch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rulvar/core": "1.
|
|
25
|
+
"@rulvar/core": "1.57.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.20.0",
|
|
29
29
|
"tsdown": "^0.22.3",
|
|
30
30
|
"typescript": "~6.0.3",
|
|
31
|
-
"@rulvar/store-conformance": "1.
|
|
31
|
+
"@rulvar/store-conformance": "1.57.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|