Initial Commit
This commit is contained in:
2
packages/api-stack/api/api-2pc/src/limits/index.ts
Normal file
2
packages/api-stack/api/api-2pc/src/limits/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { OutstandingPreparedCap } from "./outstanding-cap.js";
|
||||
export { assertTimeoutBelowCeiling } from "./timeout-ceiling.js";
|
||||
104
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.md
Normal file
104
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# `limits/outstanding-cap`
|
||||
|
||||
## Purpose
|
||||
|
||||
Admission control for in-flight 2PC transactions. The coordinator
|
||||
holds a fixed-size budget of "outstanding prepared transactions"; once
|
||||
the budget is exhausted new `run2pc` calls refuse rather than queue.
|
||||
The default is 256 (design doc §14's open question, pinned by this
|
||||
package's spec) and operators can tune it via host config.
|
||||
|
||||
Refusing rather than queuing is intentional: Postgres holds row-level
|
||||
locks for every prepared transaction, so a backed-up coordinator
|
||||
would otherwise convert a transient overload into a global lock
|
||||
storm. Failing fast lets callers route to a degraded path
|
||||
(degraded-write, retry queue, etc.).
|
||||
|
||||
## Features
|
||||
|
||||
- `OutstandingPreparedCap` — synchronous counter with `admit()`,
|
||||
`release()`, `snapshot()`, `capacity()`.
|
||||
- Constructor validates that the cap is a positive integer (rejects
|
||||
zero, negatives, fractions, `NaN`).
|
||||
- `release()` is idempotent below zero — a buggy double-release in
|
||||
caller code cannot silently expand the real cap.
|
||||
- Synchronous-only — relies on the event-loop atomicity guarantee
|
||||
between `admit` / `release` pairs in JavaScript runtimes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { OutstandingPreparedCap } from "@suaveplan/api-2pc/limits";
|
||||
|
||||
const cap = new OutstandingPreparedCap(256);
|
||||
|
||||
cap.admit();
|
||||
try {
|
||||
// run the 2PC transaction
|
||||
} finally {
|
||||
cap.release();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wrap admission inside the coordinator's public entry to centralise the
|
||||
release in one `finally`. The snapshot fuels the OTel gauge:
|
||||
|
||||
```ts
|
||||
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
|
||||
import { OutstandingPreparedCap } from "@suaveplan/api-2pc/limits";
|
||||
|
||||
class MiniCoordinator {
|
||||
private readonly cap = new OutstandingPreparedCap(256);
|
||||
|
||||
snapshot() {
|
||||
return this.cap.snapshot();
|
||||
}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
this.cap.admit();
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TwoPcError &&
|
||||
err.twoPcCode === TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED
|
||||
) {
|
||||
// route to degraded path
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.cap.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class OutstandingPreparedCap`
|
||||
- `constructor(cap: number)` — throws
|
||||
`TWOPC_OUTSTANDING_LIMIT_EXCEEDED` on invalid `cap`.
|
||||
- `admit(): void` — throws
|
||||
`TWOPC_OUTSTANDING_LIMIT_EXCEEDED` when at capacity.
|
||||
- `release(): void` — idempotent below zero.
|
||||
- `snapshot(): number` — current outstanding count.
|
||||
- `capacity(): number` — configured cap.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Construction-time validation re-uses the same error code as the
|
||||
runtime cap-exceeded path. This is deliberate: operators tuning the
|
||||
cap via host config want one error string to alert on, regardless of
|
||||
whether the misconfiguration was a fractional value at startup or a
|
||||
real overload at steady state. The context object on the thrown
|
||||
`TwoPcError` distinguishes the two cases (`cap` only vs.
|
||||
`cap` + `outstanding`).
|
||||
|
||||
The class is intentionally tiny — no async, no internal queue, no
|
||||
external dependencies. Adding a queue here would re-introduce the
|
||||
lock-storm risk the cap exists to prevent.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { OutstandingPreparedCap } from "./outstanding-cap.js";
|
||||
|
||||
describe("OutstandingPreparedCap", () => {
|
||||
it("admits up to the configured cap", { timeout: 5000 }, () => {
|
||||
const cap = new OutstandingPreparedCap(3);
|
||||
cap.admit();
|
||||
cap.admit();
|
||||
cap.admit();
|
||||
expect(cap.snapshot()).toBe(3);
|
||||
expect(cap.capacity()).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects the (cap+1)th admission with TWOPC.OUTSTANDING_LIMIT_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cap = new OutstandingPreparedCap(2);
|
||||
cap.admit();
|
||||
cap.admit();
|
||||
try {
|
||||
cap.admit();
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers as transactions release slots", { timeout: 5000 }, () => {
|
||||
const cap = new OutstandingPreparedCap(1);
|
||||
cap.admit();
|
||||
expect(() => cap.admit()).toThrow();
|
||||
cap.release();
|
||||
cap.admit();
|
||||
expect(cap.snapshot()).toBe(1);
|
||||
});
|
||||
|
||||
it("release below zero clamps at zero (defends against double-release)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cap = new OutstandingPreparedCap(2);
|
||||
cap.release();
|
||||
cap.release();
|
||||
expect(cap.snapshot()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
])("constructor rejects non-positive-integer cap %p", (bad) => {
|
||||
expect(() => new OutstandingPreparedCap(bad as number)).toThrow();
|
||||
});
|
||||
});
|
||||
64
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.ts
Normal file
64
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Admission counter for outstanding prepared transactions. Tracks how
|
||||
* many `run2pc` calls are between `admit()` and `release()`; new calls
|
||||
* past the cap raise `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` until an
|
||||
* in-flight transaction releases a slot.
|
||||
*
|
||||
* The implementation is single-threaded — `admit` and `release` are
|
||||
* synchronous balanced calls. Bun's event loop guarantees the call
|
||||
* pair is atomic relative to other tasks, so no mutex is required.
|
||||
*/
|
||||
export class OutstandingPreparedCap {
|
||||
private outstanding = 0;
|
||||
|
||||
public constructor(private readonly cap: number) {
|
||||
if (!Number.isInteger(cap) || cap <= 0) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED,
|
||||
`outstandingPreparedCap must be a positive integer; got ${cap}`,
|
||||
{ context: { cap } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a slot. Throws
|
||||
* `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` when the cap is already at
|
||||
* capacity. On success the caller MUST eventually invoke
|
||||
* `release()` in a `finally` block.
|
||||
*/
|
||||
public admit(): void {
|
||||
if (this.outstanding >= this.cap) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED,
|
||||
`Outstanding prepared transactions at cap ${this.cap}`,
|
||||
{ context: { cap: this.cap, outstanding: this.outstanding } }
|
||||
);
|
||||
}
|
||||
this.outstanding += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the slot reserved by a matching {@link admit}. Idempotent
|
||||
* against arithmetic underflow — repeated calls clamp at zero so
|
||||
* a buggy double-release in caller code does not allow the next
|
||||
* `admit` to silently exceed the real cap.
|
||||
*/
|
||||
public release(): void {
|
||||
if (this.outstanding > 0) {
|
||||
this.outstanding -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current outstanding count. Snapshot read for the OTel gauge. */
|
||||
public snapshot(): number {
|
||||
return this.outstanding;
|
||||
}
|
||||
|
||||
/** Configured cap. Diagnostics only. */
|
||||
public capacity(): number {
|
||||
return this.cap;
|
||||
}
|
||||
}
|
||||
77
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.md
Normal file
77
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# `limits/timeout-ceiling`
|
||||
|
||||
## Purpose
|
||||
|
||||
Enforces the hard 30 000 ms ceiling on caller-supplied `timeoutMs`
|
||||
values. The check fires at the public entry point of `run2pc` so
|
||||
above-ceiling calls never contact a participant; the operational
|
||||
warning in design doc §9.2 is that long prepares hold row-level locks,
|
||||
so surfacing the misuse loudly is more useful than silently clamping.
|
||||
|
||||
This module is intentionally tiny — one function — because the
|
||||
behaviour is small but the gate is one of the most-asserted contracts
|
||||
in the spec.
|
||||
|
||||
## Features
|
||||
|
||||
- `assertTimeoutBelowCeiling(timeoutMs)` — throws
|
||||
`TWOPC_TIMEOUT_CEILING_EXCEEDED` for above-ceiling, non-positive,
|
||||
non-finite, or `NaN` inputs.
|
||||
- Re-uses the `TIMEOUT_CEILING_MS` constant from `options/` so the
|
||||
ceiling stays a single source of truth.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { assertTimeoutBelowCeiling } from "@suaveplan/api-2pc/limits";
|
||||
|
||||
assertTimeoutBelowCeiling(5000); // ok
|
||||
assertTimeoutBelowCeiling(30001); // throws TWOPC_TIMEOUT_CEILING_EXCEEDED
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The function is intentionally side-effect-free so callers can wrap it
|
||||
in a per-tenant override path:
|
||||
|
||||
```ts
|
||||
import { TIMEOUT_CEILING_MS } from "@suaveplan/api-2pc/options";
|
||||
import { assertTimeoutBelowCeiling } from "@suaveplan/api-2pc/limits";
|
||||
|
||||
function resolveTimeout(
|
||||
requested: number | undefined,
|
||||
tenantMax: number
|
||||
): number {
|
||||
const effective = requested ?? DEFAULT_TIMEOUT_MS;
|
||||
assertTimeoutBelowCeiling(effective);
|
||||
if (effective > tenantMax) {
|
||||
return tenantMax;
|
||||
}
|
||||
if (effective > TIMEOUT_CEILING_MS) {
|
||||
// unreachable given the assert above — included only to
|
||||
// illustrate that the ceiling acts as the strict upper bound.
|
||||
return TIMEOUT_CEILING_MS;
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function assertTimeoutBelowCeiling(timeoutMs: number): void`
|
||||
- Throws `TwoPcError(TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED)` for any
|
||||
of: `NaN`, `Infinity`, `-Infinity`, values `<= 0`, values
|
||||
`> TIMEOUT_CEILING_MS`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The check rejects non-positive values for the same reason it rejects
|
||||
above-ceiling ones: an immediate or backwards-in-time deadline would
|
||||
collapse phase 1 to a single tick and the rollback path would fire
|
||||
before any participant could finish a real prepare. Treating that as
|
||||
the same misuse class as "30 minutes" is a deliberate ergonomic
|
||||
choice — operators get one error code to grep for.
|
||||
|
||||
`Number.isFinite` is used in preference to `Number.isNaN` because the
|
||||
former covers `Infinity` and `-Infinity` in addition to `NaN`. The
|
||||
explicit `<= 0` check then excludes zero and negative-finite values.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_TIMEOUT_MS, TIMEOUT_CEILING_MS } from "../options/options.js";
|
||||
import { assertTimeoutBelowCeiling } from "./timeout-ceiling.js";
|
||||
|
||||
function expectCeilingError(fn: () => void): TwoPcError {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
const e = err as TwoPcError;
|
||||
expect(e.twoPcCode).toBe(TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED);
|
||||
return e;
|
||||
}
|
||||
throw new Error("expected TWOPC_TIMEOUT_CEILING_EXCEEDED");
|
||||
}
|
||||
|
||||
describe("assertTimeoutBelowCeiling", () => {
|
||||
it("accepts the default timeoutMs (5000)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
assertTimeoutBelowCeiling(DEFAULT_TIMEOUT_MS)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a value equal to the ceiling", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
assertTimeoutBelowCeiling(TIMEOUT_CEILING_MS)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects timeoutMs above the ceiling", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() =>
|
||||
assertTimeoutBelowCeiling(TIMEOUT_CEILING_MS + 1)
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects zero", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() => assertTimeoutBelowCeiling(0));
|
||||
});
|
||||
|
||||
it("rejects negative values", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() => assertTimeoutBelowCeiling(-1));
|
||||
});
|
||||
|
||||
it("rejects NaN", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() => assertTimeoutBelowCeiling(Number.NaN));
|
||||
});
|
||||
|
||||
it("rejects Infinity", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() =>
|
||||
assertTimeoutBelowCeiling(Number.POSITIVE_INFINITY)
|
||||
);
|
||||
});
|
||||
});
|
||||
29
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.ts
Normal file
29
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { TIMEOUT_CEILING_MS } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Validate a caller-supplied `timeoutMs`. Per spec the coordinator
|
||||
* MUST refuse values above the hard ceiling (default 30 000 ms) at
|
||||
* call time — silently clamping would hide the misuse and let
|
||||
* long-held prepares regress production throughput.
|
||||
*
|
||||
* Negative or non-finite values are also refused — they would
|
||||
* collapse the deadline to "immediate" which is operationally
|
||||
* indistinguishable from a flaky participant.
|
||||
*/
|
||||
export function assertTimeoutBelowCeiling(timeoutMs: number): void {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED,
|
||||
`timeoutMs must be a positive finite number; got ${timeoutMs}`,
|
||||
{ context: { timeoutMs } }
|
||||
);
|
||||
}
|
||||
if (timeoutMs > TIMEOUT_CEILING_MS) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED,
|
||||
`timeoutMs ${timeoutMs} exceeds hard ceiling ${TIMEOUT_CEILING_MS}`,
|
||||
{ context: { timeoutMs, ceiling: TIMEOUT_CEILING_MS } }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user