@rulvar/store-postgres 1.96.0 → 1.98.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 +91 -7
- package/dist/index.js +147 -21
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -120,6 +120,46 @@ declare class PostgresStore implements MetaLookupStore, LeasableStore {
|
|
|
120
120
|
* the cross-host serialization working.
|
|
121
121
|
*/
|
|
122
122
|
declare const QUOTA_LOCK_TIMEOUT_MS = 2e3;
|
|
123
|
+
/**
|
|
124
|
+
* The default bound on one WHOLE admission path (RV506): lazy
|
|
125
|
+
* bootstrap, pool checkout, and the admission transaction together.
|
|
126
|
+
* `QUOTA_LOCK_TIMEOUT_MS` bounds only the lock-wait stage inside the
|
|
127
|
+
* transaction; before RV506 a call could spend that bound once at
|
|
128
|
+
* checkout and again at the lock and still not be refused. Overridable
|
|
129
|
+
* per limiter through `admissionDeadlineMs`.
|
|
130
|
+
*/
|
|
131
|
+
declare const QUOTA_ADMISSION_DEADLINE_MS = 5e3;
|
|
132
|
+
/**
|
|
133
|
+
* Thrown when one quota admission (reserve or reconcile) misses the
|
|
134
|
+
* full-path deadline. It surfaces exactly where the lock timeout
|
|
135
|
+
* surfaces, as a limiter error consumed by the engine's
|
|
136
|
+
* `onLimiterError` policy: `'deny'` (the default) turns it into a
|
|
137
|
+
* retryable transport-class denial, so nothing dispatches unpoliced.
|
|
138
|
+
* The connection the refused call held is destroyed, never returned
|
|
139
|
+
* dirty to the pool; a transaction cut mid-flight is rolled back by
|
|
140
|
+
* the server. Like any client-side timeout, expiry exactly at the
|
|
141
|
+
* commit boundary can leave a committed reservation behind; it ages
|
|
142
|
+
* out with its window unreconciled, the same bounded residue a
|
|
143
|
+
* crashed process leaves.
|
|
144
|
+
*/
|
|
145
|
+
declare class QuotaDeadlineError extends Error {
|
|
146
|
+
/** The deadline that expired, in milliseconds. */
|
|
147
|
+
readonly deadlineMs: number;
|
|
148
|
+
/** The schema whose admission missed it. */
|
|
149
|
+
readonly schema: string;
|
|
150
|
+
/** Where the path stood: acquiring a connection, or mid-transaction. */
|
|
151
|
+
readonly phase: "acquire" | "transaction";
|
|
152
|
+
constructor(deadlineMs: number, schema: string, phase: "acquire" | "transaction");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The canonical fingerprint of one rule SET (RV506): sha256 hex over
|
|
156
|
+
* the sorted canonical rule keys. Order-insensitive on purpose,
|
|
157
|
+
* matching bucket semantics (equal rules land on the same bucket
|
|
158
|
+
* regardless of array position), so reordering a config never reads as
|
|
159
|
+
* a rules change. Exported so a deployment can precompute the value it
|
|
160
|
+
* expects a schema to have recorded.
|
|
161
|
+
*/
|
|
162
|
+
declare function quotaRulesFingerprint(rules: readonly QuotaRule[]): string;
|
|
123
163
|
interface PostgresQuotaLimiterOptions {
|
|
124
164
|
/**
|
|
125
165
|
* A postgres connection string shared by every coordinating process
|
|
@@ -133,10 +173,33 @@ interface PostgresQuotaLimiterOptions {
|
|
|
133
173
|
* identifier.
|
|
134
174
|
*/
|
|
135
175
|
schema?: string;
|
|
136
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* The shared rule set; must be identical across hosts. Enforced: the
|
|
178
|
+
* schema records `quotaRulesFingerprint(rules)` on first boot, and an
|
|
179
|
+
* instance whose fingerprint differs is refused with a typed
|
|
180
|
+
* `ConfigError` naming both hashes.
|
|
181
|
+
*/
|
|
137
182
|
rules: readonly QuotaRule[];
|
|
138
183
|
/** Pool size ceiling; default 10. Admissions are short transactions. */
|
|
139
184
|
max?: number;
|
|
185
|
+
/**
|
|
186
|
+
* Bound on one whole admission path (bootstrap, pool checkout, and
|
|
187
|
+
* the admission transaction together); default
|
|
188
|
+
* `QUOTA_ADMISSION_DEADLINE_MS` (5000). Must be an integer strictly
|
|
189
|
+
* greater than `QUOTA_LOCK_TIMEOUT_MS`, which bounds only the
|
|
190
|
+
* lock-wait stage inside it. Expiry throws `QuotaDeadlineError` into
|
|
191
|
+
* the engine's `onLimiterError` policy and destroys the connection
|
|
192
|
+
* the refused call held.
|
|
193
|
+
*/
|
|
194
|
+
admissionDeadlineMs?: number;
|
|
195
|
+
/**
|
|
196
|
+
* Rules rotation opt-in: rewrite the schema's recorded rules
|
|
197
|
+
* fingerprint with this instance's own at boot instead of refusing on
|
|
198
|
+
* a mismatch. Procedure: enable on the NEW deployment, roll every
|
|
199
|
+
* host to the new rule set, then remove the flag so drift is refused
|
|
200
|
+
* again. Default false.
|
|
201
|
+
*/
|
|
202
|
+
acceptRulesUpdate?: boolean;
|
|
140
203
|
/** Injectable clock for window tests. */
|
|
141
204
|
now?: () => number;
|
|
142
205
|
}
|
|
@@ -153,16 +216,26 @@ interface PostgresQuotaLimiterOptions {
|
|
|
153
216
|
* and the admission decision are the core's own exported functions, so
|
|
154
217
|
* this limiter, `memoryQuotaLimiter`, and `SqliteQuotaLimiter` agree
|
|
155
218
|
* on every verdict. The `rules` MUST be identical across coordinating
|
|
156
|
-
* processes (buckets key on rule content)
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
219
|
+
* processes (buckets key on rule content), and since RV506 that is
|
|
220
|
+
* enforced: boot records `quotaRulesFingerprint(rules)` in the
|
|
221
|
+
* schema's `rulvar_quota_meta` row and refuses a drifted instance with
|
|
222
|
+
* a typed `ConfigError` naming both hashes (`acceptRulesUpdate: true`
|
|
223
|
+
* rotates the record). Runtime contention queues on the advisory lock
|
|
224
|
+
* (a hot limiter is EXPECTED to serialize; note the lock serializes
|
|
225
|
+
* `reserve` AND `reconcile`, so it sees admission attempts plus
|
|
226
|
+
* grants); a call still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws,
|
|
227
|
+
* and the whole admission path (bootstrap, checkout, transaction) is
|
|
228
|
+
* bounded by `admissionDeadlineMs`, whose expiry throws a typed
|
|
229
|
+
* `QuotaDeadlineError` and destroys the held connection. Both throws
|
|
230
|
+
* land in the engine's `onLimiterError` policy, which decides what
|
|
231
|
+
* they mean. Call `close()` when done.
|
|
161
232
|
*/
|
|
162
233
|
declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
163
234
|
private readonly pool;
|
|
164
235
|
private readonly schema;
|
|
165
236
|
private readonly rules;
|
|
237
|
+
private readonly admissionDeadlineMs;
|
|
238
|
+
private readonly acceptRulesUpdate;
|
|
166
239
|
private readonly now;
|
|
167
240
|
private boot;
|
|
168
241
|
constructor(options: PostgresQuotaLimiterOptions);
|
|
@@ -182,6 +255,17 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
|
182
255
|
* take the schema-wide quota advisory lock, run `fn`, COMMIT. Every
|
|
183
256
|
* counter mutation goes through here, which is what makes the
|
|
184
257
|
* verdict read and the consume one unit across processes and hosts.
|
|
258
|
+
*
|
|
259
|
+
* The WHOLE path (lazy bootstrap, pool checkout, transaction) races
|
|
260
|
+
* `admissionDeadlineMs` (RV506): `lock_timeout` bounds one stage, and
|
|
261
|
+
* before the deadline a call could spend that bound at checkout and
|
|
262
|
+
* again at the lock without ever being refused. On expiry the caller
|
|
263
|
+
* gets a typed `QuotaDeadlineError`, and a connection the refused
|
|
264
|
+
* call holds is destroyed through `release(err)`, never returned
|
|
265
|
+
* mid-transaction to the pool; destroying it also cancels the
|
|
266
|
+
* abandoned wait server-side. The deadline runs on the REAL clock
|
|
267
|
+
* (an injected test `now` freezes window math, not infrastructure
|
|
268
|
+
* timeouts).
|
|
185
269
|
*/
|
|
186
270
|
private withQuotaLock;
|
|
187
271
|
reserve(request: QuotaReservationRequest): Promise<QuotaDecision>;
|
|
@@ -198,4 +282,4 @@ declare class PostgresQuotaLimiter implements QuotaLimiter {
|
|
|
198
282
|
private prune;
|
|
199
283
|
}
|
|
200
284
|
//#endregion
|
|
201
|
-
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, type PostgresQuotaLimiterOptions, PostgresStore, type PostgresStoreOptions, type PostgresTranscriptStore, QUOTA_LOCK_TIMEOUT_MS };
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pg from "pg";
|
|
2
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
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
//#region src/store.ts
|
|
5
5
|
/**
|
|
6
6
|
* PostgresStore (RV-214): JournalStore plus LeasableStore with fencing
|
|
@@ -441,7 +441,19 @@ var PostgresStore = class {
|
|
|
441
441
|
* raw: a hot limiter is EXPECTED to serialize admissions. A call
|
|
442
442
|
* still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws (postgres
|
|
443
443
|
* cancels the statement under `SET LOCAL lock_timeout`), and the
|
|
444
|
-
* engine's `onLimiterError` policy decides what that means.
|
|
444
|
+
* engine's `onLimiterError` policy decides what that means. The lock
|
|
445
|
+
* serializes `reserve` AND `reconcile`, so its arrival rate is
|
|
446
|
+
* admission attempts PLUS grants, not attempts alone.
|
|
447
|
+
* - The lock bound is one STAGE; the whole admission path (bootstrap,
|
|
448
|
+
* pool checkout, transaction) is bounded by `admissionDeadlineMs`
|
|
449
|
+
* (default `QUOTA_ADMISSION_DEADLINE_MS`, RV506). Expiry throws a
|
|
450
|
+
* typed `QuotaDeadlineError` into the same `onLimiterError` surface
|
|
451
|
+
* and destroys the held connection instead of returning it dirty.
|
|
452
|
+
* - Boot records the sha256 fingerprint of the canonical rule set in
|
|
453
|
+
* `rulvar_quota_meta` under the boot lock; a host with a drifted
|
|
454
|
+
* rule set is refused typed with both hashes instead of silently
|
|
455
|
+
* splitting the budget across mismatched bucket keys. Rotation is
|
|
456
|
+
* the explicit `acceptRulesUpdate` opt-in (RV506).
|
|
445
457
|
* - The durable admission QUEUE stays the host's: a denial carries the
|
|
446
458
|
* honest window remainder, and what to do while waiting (park the
|
|
447
459
|
* run, spill to another provider, surface backpressure) is host
|
|
@@ -456,6 +468,43 @@ var PostgresStore = class {
|
|
|
456
468
|
* the cross-host serialization working.
|
|
457
469
|
*/
|
|
458
470
|
const QUOTA_LOCK_TIMEOUT_MS = 2e3;
|
|
471
|
+
/**
|
|
472
|
+
* The default bound on one WHOLE admission path (RV506): lazy
|
|
473
|
+
* bootstrap, pool checkout, and the admission transaction together.
|
|
474
|
+
* `QUOTA_LOCK_TIMEOUT_MS` bounds only the lock-wait stage inside the
|
|
475
|
+
* transaction; before RV506 a call could spend that bound once at
|
|
476
|
+
* checkout and again at the lock and still not be refused. Overridable
|
|
477
|
+
* per limiter through `admissionDeadlineMs`.
|
|
478
|
+
*/
|
|
479
|
+
const QUOTA_ADMISSION_DEADLINE_MS = 5e3;
|
|
480
|
+
/**
|
|
481
|
+
* Thrown when one quota admission (reserve or reconcile) misses the
|
|
482
|
+
* full-path deadline. It surfaces exactly where the lock timeout
|
|
483
|
+
* surfaces, as a limiter error consumed by the engine's
|
|
484
|
+
* `onLimiterError` policy: `'deny'` (the default) turns it into a
|
|
485
|
+
* retryable transport-class denial, so nothing dispatches unpoliced.
|
|
486
|
+
* The connection the refused call held is destroyed, never returned
|
|
487
|
+
* dirty to the pool; a transaction cut mid-flight is rolled back by
|
|
488
|
+
* the server. Like any client-side timeout, expiry exactly at the
|
|
489
|
+
* commit boundary can leave a committed reservation behind; it ages
|
|
490
|
+
* out with its window unreconciled, the same bounded residue a
|
|
491
|
+
* crashed process leaves.
|
|
492
|
+
*/
|
|
493
|
+
var QuotaDeadlineError = class extends Error {
|
|
494
|
+
/** The deadline that expired, in milliseconds. */
|
|
495
|
+
deadlineMs;
|
|
496
|
+
/** The schema whose admission missed it. */
|
|
497
|
+
schema;
|
|
498
|
+
/** Where the path stood: acquiring a connection, or mid-transaction. */
|
|
499
|
+
phase;
|
|
500
|
+
constructor(deadlineMs, schema, phase) {
|
|
501
|
+
super(`PostgresQuotaLimiter admission over schema "${schema}" missed its ${String(deadlineMs)}ms deadline ` + (phase === "acquire" ? "while waiting for bootstrap or a pooled connection" : "inside the admission transaction") + "; the in-flight admission was abandoned and its connection destroyed");
|
|
502
|
+
this.name = "QuotaDeadlineError";
|
|
503
|
+
this.deadlineMs = deadlineMs;
|
|
504
|
+
this.schema = schema;
|
|
505
|
+
this.phase = phase;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
459
508
|
const wallClock = Date.now.bind(globalThis);
|
|
460
509
|
/**
|
|
461
510
|
* The canonical bucket key of one rule: a fixed-field-order JSON of
|
|
@@ -472,6 +521,18 @@ function ruleKey(rule) {
|
|
|
472
521
|
});
|
|
473
522
|
}
|
|
474
523
|
/**
|
|
524
|
+
* The canonical fingerprint of one rule SET (RV506): sha256 hex over
|
|
525
|
+
* the sorted canonical rule keys. Order-insensitive on purpose,
|
|
526
|
+
* matching bucket semantics (equal rules land on the same bucket
|
|
527
|
+
* regardless of array position), so reordering a config never reads as
|
|
528
|
+
* a rules change. Exported so a deployment can precompute the value it
|
|
529
|
+
* expects a schema to have recorded.
|
|
530
|
+
*/
|
|
531
|
+
function quotaRulesFingerprint(rules) {
|
|
532
|
+
const keys = rules.map(ruleKey).sort();
|
|
533
|
+
return createHash("sha256").update(JSON.stringify(keys)).digest("hex");
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
475
536
|
* The multi-host reference implementation of the core QuotaLimiter
|
|
476
537
|
* SPI: engine processes pointing instances at ONE database and schema
|
|
477
538
|
* (a PostgresStore's database or their own) enforce one global
|
|
@@ -484,16 +545,26 @@ function ruleKey(rule) {
|
|
|
484
545
|
* and the admission decision are the core's own exported functions, so
|
|
485
546
|
* this limiter, `memoryQuotaLimiter`, and `SqliteQuotaLimiter` agree
|
|
486
547
|
* on every verdict. The `rules` MUST be identical across coordinating
|
|
487
|
-
* processes (buckets key on rule content)
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
548
|
+
* processes (buckets key on rule content), and since RV506 that is
|
|
549
|
+
* enforced: boot records `quotaRulesFingerprint(rules)` in the
|
|
550
|
+
* schema's `rulvar_quota_meta` row and refuses a drifted instance with
|
|
551
|
+
* a typed `ConfigError` naming both hashes (`acceptRulesUpdate: true`
|
|
552
|
+
* rotates the record). Runtime contention queues on the advisory lock
|
|
553
|
+
* (a hot limiter is EXPECTED to serialize; note the lock serializes
|
|
554
|
+
* `reserve` AND `reconcile`, so it sees admission attempts plus
|
|
555
|
+
* grants); a call still waiting past `QUOTA_LOCK_TIMEOUT_MS` throws,
|
|
556
|
+
* and the whole admission path (bootstrap, checkout, transaction) is
|
|
557
|
+
* bounded by `admissionDeadlineMs`, whose expiry throws a typed
|
|
558
|
+
* `QuotaDeadlineError` and destroys the held connection. Both throws
|
|
559
|
+
* land in the engine's `onLimiterError` policy, which decides what
|
|
560
|
+
* they mean. Call `close()` when done.
|
|
492
561
|
*/
|
|
493
562
|
var PostgresQuotaLimiter = class {
|
|
494
563
|
pool;
|
|
495
564
|
schema;
|
|
496
565
|
rules;
|
|
566
|
+
admissionDeadlineMs;
|
|
567
|
+
acceptRulesUpdate;
|
|
497
568
|
now;
|
|
498
569
|
boot;
|
|
499
570
|
constructor(options) {
|
|
@@ -502,12 +573,16 @@ var PostgresQuotaLimiter = class {
|
|
|
502
573
|
if (!IDENTIFIER.test(schema)) throw new ConfigError(`PostgresQuotaLimiterOptions.schema must be a plain SQL identifier; got '${schema}'`);
|
|
503
574
|
validateQuotaRules(options.rules, "PostgresQuotaLimiterOptions.rules");
|
|
504
575
|
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)}`);
|
|
505
577
|
this.schema = schema;
|
|
506
578
|
this.rules = options.rules;
|
|
579
|
+
this.admissionDeadlineMs = options.admissionDeadlineMs ?? 5e3;
|
|
580
|
+
this.acceptRulesUpdate = options.acceptRulesUpdate ?? false;
|
|
507
581
|
this.now = options.now ?? wallClock;
|
|
508
582
|
this.pool = new pg.Pool({
|
|
509
583
|
connectionString: options.url,
|
|
510
|
-
max: options.max ?? 10
|
|
584
|
+
max: options.max ?? 10,
|
|
585
|
+
connectionTimeoutMillis: this.admissionDeadlineMs
|
|
511
586
|
});
|
|
512
587
|
this.pool.on("error", () => void 0);
|
|
513
588
|
}
|
|
@@ -530,6 +605,7 @@ var PostgresQuotaLimiter = class {
|
|
|
530
605
|
return this.boot;
|
|
531
606
|
}
|
|
532
607
|
async runBootstrap() {
|
|
608
|
+
const fingerprint = quotaRulesFingerprint(this.rules);
|
|
533
609
|
const client = await this.pool.connect();
|
|
534
610
|
try {
|
|
535
611
|
await client.query("BEGIN");
|
|
@@ -549,7 +625,19 @@ var PostgresQuotaLimiter = class {
|
|
|
549
625
|
estimate_tokens BIGINT NOT NULL,
|
|
550
626
|
rule_keys TEXT NOT NULL
|
|
551
627
|
);
|
|
628
|
+
CREATE TABLE IF NOT EXISTS ${this.table("quota_meta")} (
|
|
629
|
+
key TEXT PRIMARY KEY,
|
|
630
|
+
value TEXT
|
|
631
|
+
);
|
|
552
632
|
`);
|
|
633
|
+
const recorded = (await client.query(`SELECT value FROM ${this.table("quota_meta")} WHERE key = 'rules_fingerprint'`)).rows[0]?.value;
|
|
634
|
+
if (recorded === void 0 || this.acceptRulesUpdate) await client.query(`INSERT INTO ${this.table("quota_meta")} (key, value) VALUES ('rules_fingerprint', $1)
|
|
635
|
+
ON CONFLICT (key) DO UPDATE SET value = excluded.value`, [fingerprint]);
|
|
636
|
+
else if (recorded !== fingerprint) throw new ConfigError(`PostgresQuotaLimiter rules mismatch over schema "${this.schema}": the schema records rules fingerprint ${recorded}, this instance computes ${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
|
+
schema: this.schema,
|
|
638
|
+
recorded,
|
|
639
|
+
computed: fingerprint
|
|
640
|
+
} });
|
|
553
641
|
await client.query("COMMIT");
|
|
554
642
|
} catch (thrown) {
|
|
555
643
|
await client.query("ROLLBACK").catch(() => void 0);
|
|
@@ -563,22 +651,60 @@ var PostgresQuotaLimiter = class {
|
|
|
563
651
|
* take the schema-wide quota advisory lock, run `fn`, COMMIT. Every
|
|
564
652
|
* counter mutation goes through here, which is what makes the
|
|
565
653
|
* verdict read and the consume one unit across processes and hosts.
|
|
654
|
+
*
|
|
655
|
+
* The WHOLE path (lazy bootstrap, pool checkout, transaction) races
|
|
656
|
+
* `admissionDeadlineMs` (RV506): `lock_timeout` bounds one stage, and
|
|
657
|
+
* before the deadline a call could spend that bound at checkout and
|
|
658
|
+
* again at the lock without ever being refused. On expiry the caller
|
|
659
|
+
* gets a typed `QuotaDeadlineError`, and a connection the refused
|
|
660
|
+
* call holds is destroyed through `release(err)`, never returned
|
|
661
|
+
* mid-transaction to the pool; destroying it also cancels the
|
|
662
|
+
* abandoned wait server-side. The deadline runs on the REAL clock
|
|
663
|
+
* (an injected test `now` freezes window math, not infrastructure
|
|
664
|
+
* timeouts).
|
|
566
665
|
*/
|
|
567
666
|
async withQuotaLock(fn) {
|
|
568
|
-
|
|
569
|
-
|
|
667
|
+
let client;
|
|
668
|
+
let expired = false;
|
|
669
|
+
let timer;
|
|
670
|
+
const refusal = () => new QuotaDeadlineError(this.admissionDeadlineMs, this.schema, client === void 0 ? "acquire" : "transaction");
|
|
671
|
+
const work = (async () => {
|
|
672
|
+
await this.booted();
|
|
673
|
+
if (expired) throw refusal();
|
|
674
|
+
const checkout = await this.pool.connect();
|
|
675
|
+
if (expired) {
|
|
676
|
+
checkout.release();
|
|
677
|
+
throw refusal();
|
|
678
|
+
}
|
|
679
|
+
client = checkout;
|
|
680
|
+
try {
|
|
681
|
+
await client.query("BEGIN");
|
|
682
|
+
await client.query(`SET LOCAL lock_timeout = '${String(QUOTA_LOCK_TIMEOUT_MS)}ms'`);
|
|
683
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, $2))", [`${this.schema}:rulvar-quota`, LOCK_SEED]);
|
|
684
|
+
const result = await fn(client);
|
|
685
|
+
await client.query("COMMIT");
|
|
686
|
+
return result;
|
|
687
|
+
} catch (thrown) {
|
|
688
|
+
if (!expired) await client.query("ROLLBACK").catch(() => void 0);
|
|
689
|
+
throw thrown;
|
|
690
|
+
} finally {
|
|
691
|
+
if (!expired) client.release();
|
|
692
|
+
client = void 0;
|
|
693
|
+
}
|
|
694
|
+
})();
|
|
695
|
+
const gate = new Promise((_, reject) => {
|
|
696
|
+
timer = setTimeout(() => {
|
|
697
|
+
const reason = refusal();
|
|
698
|
+
expired = true;
|
|
699
|
+
client?.release(reason);
|
|
700
|
+
client = void 0;
|
|
701
|
+
reject(reason);
|
|
702
|
+
}, this.admissionDeadlineMs);
|
|
703
|
+
});
|
|
570
704
|
try {
|
|
571
|
-
await
|
|
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;
|
|
705
|
+
return await Promise.race([work, gate]);
|
|
580
706
|
} finally {
|
|
581
|
-
|
|
707
|
+
clearTimeout(timer);
|
|
582
708
|
}
|
|
583
709
|
}
|
|
584
710
|
reserve(request) {
|
|
@@ -683,4 +809,4 @@ var PostgresQuotaLimiter = class {
|
|
|
683
809
|
}
|
|
684
810
|
};
|
|
685
811
|
//#endregion
|
|
686
|
-
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_LOCK_TIMEOUT_MS };
|
|
812
|
+
export { DEFAULT_LEASE_TTL_MS, DEFAULT_POOL_MAX, PostgresQuotaLimiter, PostgresStore, QUOTA_ADMISSION_DEADLINE_MS, QUOTA_LOCK_TIMEOUT_MS, QuotaDeadlineError, quotaRulesFingerprint };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/store-postgres",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.98.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.98.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.98.0"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|