@rulvar/store-postgres 1.104.0 → 1.105.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 +72 -9
- package/dist/index.js +171 -38
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -147,17 +147,56 @@ declare class QuotaDeadlineError extends Error {
|
|
|
147
147
|
readonly deadlineMs: number;
|
|
148
148
|
/** The schema whose admission missed it. */
|
|
149
149
|
readonly schema: string;
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Where the path stood: inside the schema bootstrap transaction,
|
|
152
|
+
* waiting for a pooled connection, or mid-admission-transaction. The
|
|
153
|
+
* message narrates only what actually happened to a connection in
|
|
154
|
+
* that phase (RV608): a refusal while WAITING held nothing, so it
|
|
155
|
+
* destroyed nothing.
|
|
156
|
+
*/
|
|
157
|
+
readonly phase: "bootstrap" | "acquire" | "transaction";
|
|
158
|
+
constructor(deadlineMs: number, schema: string, phase: "bootstrap" | "acquire" | "transaction");
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Thrown by an admission whose booted rule identity no longer matches
|
|
162
|
+
* the schema's (RV608): another deployment rotated the recorded rules
|
|
163
|
+
* fingerprint and generation after this host booted, so admitting under
|
|
164
|
+
* the retired rules would silently split the budget across mismatched
|
|
165
|
+
* bucket keys. The refused host must restart with the current rule set;
|
|
166
|
+
* its outstanding reservations age out with their window (the same
|
|
167
|
+
* bounded residue a crashed process leaves), and the rotation carried
|
|
168
|
+
* current-window consumption conservatively. Like every limiter throw,
|
|
169
|
+
* it lands in the engine's `onLimiterError` policy.
|
|
170
|
+
*/
|
|
171
|
+
declare class QuotaGenerationError extends Error {
|
|
172
|
+
/** The schema whose recorded identity moved. */
|
|
173
|
+
readonly schema: string;
|
|
174
|
+
/** What this instance booted with. */
|
|
175
|
+
readonly booted: {
|
|
176
|
+
fingerprint: string;
|
|
177
|
+
generation: number;
|
|
178
|
+
};
|
|
179
|
+
/** What the schema records now (absent fields mean a wiped meta row). */
|
|
180
|
+
readonly recorded: {
|
|
181
|
+
fingerprint: string | undefined;
|
|
182
|
+
generation: number | undefined;
|
|
183
|
+
};
|
|
184
|
+
constructor(schema: string, booted: {
|
|
185
|
+
fingerprint: string;
|
|
186
|
+
generation: number;
|
|
187
|
+
}, recorded: {
|
|
188
|
+
fingerprint: string | undefined;
|
|
189
|
+
generation: number | undefined;
|
|
190
|
+
});
|
|
153
191
|
}
|
|
154
192
|
/**
|
|
155
193
|
* The canonical fingerprint of one rule SET (RV506): sha256 hex over
|
|
156
|
-
* the sorted canonical rule keys
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
194
|
+
* the sorted canonical rule keys (the core's `quotaRuleKey`, the same
|
|
195
|
+
* encoding both store references bucket on). Order-insensitive on
|
|
196
|
+
* purpose, matching bucket semantics (equal rules land on the same
|
|
197
|
+
* bucket regardless of array position), so reordering a config never
|
|
198
|
+
* reads as a rules change. Exported so a deployment can precompute the
|
|
199
|
+
* value it expects a schema to have recorded.
|
|
161
200
|
*/
|
|
162
201
|
declare function quotaRulesFingerprint(rules: readonly QuotaRule[]): string;
|
|
163
202
|
interface PostgresQuotaLimiterOptions {
|
|
@@ -234,10 +273,24 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
|
234
273
|
private readonly pool;
|
|
235
274
|
private readonly schema;
|
|
236
275
|
private readonly rules;
|
|
276
|
+
/** Matching order for the denial fold: canonical rule-key order
|
|
277
|
+
* (RV608), so permuted identical sets produce byte-identical
|
|
278
|
+
* refusals. Telemetry keeps the declared order. */
|
|
279
|
+
private readonly ordered;
|
|
280
|
+
/** Computed ONCE from the immutable snapshot at construction (RV608):
|
|
281
|
+
* what boot records and every admission re-verifies. */
|
|
282
|
+
private readonly fingerprint;
|
|
283
|
+
/** The schema generation this instance booted at; admissions re-read
|
|
284
|
+
* the schema's and are fenced typed on a mismatch (RV608). */
|
|
285
|
+
private generation;
|
|
237
286
|
private readonly admissionDeadlineMs;
|
|
238
287
|
private readonly acceptRulesUpdate;
|
|
239
288
|
private readonly now;
|
|
240
289
|
private boot;
|
|
290
|
+
/** The bootstrap transaction's connection while one is in flight, so
|
|
291
|
+
* a deadline can destroy it instead of letting an abandoned bootstrap
|
|
292
|
+
* commit DDL or a rotation late (RV608). */
|
|
293
|
+
private bootClient;
|
|
241
294
|
constructor(options: PostgresQuotaLimiterOptions);
|
|
242
295
|
/** `"schema".rulvar_<name>`, always schema-qualified. */
|
|
243
296
|
private table;
|
|
@@ -268,6 +321,16 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
|
268
321
|
* timeouts).
|
|
269
322
|
*/
|
|
270
323
|
private withQuotaLock;
|
|
324
|
+
/**
|
|
325
|
+
* The generation fence (RV608), run INSIDE every admission
|
|
326
|
+
* transaction after the lock is taken: the schema's recorded rule
|
|
327
|
+
* identity is re-read and compared to what this instance booted, so a
|
|
328
|
+
* host whose rules were rotated away admits NOTHING under retired
|
|
329
|
+
* bucket keys. The refusal is typed, and the boot memo is cleared so
|
|
330
|
+
* the host's next call re-boots into the honest boot-time refusal
|
|
331
|
+
* naming both fingerprints.
|
|
332
|
+
*/
|
|
333
|
+
private assertCurrentIdentity;
|
|
271
334
|
reserve(request: QuotaReservationRequest): Promise<QuotaDecision>;
|
|
272
335
|
reconcile(reservationId: string, usage: Usage): Promise<void>;
|
|
273
336
|
/** Current-window counters per rule, for telemetry and referees. */
|
|
@@ -282,4 +345,4 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
|
282
345
|
private prune;
|
|
283
346
|
}
|
|
284
347
|
//#endregion
|
|
285
|
-
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, quotaRulesFingerprint };
|
|
348
|
+
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, QuotaGenerationError, quotaRulesFingerprint };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pg from "pg";
|
|
2
|
-
import { ConfigError, JournalOrderViolation, LeaseHeldError, QUOTA_WINDOW_MS, mergeQuotaDenial, metaMatchesFilter, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleMatches,
|
|
2
|
+
import { ConfigError, JournalOrderViolation, LeaseHeldError, MAX_TIMER_DELAY_MS, QUOTA_WINDOW_MS, mergeQuotaDenial, metaMatchesFilter, quotaActualTokens, quotaEstimateTokens, quotaRuleAdmission, quotaRuleKey, quotaRuleMatches, snapshotQuotaRules } from "@rulvar/core";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
//#region src/store.ts
|
|
5
5
|
/**
|
|
@@ -495,43 +495,72 @@ var QuotaDeadlineError = class extends Error {
|
|
|
495
495
|
deadlineMs;
|
|
496
496
|
/** The schema whose admission missed it. */
|
|
497
497
|
schema;
|
|
498
|
-
/**
|
|
498
|
+
/**
|
|
499
|
+
* Where the path stood: inside the schema bootstrap transaction,
|
|
500
|
+
* waiting for a pooled connection, or mid-admission-transaction. The
|
|
501
|
+
* message narrates only what actually happened to a connection in
|
|
502
|
+
* that phase (RV608): a refusal while WAITING held nothing, so it
|
|
503
|
+
* destroyed nothing.
|
|
504
|
+
*/
|
|
499
505
|
phase;
|
|
500
506
|
constructor(deadlineMs, schema, phase) {
|
|
501
|
-
super(`PostgresQuotaLimiter admission over schema "${schema}" missed its ${String(deadlineMs)}ms deadline ` + (phase === "acquire" ? "while waiting for
|
|
507
|
+
super(`PostgresQuotaLimiter admission over schema "${schema}" missed its ${String(deadlineMs)}ms deadline ` + (phase === "bootstrap" ? "during schema bootstrap; the bootstrap transaction was abandoned and its connection destroyed, so nothing it staged (DDL, fingerprint, generation) was committed" : phase === "acquire" ? "while waiting for a pooled connection; the admission was abandoned before it held one" : "inside the admission transaction; the admission was abandoned and its connection destroyed"));
|
|
502
508
|
this.name = "QuotaDeadlineError";
|
|
503
509
|
this.deadlineMs = deadlineMs;
|
|
504
510
|
this.schema = schema;
|
|
505
511
|
this.phase = phase;
|
|
506
512
|
}
|
|
507
513
|
};
|
|
508
|
-
const wallClock = Date.now.bind(globalThis);
|
|
509
514
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
515
|
+
* Thrown by an admission whose booted rule identity no longer matches
|
|
516
|
+
* the schema's (RV608): another deployment rotated the recorded rules
|
|
517
|
+
* fingerprint and generation after this host booted, so admitting under
|
|
518
|
+
* the retired rules would silently split the budget across mismatched
|
|
519
|
+
* bucket keys. The refused host must restart with the current rule set;
|
|
520
|
+
* its outstanding reservations age out with their window (the same
|
|
521
|
+
* bounded residue a crashed process leaves), and the rotation carried
|
|
522
|
+
* current-window consumption conservatively. Like every limiter throw,
|
|
523
|
+
* it lands in the engine's `onLimiterError` policy.
|
|
513
524
|
*/
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
525
|
+
var QuotaGenerationError = class extends Error {
|
|
526
|
+
/** The schema whose recorded identity moved. */
|
|
527
|
+
schema;
|
|
528
|
+
/** What this instance booted with. */
|
|
529
|
+
booted;
|
|
530
|
+
/** What the schema records now (absent fields mean a wiped meta row). */
|
|
531
|
+
recorded;
|
|
532
|
+
constructor(schema, booted, recorded) {
|
|
533
|
+
super(`PostgresQuotaLimiter admission over schema "${schema}" was fenced: the schema now records rules fingerprint ${recorded.fingerprint ?? "(none)"} at generation ${String(recorded.generation ?? "(none)")}, this instance booted fingerprint ${booted.fingerprint} at generation ${String(booted.generation)}. The rules rotated after this host booted; restart it with the current rule set. Its outstanding reservations age out with their window, and the rotation carried current-window consumption conservatively.`);
|
|
534
|
+
this.name = "QuotaGenerationError";
|
|
535
|
+
this.schema = schema;
|
|
536
|
+
this.booted = booted;
|
|
537
|
+
this.recorded = recorded;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
const wallClock = Date.now.bind(globalThis);
|
|
523
541
|
/**
|
|
524
542
|
* The canonical fingerprint of one rule SET (RV506): sha256 hex over
|
|
525
|
-
* the sorted canonical rule keys
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
543
|
+
* the sorted canonical rule keys (the core's `quotaRuleKey`, the same
|
|
544
|
+
* encoding both store references bucket on). Order-insensitive on
|
|
545
|
+
* purpose, matching bucket semantics (equal rules land on the same
|
|
546
|
+
* bucket regardless of array position), so reordering a config never
|
|
547
|
+
* reads as a rules change. Exported so a deployment can precompute the
|
|
548
|
+
* value it expects a schema to have recorded.
|
|
530
549
|
*/
|
|
531
550
|
function quotaRulesFingerprint(rules) {
|
|
532
|
-
const keys = rules.map(
|
|
551
|
+
const keys = rules.map(quotaRuleKey).sort();
|
|
533
552
|
return createHash("sha256").update(JSON.stringify(keys)).digest("hex");
|
|
534
553
|
}
|
|
554
|
+
/** The dimension triple of one canonical rule key, the identity the
|
|
555
|
+
* rotation carry maps consumption across cap changes by. */
|
|
556
|
+
function dimensionTriple(key) {
|
|
557
|
+
const parsed = JSON.parse(key);
|
|
558
|
+
return JSON.stringify({
|
|
559
|
+
provider: parsed.provider ?? null,
|
|
560
|
+
model: parsed.model ?? null,
|
|
561
|
+
tenant: parsed.tenant ?? null
|
|
562
|
+
});
|
|
563
|
+
}
|
|
535
564
|
/**
|
|
536
565
|
* The multi-host reference implementation of the core QuotaLimiter
|
|
537
566
|
* SPI: engine processes pointing instances at ONE database and schema
|
|
@@ -563,19 +592,38 @@ var PostgresQuotaLimiter = class {
|
|
|
563
592
|
pool;
|
|
564
593
|
schema;
|
|
565
594
|
rules;
|
|
595
|
+
/** Matching order for the denial fold: canonical rule-key order
|
|
596
|
+
* (RV608), so permuted identical sets produce byte-identical
|
|
597
|
+
* refusals. Telemetry keeps the declared order. */
|
|
598
|
+
ordered;
|
|
599
|
+
/** Computed ONCE from the immutable snapshot at construction (RV608):
|
|
600
|
+
* what boot records and every admission re-verifies. */
|
|
601
|
+
fingerprint;
|
|
602
|
+
/** The schema generation this instance booted at; admissions re-read
|
|
603
|
+
* the schema's and are fenced typed on a mismatch (RV608). */
|
|
604
|
+
generation = 0;
|
|
566
605
|
admissionDeadlineMs;
|
|
567
606
|
acceptRulesUpdate;
|
|
568
607
|
now;
|
|
569
608
|
boot;
|
|
609
|
+
/** The bootstrap transaction's connection while one is in flight, so
|
|
610
|
+
* a deadline can destroy it instead of letting an abandoned bootstrap
|
|
611
|
+
* commit DDL or a rotation late (RV608). */
|
|
612
|
+
bootClient;
|
|
570
613
|
constructor(options) {
|
|
571
614
|
if (typeof options.url !== "string" || options.url === "") throw new ConfigError("PostgresQuotaLimiterOptions.url must be a nonempty connection string");
|
|
572
615
|
const schema = options.schema ?? "public";
|
|
573
616
|
if (!IDENTIFIER.test(schema)) throw new ConfigError(`PostgresQuotaLimiterOptions.schema must be a plain SQL identifier; got '${schema}'`);
|
|
574
|
-
validateQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
|
|
575
617
|
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)}`);
|
|
576
|
-
if (options.admissionDeadlineMs !== void 0 && (!Number.isInteger(options.admissionDeadlineMs) || options.admissionDeadlineMs <= 2e3)) throw new ConfigError(`PostgresQuotaLimiterOptions.admissionDeadlineMs must be an integer greater than QUOTA_LOCK_TIMEOUT_MS (${String(QUOTA_LOCK_TIMEOUT_MS)}), the lock-wait stage it bounds; got ${String(options.admissionDeadlineMs)}`);
|
|
618
|
+
if (options.admissionDeadlineMs !== void 0 && (!Number.isInteger(options.admissionDeadlineMs) || options.admissionDeadlineMs <= 2e3 || options.admissionDeadlineMs > MAX_TIMER_DELAY_MS)) throw new ConfigError(`PostgresQuotaLimiterOptions.admissionDeadlineMs must be an integer greater than QUOTA_LOCK_TIMEOUT_MS (${String(QUOTA_LOCK_TIMEOUT_MS)}), the lock-wait stage it bounds, and at most ${String(MAX_TIMER_DELAY_MS)} (the Node timer maximum, above which a timer fires after about a millisecond); got ${String(options.admissionDeadlineMs)}`);
|
|
619
|
+
if (options.acceptRulesUpdate !== void 0 && typeof options.acceptRulesUpdate !== "boolean") throw new ConfigError(`PostgresQuotaLimiterOptions.acceptRulesUpdate must be a boolean when given (it authorizes rewriting the schema's recorded rule identity, so truthiness is not enough); got ${JSON.stringify(options.acceptRulesUpdate)}`);
|
|
577
620
|
this.schema = schema;
|
|
578
|
-
this.rules = options.rules;
|
|
621
|
+
this.rules = snapshotQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
|
|
622
|
+
this.ordered = this.rules.map((rule) => ({
|
|
623
|
+
rule,
|
|
624
|
+
key: quotaRuleKey(rule)
|
|
625
|
+
})).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
626
|
+
this.fingerprint = quotaRulesFingerprint(this.rules);
|
|
579
627
|
this.admissionDeadlineMs = options.admissionDeadlineMs ?? 5e3;
|
|
580
628
|
this.acceptRulesUpdate = options.acceptRulesUpdate ?? false;
|
|
581
629
|
this.now = options.now ?? wallClock;
|
|
@@ -605,10 +653,12 @@ var PostgresQuotaLimiter = class {
|
|
|
605
653
|
return this.boot;
|
|
606
654
|
}
|
|
607
655
|
async runBootstrap() {
|
|
608
|
-
const fingerprint = quotaRulesFingerprint(this.rules);
|
|
609
656
|
const client = await this.pool.connect();
|
|
657
|
+
this.bootClient = client;
|
|
658
|
+
let destroyed = false;
|
|
610
659
|
try {
|
|
611
660
|
await client.query("BEGIN");
|
|
661
|
+
await client.query(`SET LOCAL lock_timeout = '${String(QUOTA_LOCK_TIMEOUT_MS)}ms'`);
|
|
612
662
|
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`rulvar-quota-boot:${this.schema}`, LOCK_SEED]);
|
|
613
663
|
if (this.schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
|
|
614
664
|
await client.query(`
|
|
@@ -630,20 +680,74 @@ var PostgresQuotaLimiter = class {
|
|
|
630
680
|
value TEXT
|
|
631
681
|
);
|
|
632
682
|
`);
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
683
|
+
const meta = new Map((await client.query(`SELECT key, value FROM ${this.table("quota_meta")}
|
|
684
|
+
WHERE key IN ('rules_fingerprint', 'rules_generation')`)).rows.map((row) => [row.key, row.value]));
|
|
685
|
+
const recorded = meta.get("rules_fingerprint");
|
|
686
|
+
const recordedGeneration = meta.get("rules_generation");
|
|
687
|
+
const writeIdentity = async (generation) => {
|
|
688
|
+
await client.query(`INSERT INTO ${this.table("quota_meta")} (key, value)
|
|
689
|
+
VALUES ('rules_fingerprint', $1), ('rules_generation', $2)
|
|
690
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value`, [this.fingerprint, String(generation)]);
|
|
691
|
+
};
|
|
692
|
+
if (recorded === void 0) {
|
|
693
|
+
this.generation = 1;
|
|
694
|
+
await writeIdentity(this.generation);
|
|
695
|
+
} else if (recorded === this.fingerprint) {
|
|
696
|
+
this.generation = recordedGeneration === void 0 ? 1 : Number(recordedGeneration);
|
|
697
|
+
if (recordedGeneration === void 0) await writeIdentity(this.generation);
|
|
698
|
+
} else if (this.acceptRulesUpdate) {
|
|
699
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`${this.schema}:rulvar-quota`, LOCK_SEED]);
|
|
700
|
+
const at = this.now();
|
|
701
|
+
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
702
|
+
const oldBuckets = (await client.query(`SELECT rule_key, requests::int8 AS requests, tokens::int8 AS tokens
|
|
703
|
+
FROM ${this.table("quota_buckets")} WHERE window_start = $1`, [windowStart])).rows;
|
|
704
|
+
const byTriple = /* @__PURE__ */ new Map();
|
|
705
|
+
for (const row of oldBuckets) {
|
|
706
|
+
const triple = dimensionTriple(row.rule_key);
|
|
707
|
+
const prior = byTriple.get(triple) ?? {
|
|
708
|
+
requests: 0,
|
|
709
|
+
tokens: 0
|
|
710
|
+
};
|
|
711
|
+
byTriple.set(triple, {
|
|
712
|
+
requests: Math.max(prior.requests, Number(row.requests)),
|
|
713
|
+
tokens: Math.max(prior.tokens, Number(row.tokens))
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
for (const { rule, key } of this.ordered) {
|
|
717
|
+
const carried = byTriple.get(JSON.stringify({
|
|
718
|
+
provider: rule.provider ?? null,
|
|
719
|
+
model: rule.model ?? null,
|
|
720
|
+
tenant: rule.tenant ?? null
|
|
721
|
+
}));
|
|
722
|
+
if (carried === void 0 || carried.requests === 0 && carried.tokens === 0) continue;
|
|
723
|
+
await client.query(`INSERT INTO ${this.table("quota_buckets")} AS b
|
|
724
|
+
(rule_key, window_start, requests, tokens) VALUES ($1, $2, $3, $4)
|
|
725
|
+
ON CONFLICT (rule_key, window_start) DO UPDATE SET
|
|
726
|
+
requests = GREATEST(b.requests, excluded.requests),
|
|
727
|
+
tokens = GREATEST(b.tokens, excluded.tokens)`, [
|
|
728
|
+
key,
|
|
729
|
+
windowStart,
|
|
730
|
+
carried.requests,
|
|
731
|
+
carried.tokens
|
|
732
|
+
]);
|
|
733
|
+
}
|
|
734
|
+
this.generation = (recordedGeneration === void 0 ? 1 : Number(recordedGeneration)) + 1;
|
|
735
|
+
await writeIdentity(this.generation);
|
|
736
|
+
} else throw new ConfigError(`PostgresQuotaLimiter rules mismatch over schema "${this.schema}": the schema records rules fingerprint ${recorded}, this instance computes ${this.fingerprint}. Every coordinating process must configure the identical rule set (buckets key on rule content, so a drifted host would silently split the budget). To rotate the rules, boot the new deployment with acceptRulesUpdate: true, then remove the flag.`, { data: {
|
|
637
737
|
schema: this.schema,
|
|
638
738
|
recorded,
|
|
639
|
-
computed: fingerprint
|
|
739
|
+
computed: this.fingerprint
|
|
640
740
|
} });
|
|
641
741
|
await client.query("COMMIT");
|
|
642
742
|
} catch (thrown) {
|
|
643
|
-
|
|
743
|
+
destroyed = this.bootClient === void 0;
|
|
744
|
+
if (!destroyed) await client.query("ROLLBACK").catch(() => void 0);
|
|
644
745
|
throw thrown;
|
|
645
746
|
} finally {
|
|
646
|
-
|
|
747
|
+
if (this.bootClient !== void 0) {
|
|
748
|
+
this.bootClient = void 0;
|
|
749
|
+
if (!destroyed) client.release();
|
|
750
|
+
}
|
|
647
751
|
}
|
|
648
752
|
}
|
|
649
753
|
/**
|
|
@@ -667,7 +771,7 @@ var PostgresQuotaLimiter = class {
|
|
|
667
771
|
let client;
|
|
668
772
|
let expired = false;
|
|
669
773
|
let timer;
|
|
670
|
-
const refusal = () => new QuotaDeadlineError(this.admissionDeadlineMs, this.schema, client
|
|
774
|
+
const refusal = () => new QuotaDeadlineError(this.admissionDeadlineMs, this.schema, client !== void 0 ? "transaction" : this.bootClient !== void 0 ? "bootstrap" : "acquire");
|
|
671
775
|
const work = (async () => {
|
|
672
776
|
await this.booted();
|
|
673
777
|
if (expired) throw refusal();
|
|
@@ -698,6 +802,8 @@ var PostgresQuotaLimiter = class {
|
|
|
698
802
|
expired = true;
|
|
699
803
|
client?.release(reason);
|
|
700
804
|
client = void 0;
|
|
805
|
+
this.bootClient?.release(reason);
|
|
806
|
+
this.bootClient = void 0;
|
|
701
807
|
reject(reason);
|
|
702
808
|
}, this.admissionDeadlineMs);
|
|
703
809
|
});
|
|
@@ -707,17 +813,43 @@ var PostgresQuotaLimiter = class {
|
|
|
707
813
|
clearTimeout(timer);
|
|
708
814
|
}
|
|
709
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* The generation fence (RV608), run INSIDE every admission
|
|
818
|
+
* transaction after the lock is taken: the schema's recorded rule
|
|
819
|
+
* identity is re-read and compared to what this instance booted, so a
|
|
820
|
+
* host whose rules were rotated away admits NOTHING under retired
|
|
821
|
+
* bucket keys. The refusal is typed, and the boot memo is cleared so
|
|
822
|
+
* the host's next call re-boots into the honest boot-time refusal
|
|
823
|
+
* naming both fingerprints.
|
|
824
|
+
*/
|
|
825
|
+
async assertCurrentIdentity(client) {
|
|
826
|
+
const meta = new Map((await client.query(`SELECT key, value FROM ${this.table("quota_meta")}
|
|
827
|
+
WHERE key IN ('rules_fingerprint', 'rules_generation')`)).rows.map((row) => [row.key, row.value]));
|
|
828
|
+
const fingerprint = meta.get("rules_fingerprint");
|
|
829
|
+
const generationRaw = meta.get("rules_generation");
|
|
830
|
+
const generation = generationRaw === void 0 ? void 0 : Number(generationRaw);
|
|
831
|
+
if (fingerprint !== this.fingerprint || generation !== this.generation) {
|
|
832
|
+
this.boot = void 0;
|
|
833
|
+
throw new QuotaGenerationError(this.schema, {
|
|
834
|
+
fingerprint: this.fingerprint,
|
|
835
|
+
generation: this.generation
|
|
836
|
+
}, {
|
|
837
|
+
fingerprint,
|
|
838
|
+
generation
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
710
842
|
reserve(request) {
|
|
711
843
|
const at = this.now();
|
|
712
844
|
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
713
845
|
const estimateTokens = quotaEstimateTokens(request);
|
|
714
846
|
return this.withQuotaLock(async (client) => {
|
|
847
|
+
await this.assertCurrentIdentity(client);
|
|
715
848
|
await this.prune(client, windowStart);
|
|
716
849
|
const matched = [];
|
|
717
850
|
let denial;
|
|
718
|
-
for (const rule of this.
|
|
851
|
+
for (const { rule, key } of this.ordered) {
|
|
719
852
|
if (!quotaRuleMatches(rule, request)) continue;
|
|
720
|
-
const key = ruleKey(rule);
|
|
721
853
|
matched.push(key);
|
|
722
854
|
const row = (await client.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
|
|
723
855
|
FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [key, windowStart])).rows[0];
|
|
@@ -764,6 +896,7 @@ var PostgresQuotaLimiter = class {
|
|
|
764
896
|
const at = this.now();
|
|
765
897
|
const windowStart = at - at % QUOTA_WINDOW_MS;
|
|
766
898
|
return this.withQuotaLock(async (client) => {
|
|
899
|
+
await this.assertCurrentIdentity(client);
|
|
767
900
|
const row = (await client.query(`SELECT window_start::int8 AS window_start, estimate_tokens::int8 AS estimate_tokens,
|
|
768
901
|
rule_keys
|
|
769
902
|
FROM ${this.table("quota_reservations")} WHERE id = $1`, [reservationId])).rows[0];
|
|
@@ -788,7 +921,7 @@ var PostgresQuotaLimiter = class {
|
|
|
788
921
|
const out = [];
|
|
789
922
|
for (const rule of this.rules) {
|
|
790
923
|
const row = (await this.pool.query(`SELECT requests::int8 AS requests, tokens::int8 AS tokens
|
|
791
|
-
FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [
|
|
924
|
+
FROM ${this.table("quota_buckets")} WHERE rule_key = $1 AND window_start = $2`, [quotaRuleKey(rule), windowStart])).rows[0];
|
|
792
925
|
out.push({
|
|
793
926
|
rule,
|
|
794
927
|
windowStart,
|
|
@@ -809,4 +942,4 @@ var PostgresQuotaLimiter = class {
|
|
|
809
942
|
}
|
|
810
943
|
};
|
|
811
944
|
//#endregion
|
|
812
|
-
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, quotaRulesFingerprint };
|
|
945
|
+
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, QuotaGenerationError, quotaRulesFingerprint };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/store-postgres",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.105.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.105.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.105.0"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|