@rulvar/store-postgres 1.93.0 → 1.95.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 +88 -2
- package/dist/index.js +280 -5
- 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, shared with the sqlite store. */
|
|
@@ -112,4 +112,90 @@ declare class PostgresStore implements MetaLookupStore, LeasableStore {
|
|
|
112
112
|
release(l: Lease): Promise<void>;
|
|
113
113
|
}
|
|
114
114
|
//#endregion
|
|
115
|
-
|
|
115
|
+
//#region src/quota.d.ts
|
|
116
|
+
/**
|
|
117
|
+
* How long a reserve/reconcile transaction waits for the schema-wide
|
|
118
|
+
* admission lock before postgres cancels the statement. Quota
|
|
119
|
+
* admissions are short single-writer transactions; queueing here IS
|
|
120
|
+
* the cross-host serialization working.
|
|
121
|
+
*/
|
|
122
|
+
declare const QUOTA_LOCK_TIMEOUT_MS = 2e3;
|
|
123
|
+
interface PostgresQuotaLimiterOptions {
|
|
124
|
+
/**
|
|
125
|
+
* A postgres connection string shared by every coordinating process
|
|
126
|
+
* and host (the database may also hold a PostgresStore; the tables
|
|
127
|
+
* do not collide).
|
|
128
|
+
*/
|
|
129
|
+
url: string;
|
|
130
|
+
/**
|
|
131
|
+
* Schema holding the two quota tables; default `public`. A
|
|
132
|
+
* non-public schema is created on boot. Must be a plain SQL
|
|
133
|
+
* identifier.
|
|
134
|
+
*/
|
|
135
|
+
schema?: string;
|
|
136
|
+
/** The shared rule set; must be identical across hosts. */
|
|
137
|
+
rules: readonly QuotaRule[];
|
|
138
|
+
/** Pool size ceiling; default 10. Admissions are short transactions. */
|
|
139
|
+
max?: number;
|
|
140
|
+
/** Injectable clock for window tests. */
|
|
141
|
+
now?: () => number;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The multi-host reference implementation of the core QuotaLimiter
|
|
145
|
+
* SPI: engine processes pointing instances at ONE database and schema
|
|
146
|
+
* (a PostgresStore's database or their own) enforce one global
|
|
147
|
+
* provider quota. Admission consumes the window counters inside a
|
|
148
|
+
* single transaction serialized on a schema-wide advisory transaction
|
|
149
|
+
* lock, so two processes or HOSTS can never both take the last slot;
|
|
150
|
+
* reservations are rows, so `reconcile` settles a grant from any host;
|
|
151
|
+
* both tables are lazily pruned to the current and previous accounting
|
|
152
|
+
* window. The rule model, the fixed epoch-aligned one-minute windows,
|
|
153
|
+
* and the admission decision are the core's own exported functions, so
|
|
154
|
+
* this limiter, `memoryQuotaLimiter`, and `SqliteQuotaLimiter` agree
|
|
155
|
+
* on every verdict. The `rules` MUST be identical across coordinating
|
|
156
|
+
* processes (buckets key on rule content). Runtime contention queues
|
|
157
|
+
* on the advisory lock (a hot limiter is EXPECTED to serialize); a
|
|
158
|
+
* call still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws, and the
|
|
159
|
+
* engine's `onLimiterError` policy decides what that means. Call
|
|
160
|
+
* `close()` when done.
|
|
161
|
+
*/
|
|
162
|
+
declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
163
|
+
private readonly pool;
|
|
164
|
+
private readonly schema;
|
|
165
|
+
private readonly rules;
|
|
166
|
+
private readonly now;
|
|
167
|
+
private boot;
|
|
168
|
+
constructor(options: PostgresQuotaLimiterOptions);
|
|
169
|
+
/** `"schema".rulvar_<name>`, always schema-qualified. */
|
|
170
|
+
private table;
|
|
171
|
+
/**
|
|
172
|
+
* The lazy idempotent bootstrap, memoized so it runs once per
|
|
173
|
+
* limiter; a rejected boot clears the memo so the next call retries.
|
|
174
|
+
* The schema-scoped advisory transaction lock serializes a fleet of
|
|
175
|
+
* processes bootstrapping the same fresh database (the PostgresStore
|
|
176
|
+
* pattern: postgres queues on the lock and needs no busy retry).
|
|
177
|
+
*/
|
|
178
|
+
private booted;
|
|
179
|
+
private runBootstrap;
|
|
180
|
+
/**
|
|
181
|
+
* One serialized admission transaction: BEGIN, bound the lock wait,
|
|
182
|
+
* take the schema-wide quota advisory lock, run `fn`, COMMIT. Every
|
|
183
|
+
* counter mutation goes through here, which is what makes the
|
|
184
|
+
* verdict read and the consume one unit across processes and hosts.
|
|
185
|
+
*/
|
|
186
|
+
private withQuotaLock;
|
|
187
|
+
reserve(request: QuotaReservationRequest): Promise<QuotaDecision>;
|
|
188
|
+
reconcile(reservationId: string, usage: Usage): Promise<void>;
|
|
189
|
+
/** Current-window counters per rule, for telemetry and referees. */
|
|
190
|
+
snapshot(): Promise<Array<{
|
|
191
|
+
rule: QuotaRule;
|
|
192
|
+
windowStart: number;
|
|
193
|
+
requests: number;
|
|
194
|
+
tokens: number;
|
|
195
|
+
}>>;
|
|
196
|
+
close(): Promise<void>;
|
|
197
|
+
/** Both tables stay bounded to the current and previous window. */
|
|
198
|
+
private prune;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_LOCK_TIMEOUT_MS };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from "pg";
|
|
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
|
* PostgresStore (RV-214): JournalStore plus LeasableStore with fencing
|
|
@@ -56,9 +57,12 @@ import { ConfigError, JournalOrderViolation, LeaseHeldError, metaMatchesFilter }
|
|
|
56
57
|
const DEFAULT_LEASE_TTL_MS = 6e4;
|
|
57
58
|
/** Default pg Pool size; every operation is a short transaction. */
|
|
58
59
|
const DEFAULT_POOL_MAX = 10;
|
|
59
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The advisory-lock hash seed; a constant namespace shared by this
|
|
62
|
+
* store and the quota limiter beside it (their key strings differ).
|
|
63
|
+
*/
|
|
60
64
|
const LOCK_SEED = 8214;
|
|
61
|
-
const wallClock = Date.now.bind(globalThis);
|
|
65
|
+
const wallClock$1 = Date.now.bind(globalThis);
|
|
62
66
|
/** A conservative SQL identifier gate for the schema option. */
|
|
63
67
|
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
64
68
|
var PostgresStore = class {
|
|
@@ -89,7 +93,7 @@ var PostgresStore = class {
|
|
|
89
93
|
if (options.max !== void 0 && (!Number.isInteger(options.max) || options.max < 1)) throw new ConfigError(`PostgresStoreOptions.max must be a positive integer; got ${String(options.max)}`);
|
|
90
94
|
this.schema = schema;
|
|
91
95
|
this.ttlMs = ttlMs;
|
|
92
|
-
this.now = options.now ?? wallClock;
|
|
96
|
+
this.now = options.now ?? wallClock$1;
|
|
93
97
|
this.pool = new pg.Pool({
|
|
94
98
|
connectionString: options.url,
|
|
95
99
|
max: options.max ?? 10
|
|
@@ -408,4 +412,275 @@ var PostgresStore = class {
|
|
|
408
412
|
}
|
|
409
413
|
};
|
|
410
414
|
//#endregion
|
|
411
|
-
|
|
415
|
+
//#region src/quota.ts
|
|
416
|
+
/**
|
|
417
|
+
* PostgresQuotaLimiter (RV410): the multi-host reference
|
|
418
|
+
* implementation of the core QuotaLimiter SPI over node-postgres.
|
|
419
|
+
* Engine processes on ANY number of hosts pointing instances at one
|
|
420
|
+
* database and schema enforce ONE global quota: admission consumes the
|
|
421
|
+
* window counters inside a single transaction that first takes a
|
|
422
|
+
* schema-wide advisory transaction lock (the PostgresStore translation
|
|
423
|
+
* of the sqlite BEGIN IMMEDIATE lesson: checking in one statement and
|
|
424
|
+
* mutating in the next leaves a window where two admitters both read
|
|
425
|
+
* the last slot), so two hosts can never both take it.
|
|
426
|
+
*
|
|
427
|
+
* Contract highlights:
|
|
428
|
+
* - The rule model, window math, and admission decision are the
|
|
429
|
+
* core's own (`quotaRuleAdmission` over fixed epoch-aligned
|
|
430
|
+
* one-minute windows), so this limiter, `memoryQuotaLimiter`, and
|
|
431
|
+
* `SqliteQuotaLimiter` agree byte-for-byte on every verdict.
|
|
432
|
+
* - Buckets key on the rule CONTENT (a canonical fixed-order JSON of
|
|
433
|
+
* the rule), not on array position: every process sharing the
|
|
434
|
+
* database must configure the same rules, and equal rules land on
|
|
435
|
+
* the same bucket regardless of order.
|
|
436
|
+
* - Reservations are rows, so reconcile works from any host. A
|
|
437
|
+
* crashed process that never reconciles leaves its estimate in the
|
|
438
|
+
* window until the window ages out; the lazy prune keeps both
|
|
439
|
+
* tables bounded to two windows.
|
|
440
|
+
* - Runtime contention queues on the advisory lock instead of failing
|
|
441
|
+
* raw: a hot limiter is EXPECTED to serialize admissions. A call
|
|
442
|
+
* still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws (postgres
|
|
443
|
+
* cancels the statement under `SET LOCAL lock_timeout`), and the
|
|
444
|
+
* engine's `onLimiterError` policy decides what that means.
|
|
445
|
+
* - The durable admission QUEUE stays the host's: a denial carries the
|
|
446
|
+
* honest window remainder, and what to do while waiting (park the
|
|
447
|
+
* run, spill to another provider, surface backpressure) is host
|
|
448
|
+
* policy, exactly as documented for the other references.
|
|
449
|
+
*
|
|
450
|
+
* Docs: https://docs.rulvar.com/guide/model-routing
|
|
451
|
+
*/
|
|
452
|
+
/**
|
|
453
|
+
* How long a reserve/reconcile transaction waits for the schema-wide
|
|
454
|
+
* admission lock before postgres cancels the statement. Quota
|
|
455
|
+
* admissions are short single-writer transactions; queueing here IS
|
|
456
|
+
* the cross-host serialization working.
|
|
457
|
+
*/
|
|
458
|
+
const QUOTA_LOCK_TIMEOUT_MS = 2e3;
|
|
459
|
+
const wallClock = Date.now.bind(globalThis);
|
|
460
|
+
/**
|
|
461
|
+
* The canonical bucket key of one rule: a fixed-field-order JSON of
|
|
462
|
+
* its content, identical across hosts for identical rules (and
|
|
463
|
+
* identical to the SqliteQuotaLimiter encoding).
|
|
464
|
+
*/
|
|
465
|
+
function ruleKey(rule) {
|
|
466
|
+
return JSON.stringify({
|
|
467
|
+
provider: rule.provider ?? null,
|
|
468
|
+
model: rule.model ?? null,
|
|
469
|
+
tenant: rule.tenant ?? null,
|
|
470
|
+
requestsPerMinute: rule.requestsPerMinute ?? null,
|
|
471
|
+
tokensPerMinute: rule.tokensPerMinute ?? null
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The multi-host reference implementation of the core QuotaLimiter
|
|
476
|
+
* SPI: engine processes pointing instances at ONE database and schema
|
|
477
|
+
* (a PostgresStore's database or their own) enforce one global
|
|
478
|
+
* provider quota. Admission consumes the window counters inside a
|
|
479
|
+
* single transaction serialized on a schema-wide advisory transaction
|
|
480
|
+
* lock, so two processes or HOSTS can never both take the last slot;
|
|
481
|
+
* reservations are rows, so `reconcile` settles a grant from any host;
|
|
482
|
+
* both tables are lazily pruned to the current and previous accounting
|
|
483
|
+
* window. The rule model, the fixed epoch-aligned one-minute windows,
|
|
484
|
+
* and the admission decision are the core's own exported functions, so
|
|
485
|
+
* this limiter, `memoryQuotaLimiter`, and `SqliteQuotaLimiter` agree
|
|
486
|
+
* on every verdict. The `rules` MUST be identical across coordinating
|
|
487
|
+
* processes (buckets key on rule content). Runtime contention queues
|
|
488
|
+
* on the advisory lock (a hot limiter is EXPECTED to serialize); a
|
|
489
|
+
* call still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws, and the
|
|
490
|
+
* engine's `onLimiterError` policy decides what that means. Call
|
|
491
|
+
* `close()` when done.
|
|
492
|
+
*/
|
|
493
|
+
var PostgresQuotaLimiter = class {
|
|
494
|
+
pool;
|
|
495
|
+
schema;
|
|
496
|
+
rules;
|
|
497
|
+
now;
|
|
498
|
+
boot;
|
|
499
|
+
constructor(options) {
|
|
500
|
+
if (typeof options.url !== "string" || options.url === "") throw new ConfigError("PostgresQuotaLimiterOptions.url must be a nonempty connection string");
|
|
501
|
+
const schema = options.schema ?? "public";
|
|
502
|
+
if (!IDENTIFIER.test(schema)) throw new ConfigError(`PostgresQuotaLimiterOptions.schema must be a plain SQL identifier; got '${schema}'`);
|
|
503
|
+
validateQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
|
|
504
|
+
if (options.max !== void 0 && (!Number.isInteger(options.max) || options.max < 1)) throw new ConfigError(`PostgresQuotaLimiterOptions.max must be a positive integer; got ${String(options.max)}`);
|
|
505
|
+
this.schema = schema;
|
|
506
|
+
this.rules = options.rules;
|
|
507
|
+
this.now = options.now ?? wallClock;
|
|
508
|
+
this.pool = new pg.Pool({
|
|
509
|
+
connectionString: options.url,
|
|
510
|
+
max: options.max ?? 10
|
|
511
|
+
});
|
|
512
|
+
this.pool.on("error", () => void 0);
|
|
513
|
+
}
|
|
514
|
+
/** `"schema".rulvar_<name>`, always schema-qualified. */
|
|
515
|
+
table(name) {
|
|
516
|
+
return `"${this.schema}".rulvar_${name}`;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The lazy idempotent bootstrap, memoized so it runs once per
|
|
520
|
+
* limiter; a rejected boot clears the memo so the next call retries.
|
|
521
|
+
* The schema-scoped advisory transaction lock serializes a fleet of
|
|
522
|
+
* processes bootstrapping the same fresh database (the PostgresStore
|
|
523
|
+
* pattern: postgres queues on the lock and needs no busy retry).
|
|
524
|
+
*/
|
|
525
|
+
booted() {
|
|
526
|
+
this.boot ??= this.runBootstrap().catch((thrown) => {
|
|
527
|
+
this.boot = void 0;
|
|
528
|
+
throw thrown;
|
|
529
|
+
});
|
|
530
|
+
return this.boot;
|
|
531
|
+
}
|
|
532
|
+
async runBootstrap() {
|
|
533
|
+
const client = await this.pool.connect();
|
|
534
|
+
try {
|
|
535
|
+
await client.query("BEGIN");
|
|
536
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`rulvar-quota-boot:${this.schema}`, LOCK_SEED]);
|
|
537
|
+
if (this.schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
|
|
538
|
+
await client.query(`
|
|
539
|
+
CREATE TABLE IF NOT EXISTS ${this.table("quota_buckets")} (
|
|
540
|
+
rule_key TEXT NOT NULL,
|
|
541
|
+
window_start BIGINT NOT NULL,
|
|
542
|
+
requests BIGINT NOT NULL,
|
|
543
|
+
tokens BIGINT NOT NULL,
|
|
544
|
+
PRIMARY KEY (rule_key, window_start)
|
|
545
|
+
);
|
|
546
|
+
CREATE TABLE IF NOT EXISTS ${this.table("quota_reservations")} (
|
|
547
|
+
id TEXT PRIMARY KEY,
|
|
548
|
+
window_start BIGINT NOT NULL,
|
|
549
|
+
estimate_tokens BIGINT NOT NULL,
|
|
550
|
+
rule_keys TEXT NOT NULL
|
|
551
|
+
);
|
|
552
|
+
`);
|
|
553
|
+
await client.query("COMMIT");
|
|
554
|
+
} catch (thrown) {
|
|
555
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
556
|
+
throw thrown;
|
|
557
|
+
} finally {
|
|
558
|
+
client.release();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* One serialized admission transaction: BEGIN, bound the lock wait,
|
|
563
|
+
* take the schema-wide quota advisory lock, run `fn`, COMMIT. Every
|
|
564
|
+
* counter mutation goes through here, which is what makes the
|
|
565
|
+
* verdict read and the consume one unit across processes and hosts.
|
|
566
|
+
*/
|
|
567
|
+
async withQuotaLock(fn) {
|
|
568
|
+
await this.booted();
|
|
569
|
+
const client = await this.pool.connect();
|
|
570
|
+
try {
|
|
571
|
+
await client.query("BEGIN");
|
|
572
|
+
await client.query(`SET LOCAL lock_timeout = '${String(QUOTA_LOCK_TIMEOUT_MS)}ms'`);
|
|
573
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`${this.schema}:rulvar-quota`, LOCK_SEED]);
|
|
574
|
+
const result = await fn(client);
|
|
575
|
+
await client.query("COMMIT");
|
|
576
|
+
return result;
|
|
577
|
+
} catch (thrown) {
|
|
578
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
579
|
+
throw thrown;
|
|
580
|
+
} finally {
|
|
581
|
+
client.release();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
reserve(request) {
|
|
585
|
+
const at = this.now();
|
|
586
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
587
|
+
const estimateTokens = quotaEstimateTokens(request);
|
|
588
|
+
return this.withQuotaLock(async (client) => {
|
|
589
|
+
await this.prune(client, windowStart);
|
|
590
|
+
const matched = [];
|
|
591
|
+
let denial;
|
|
592
|
+
for (const rule of this.rules) {
|
|
593
|
+
if (!quotaRuleMatches(rule, request)) continue;
|
|
594
|
+
const key = ruleKey(rule);
|
|
595
|
+
matched.push(key);
|
|
596
|
+
const row = (await client.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
|
|
597
|
+
FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [key, windowStart])).rows[0];
|
|
598
|
+
const verdict = quotaRuleAdmission(rule, row === void 0 ? {
|
|
599
|
+
requests: 0,
|
|
600
|
+
tokens: 0
|
|
601
|
+
} : {
|
|
602
|
+
requests: Number(row.requests),
|
|
603
|
+
tokens: Number(row.tokens)
|
|
604
|
+
}, {
|
|
605
|
+
requests: request.estimate.requests,
|
|
606
|
+
tokens: estimateTokens
|
|
607
|
+
}, windowStart + QUOTA_WINDOW_MS - at);
|
|
608
|
+
if (!verdict.admit) denial = mergeQuotaDenial(denial, verdict);
|
|
609
|
+
}
|
|
610
|
+
if (denial !== void 0) return {
|
|
611
|
+
granted: false,
|
|
612
|
+
...denial
|
|
613
|
+
};
|
|
614
|
+
for (const key of matched) await client.query(`INSERT INTO ${this.table("quota_buckets")} AS b
|
|
615
|
+
(rule_key, window_start, requests, tokens) VALUES ($1, $2, $3, $4)
|
|
616
|
+
ON CONFLICT (rule_key, window_start) DO UPDATE SET
|
|
617
|
+
requests = b.requests + excluded.requests, tokens = b.tokens + excluded.tokens`, [
|
|
618
|
+
key,
|
|
619
|
+
windowStart,
|
|
620
|
+
request.estimate.requests,
|
|
621
|
+
estimateTokens
|
|
622
|
+
]);
|
|
623
|
+
const reservationId = randomUUID();
|
|
624
|
+
await client.query(`INSERT INTO ${this.table("quota_reservations")}
|
|
625
|
+
(id, window_start, estimate_tokens, rule_keys) VALUES ($1, $2, $3, $4)`, [
|
|
626
|
+
reservationId,
|
|
627
|
+
windowStart,
|
|
628
|
+
estimateTokens,
|
|
629
|
+
JSON.stringify(matched)
|
|
630
|
+
]);
|
|
631
|
+
return {
|
|
632
|
+
granted: true,
|
|
633
|
+
reservationId
|
|
634
|
+
};
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
reconcile(reservationId, usage) {
|
|
638
|
+
const at = this.now();
|
|
639
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
640
|
+
return this.withQuotaLock(async (client) => {
|
|
641
|
+
const row = (await client.query(`SELECT window_start::int8 AS window_start, estimate_tokens::int8 AS estimate_tokens,
|
|
642
|
+
rule_keys
|
|
643
|
+
FROM ${this.table("quota_reservations")} WHERE id = $1`, [reservationId])).rows[0];
|
|
644
|
+
if (row === void 0) return;
|
|
645
|
+
await client.query(`DELETE FROM ${this.table("quota_reservations")} WHERE id = $1`, [reservationId]);
|
|
646
|
+
if (Number(row.window_start) === windowStart) {
|
|
647
|
+
const delta = quotaActualTokens(usage) - Number(row.estimate_tokens);
|
|
648
|
+
for (const key of JSON.parse(row.rule_keys)) await client.query(`UPDATE ${this.table("quota_buckets")} SET tokens = GREATEST(0, tokens + $1)
|
|
649
|
+
WHERE rule_key = $2 AND window_start = $3`, [
|
|
650
|
+
delta,
|
|
651
|
+
key,
|
|
652
|
+
windowStart
|
|
653
|
+
]);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
/** Current-window counters per rule, for telemetry and referees. */
|
|
658
|
+
async snapshot() {
|
|
659
|
+
await this.booted();
|
|
660
|
+
const at = this.now();
|
|
661
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
662
|
+
const out = [];
|
|
663
|
+
for (const rule of this.rules) {
|
|
664
|
+
const row = (await this.pool.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
|
|
665
|
+
FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [ruleKey(rule), windowStart])).rows[0];
|
|
666
|
+
out.push({
|
|
667
|
+
rule,
|
|
668
|
+
windowStart,
|
|
669
|
+
requests: row === void 0 ? 0 : Number(row.requests),
|
|
670
|
+
tokens: row === void 0 ? 0 : Number(row.tokens)
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
async close() {
|
|
676
|
+
await this.pool.end();
|
|
677
|
+
}
|
|
678
|
+
/** Both tables stay bounded to the current and previous window. */
|
|
679
|
+
async prune(client, windowStart) {
|
|
680
|
+
const cutoff = windowStart - QUOTA_WINDOW_MS;
|
|
681
|
+
await client.query(`DELETE FROM ${this.table("quota_buckets")} WHERE window_start < $1`, [cutoff]);
|
|
682
|
+
await client.query(`DELETE FROM ${this.table("quota_reservations")} WHERE window_start < $1`, [cutoff]);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
//#endregion
|
|
686
|
+
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_LOCK_TIMEOUT_MS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/store-postgres",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.95.0",
|
|
4
4
|
"description": "Rulvar PostgreSQL store implementing JournalStore and LeasableStore with a fencing epoch, for multi-process and multi-host deployments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -23,14 +23,14 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"pg": "^8.22.0",
|
|
26
|
-
"@rulvar/core": "1.
|
|
26
|
+
"@rulvar/core": "1.95.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.20.1",
|
|
30
30
|
"@types/pg": "^8.20.0",
|
|
31
31
|
"tsdown": "^0.22.14",
|
|
32
32
|
"typescript": "~6.0.3",
|
|
33
|
-
"@rulvar/store-conformance": "1.
|
|
33
|
+
"@rulvar/store-conformance": "1.95.0"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|