@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 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
- /** The shared rule set; must be identical across hosts. */
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). 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.
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). 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.
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
- await this.booted();
569
- const client = await this.pool.connect();
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 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;
705
+ return await Promise.race([work, gate]);
580
706
  } finally {
581
- client.release();
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.96.0",
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.96.0"
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.96.0"
33
+ "@rulvar/store-conformance": "1.98.0"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",