first commit
This commit is contained in:
109
docs/api-stack/api/api-2pc/README.md
Normal file
109
docs/api-stack/api/api-2pc/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# @suaveplan/api-2pc - Two-phase commit coordinator for the Suaveplan API plugin system.
|
||||
|
||||
Two-phase commit coordinator for the Suaveplan API plugin system. Drives Postgres PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED across registered participants with a host-owned ledger in suaveplan_2pc_transactions. Refuses non-preparable engines as writers (SQLite, SurrealDB) but accepts them as read-only voters. Default timeoutMs 5000, hard ceiling 30000, default outstanding-prepared cap 256, all operator-tunable. Ships boot-time orphan resolution via pg_prepared_xacts, the full TWOPC_* error namespace, OTel transaction/prepare/commit/rollback spans plus prepare_duration_ms / commit_duration_ms histograms / transactions_total counter / outstanding_prepared gauge, and run2pcParticipantConformance on the ./testing subpath. Server-tier — Bun + Node.
|
||||
|
||||
> **Postgres-only writer surface.** SQLite and SurrealDB cannot honour
|
||||
> `PREPARE TRANSACTION` and are refused as writers at install time.
|
||||
> They MAY participate as read-only voters that contribute a
|
||||
> `commit`/`abort` vote based on their own consistency check, but the
|
||||
> coordinator never sends them prepare / commit / rollback SQL.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [coordinator](./src/coordinator/coordinator.md) — Top-level orchestration for the 2PC protocol. `createTwoPcCoordinator` builds a fully-wired coordinator that holds a pa…
|
||||
- [coordinator](./src/coordinator/coordinator.md) — Top-level orchestration for the 2PC protocol. `createTwoPcCoordinator` builds a fully-wired coordinator that holds a pa…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `TWOPC_*` error-code namespace and the `TwoPcError` concrete class for the `@suaveplan/api-2pc` pa…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `TWOPC_*` error-code namespace and the `TwoPcError` concrete class for the `@suaveplan/api-2pc` pa…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument registry the 2PC coordinator emits against on every transaction. Each instrument's name foll…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument registry the 2PC coordinator emits against on every transaction. Each instrument's name foll…
|
||||
- [outstanding-cap](./src/limits/outstanding-cap.md) — Admission control for in-flight 2PC transactions. The coordinator holds a fixed-size budget of "outstanding prepared tr…
|
||||
- [timeout-ceiling](./src/limits/timeout-ceiling.md) — Enforces the hard 30 000 ms ceiling on caller-supplied `timeoutMs` values. The check fires at the public entry point of…
|
||||
- [options](./src/options/options.md) — Defines the boundary-validated option shape consumed by `createTwoPcCoordinator`. Every tunable that affects coordinato…
|
||||
- [options](./src/options/options.md) — Defines the boundary-validated option shape consumed by `createTwoPcCoordinator`. Every tunable that affects coordinato…
|
||||
- [persistence](./src/persistence/persistence.md) — Persists every coordinator state transition into the `suaveplan_2pc_transactions` ledger table. The table is owned by `…
|
||||
- [persistence](./src/persistence/persistence.md) — Persists every coordinator state transition into the `suaveplan_2pc_transactions` ledger table. The table is owned by `…
|
||||
- [commit](./src/phases/commit.md) — Phase-2 commit for a single participant. Once every participant has returned `commit` from phase 1 and the coordinator…
|
||||
- [gid](./src/phases/gid.md) — Builds and parses the global transaction id (gid) string passed to Postgres' `PREPARE TRANSACTION '<gid>'`. The format…
|
||||
- [prepare](./src/phases/prepare.md) — Implements phase 1 of the 2PC protocol for a single participant. The coordinator iterates participants and calls this m…
|
||||
- [rollback](./src/phases/rollback.md) — Phase-2 rollback for a single participant. Used both by the coordinator's abort path (vote_abort or prepare failure) an…
|
||||
- [recovery](./src/recovery/recovery.md) — Boot-time orphan resolution for the 2PC coordinator. When a host crashes between phase 1 and phase 2, prepared transact…
|
||||
- [recovery](./src/recovery/recovery.md) — Boot-time orphan resolution for the 2PC coordinator. When a host crashes between phase 1 and phase 2, prepared transact…
|
||||
- [registry](./src/registry/participant-registry.md) — The participant registry is the install-time admission control between plugins and the 2PC coordinator. Plugins declare…
|
||||
- [participant-registry](./src/registry/participant-registry.md) — The participant registry is the install-time admission control between plugins and the 2PC coordinator. Plugins declare…
|
||||
- [testing](./src/testing/conformance.md) — Provides `run2pcParticipantConformance(factory)` — a runnable test suite that pins the contract every 2PC writer partic…
|
||||
- [conformance](./src/testing/conformance.md) — Provides `run2pcParticipantConformance(factory)` — a runnable test suite that pins the contract every 2PC writer partic…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why
|
||||
|
||||
Some cross-database workflows demand atomic visibility — no partial
|
||||
state, no eventual convergence. Per the API Plugin Foundation design
|
||||
doc §9.2 the coordinator runs phase-1 prepare across every registered
|
||||
participant, persists the vote tally to `suaveplan_2pc_transactions`
|
||||
on the host's metadata DB, then issues `COMMIT PREPARED` or
|
||||
`ROLLBACK PREPARED` against every writer participant. Recovery on the
|
||||
next boot resolves any prepared transactions left over from a host
|
||||
crash.
|
||||
|
||||
## Modules
|
||||
|
||||
- `coordinator/` — `createTwoPcCoordinator` + `run2pc` factory and
|
||||
state machine.
|
||||
- `registry/` — `TwoPcParticipantRegistry` (admission control).
|
||||
- `phases/` — `preparePhaseOne`, `commitPhaseTwo`, `rollbackPhaseTwo`,
|
||||
`gid` helpers.
|
||||
- `persistence/` — `TwoPcLedgerStore` over the host-owned
|
||||
`suaveplan_2pc_transactions` table.
|
||||
- `recovery/` — boot-time orphan resolution via `pg_prepared_xacts`.
|
||||
- `limits/` — `OutstandingPreparedCap` (admission), `assertTimeoutBelowCeiling`
|
||||
(per-call ceiling).
|
||||
- `instruments/` — OTel meter/tracer bundle.
|
||||
- `options/` — Zod-validated coordinator options + defaults.
|
||||
- `error-codes/` — 10 `TWOPC_*` codes + `TwoPcError`.
|
||||
- `types/` — `Run2PcInput`, `Run2PcOutcome`, participant + state types.
|
||||
- `testing/` — `run2pcParticipantConformance(factory)` suite.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createTwoPcCoordinator } from "@suaveplan/api-2pc";
|
||||
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: { hostInstanceId: "host-01" },
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const outcome = await coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "billing", input: { invoiceId: "inv-1" } },
|
||||
{ id: "inventory", input: { sku: "abc-9" } },
|
||||
],
|
||||
});
|
||||
// outcome.state is "committed" | "aborted" | "in_doubt"
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
- `timeoutMs`: **5000** (operator-tunable per-call up to the
|
||||
30000 ms ceiling).
|
||||
- `outstandingPreparedCap`: **256** (operator-tunable via host
|
||||
config).
|
||||
|
||||
## Error namespace
|
||||
|
||||
Every production code path throws `TwoPcError` with one of the ten
|
||||
`TWOPC_*` codes. See `src/error-codes/error-codes.md` for the full
|
||||
catalogue.
|
||||
|
||||
## Conformance
|
||||
|
||||
Every adapter implementing a writer participant MUST run
|
||||
`run2pcParticipantConformance({ create: ... })` in a
|
||||
`tests/conformance/run-2pc-participant.test.ts` file. The harness
|
||||
pins prepare / commit / rollback semantics and the idempotency
|
||||
contract.
|
||||
|
||||
150
docs/api-stack/api/api-2pc/src/coordinator/coordinator.md
Normal file
150
docs/api-stack/api/api-2pc/src/coordinator/coordinator.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# `coordinator/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level orchestration for the 2PC protocol. `createTwoPcCoordinator`
|
||||
builds a fully-wired coordinator that holds a participant registry, a
|
||||
ledger store, an admission counter, and an OTel instrument bundle.
|
||||
The `run2pc({ participants, timeoutMs? })` entry point owns the full
|
||||
state machine described in design doc §9.2:
|
||||
|
||||
1. Admission via the outstanding-prepared cap.
|
||||
2. Ledger insert at `voting`.
|
||||
3. Phase-1 prepare per participant, with the
|
||||
`2pc.prepare_duration_ms` histogram and a `2pc.prepare` span per
|
||||
participant.
|
||||
4. Either unanimous commit → `markAllVotedCommit` → phase-2
|
||||
`COMMIT PREPARED` per writer; OR any abort/error →
|
||||
`markAborting` → phase-2 `ROLLBACK PREPARED` per prepared writer.
|
||||
5. Terminal ledger stamp (`committed` / `aborted` / `in_doubt`).
|
||||
|
||||
The coordinator is the only path application code ever touches.
|
||||
Every smaller module (phases, persistence, recovery, limits) is
|
||||
internal plumbing this factory composes.
|
||||
|
||||
## Features
|
||||
|
||||
- Single public surface: `createTwoPcCoordinator(deps)` returns
|
||||
`{ run2pc, outstandingCount, options }`.
|
||||
- Default `timeoutMs` from options (5000), per-call override accepted
|
||||
up to the 30000 ceiling (enforced via
|
||||
`limits/timeout-ceiling`).
|
||||
- Admission cap (default 256) enforced via
|
||||
`limits/outstanding-cap`; over-cap calls fail with
|
||||
`TWOPC_OUTSTANDING_LIMIT_EXCEEDED` before any participant is
|
||||
contacted.
|
||||
- Hooks every documented OTel instrument (`2pc.transaction` span,
|
||||
`2pc.prepare` / `2pc.commit` / `2pc.rollback` child spans,
|
||||
`2pc.transactions_total` counter, `prepare`/`commit_duration_ms`
|
||||
histograms, `outstanding_prepared` gauge).
|
||||
- Ledger persistence is bidirectional — every state transition writes
|
||||
through `TwoPcLedgerStore`, so the recovery runner has a full audit
|
||||
trail on restart.
|
||||
- Abort handling: vote_abort, prepare_failed, and timeout all funnel
|
||||
to the rollback branch; rollback errors escalate to `in_doubt`
|
||||
rather than failing the rollback path itself.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTwoPcCoordinator } from "@suaveplan/api-2pc/coordinator";
|
||||
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: { hostInstanceId: "host-01" },
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const outcome = await coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "billing", input: { invoiceId: "inv-1" } },
|
||||
{ id: "inventory", input: { sku: "abc-9" } },
|
||||
],
|
||||
});
|
||||
console.log(outcome.state); // "committed" | "aborted" | "in_doubt"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a custom instruments bundle to feed a hand-rolled
|
||||
MeterProvider, override the default timeout per call, and consume the
|
||||
per-participant vote summary on the outcome:
|
||||
|
||||
```ts
|
||||
import { createTwoPcCoordinator } from "@suaveplan/api-2pc/coordinator";
|
||||
import { createTwoPcInstruments } from "@suaveplan/api-2pc/instruments";
|
||||
|
||||
const instruments = createTwoPcInstruments({
|
||||
meterProvider,
|
||||
outstandingPreparedSnapshot: () => coordinator.outstandingCount(),
|
||||
});
|
||||
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: {
|
||||
hostInstanceId: "host-01",
|
||||
defaultTimeoutMs: 10_000,
|
||||
outstandingPreparedCap: 64,
|
||||
},
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
instruments,
|
||||
});
|
||||
|
||||
const outcome = await coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "billing", input: { invoiceId } },
|
||||
{ id: "audit", input: { event: "invoice.created" } },
|
||||
],
|
||||
timeoutMs: 2_500,
|
||||
});
|
||||
for (const v of outcome.votes) {
|
||||
auditLog.append({
|
||||
txId: outcome.txId,
|
||||
participant: v.participantId,
|
||||
vote: v.vote,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TwoPcCoordinatorDeps`
|
||||
- `options: TwoPcCoordinatorOptions`
|
||||
- `registry: TwoPcParticipantRegistry`
|
||||
- `ledgerDb: LedgerDb`
|
||||
- `clock: Clock`
|
||||
- `idGenerator: IdGenerator`
|
||||
- `instruments?: TwoPcInstruments`
|
||||
- `interface TwoPcCoordinator`
|
||||
- `run2pc(input: Run2PcInput): Promise<Run2PcOutcome>`
|
||||
- `outstandingCount(): number`
|
||||
- `options: ResolvedTwoPcCoordinatorOptions`
|
||||
- `createTwoPcCoordinator(deps): TwoPcCoordinator` — factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is structured as one large `try` / `finally` so cleanup
|
||||
(span end, deadline timer clear, admission release) runs regardless
|
||||
of which branch fires. The pattern keeps the happy path linear and
|
||||
the failure paths exhaustive without nested try blocks.
|
||||
|
||||
The "any commit failure → in_doubt" escalation is deliberate. Once
|
||||
the coordinator has issued `COMMIT PREPARED` against at least one
|
||||
participant, the only correct follow-up is to keep trying — but the
|
||||
hot path cannot block on that. Instead, the row is marked `in_doubt`,
|
||||
the OTel counter ticks the `in_doubt` outcome, and the recovery
|
||||
runner picks it up on the next boot.
|
||||
|
||||
Vote-abort and prepare-failed paths both call `ac.abort()` BEFORE
|
||||
running phase 2, so any prepared participants whose subsequent SQL
|
||||
hits the signal map to `TWOPC_TIMEOUT` rather than getting stuck. The
|
||||
deadline timer is cleared in the `finally` so a successful commit
|
||||
does not leave a dangling abort handle on the AbortController.
|
||||
97
docs/api-stack/api/api-2pc/src/error-codes/error-codes.md
Normal file
97
docs/api-stack/api/api-2pc/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# `error-codes/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the `TWOPC_*` error-code namespace and the `TwoPcError`
|
||||
concrete class for the `@suaveplan/api-2pc` package. The registry is the
|
||||
canonical contract every production code path in the coordinator throws
|
||||
against — observability dashboards, support runbooks, and integrating
|
||||
plugins all join on these stable string codes.
|
||||
|
||||
The codes are intentionally enumerated up-front rather than grown
|
||||
ad-hoc: a missing code is a spec deviation that a CI gate can detect,
|
||||
and the closed set lets operators write alerting rules without
|
||||
constantly chasing schema drift.
|
||||
|
||||
## Features
|
||||
|
||||
- Ten stable codes (`TIMEOUT`, `VOTE_ABORT`, `PREPARE_FAILED`,
|
||||
`COMMIT_FAILED`, `ROLLBACK_FAILED`, `IN_DOUBT`,
|
||||
`PARTICIPANT_NOT_PREPARABLE`, `READ_ONLY_PARTICIPANT_CANNOT_WRITE`,
|
||||
`OUTSTANDING_LIMIT_EXCEEDED`, `TIMEOUT_CEILING_EXCEEDED`).
|
||||
- Each code is exposed via `TWOPC_CODES.<NAME>` returning the prefixed
|
||||
string `"TWOPC.<NAME>"` produced by `defineCodes`.
|
||||
- `TwoPcError` extends `SuaveplanError` so cause-chain traversal,
|
||||
Zod-validated wire format, and the OTel span-event adapter from
|
||||
`@suaveplan/error` all apply unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
|
||||
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT,
|
||||
"Phase-1 deadline elapsed",
|
||||
{ context: { txId, timeoutMs: 5000 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Branching on error code at the boundary between coordinator and caller
|
||||
is the recommended consumer pattern. The narrowed `twoPcCode` field
|
||||
gives an exhaustive switch the compiler can check:
|
||||
|
||||
```ts
|
||||
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
|
||||
|
||||
try {
|
||||
await run2pc({ participants, timeoutMs: 5000 }, ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof TwoPcError) {
|
||||
switch (err.twoPcCode) {
|
||||
case TWOPC_CODES.TIMEOUT:
|
||||
metrics.incr("billing.2pc.timeout");
|
||||
return retryWithBackoff();
|
||||
case TWOPC_CODES.VOTE_ABORT:
|
||||
return surfaceUserError();
|
||||
case TWOPC_CODES.IN_DOUBT:
|
||||
pageOncall(err);
|
||||
return;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TWOPC_CODES` — frozen object `{ TIMEOUT: "TWOPC.TIMEOUT", ... }`.
|
||||
- `type TwoPcErrorCode` — string-literal union of every value in
|
||||
`TWOPC_CODES`.
|
||||
- `class TwoPcError extends SuaveplanError<TwoPcErrorCode>` — exposes
|
||||
`twoPcCode` as a same-typed accessor for ergonomic narrowing without
|
||||
re-asserting at every catch site.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` from `@suaveplan/error` produces the prefixed values at
|
||||
runtime, so every code string starts with `"TWOPC."`. This matches the
|
||||
prefix convention used by sibling api-stack packages (`HOST.*`,
|
||||
`PLUGIN.*`, `RELOAD.*`, `LEDGER.*`). The `TwoPcError` class carries a
|
||||
redundant `twoPcCode` field — duplicating `code` — purely to give
|
||||
callers a name they don't have to import the generic
|
||||
`SuaveplanError<C>` type to narrow against. The duplication is
|
||||
deliberate and harmless because the base class makes `code` readonly.
|
||||
|
||||
The registry is closed; adding a code requires an OpenSpec change so
|
||||
every consumer dashboard and runbook stays in sync. Coverage assertions
|
||||
ensure every code is actually thrown by a production path before this
|
||||
package can graduate to `1.0.0`.
|
||||
105
docs/api-stack/api/api-2pc/src/instruments/instruments.md
Normal file
105
docs/api-stack/api/api-2pc/src/instruments/instruments.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `instruments/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the OTel instrument registry the 2PC coordinator emits against
|
||||
on every transaction. Each instrument's name follows design doc §9.2
|
||||
exactly so a missing entry is a spec deviation a CI gate could detect.
|
||||
Centralising creation keeps the coordinator code free of meter / tracer
|
||||
plumbing — every phase module simply records against the frozen
|
||||
bundle returned here.
|
||||
|
||||
The observable gauge wires into the coordinator's outstanding-prepared
|
||||
counter via a snapshot callback, so dashboards see the live admission
|
||||
state without the coordinator pushing on every change.
|
||||
|
||||
## Features
|
||||
|
||||
- One span: `2pc.transaction` per transaction lifetime; per-phase
|
||||
child spans `2pc.prepare`, `2pc.commit`, `2pc.rollback` (started by
|
||||
the phase modules themselves, using the tracer this bundle exposes).
|
||||
- One counter: `2pc.transactions_total` labelled `{ outcome }`.
|
||||
- Two histograms: `2pc.prepare_duration_ms`, `2pc.commit_duration_ms`
|
||||
labelled `{ outcome }`.
|
||||
- One observable gauge: `2pc.outstanding_prepared` wired to a
|
||||
snapshot callback supplied at construction.
|
||||
- Provider defaults to the OTel globals (no-op when no SDK is
|
||||
installed) so unit tests do not have to wire a provider.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTwoPcInstruments } from "@suaveplan/api-2pc/instruments";
|
||||
|
||||
const instruments = createTwoPcInstruments({
|
||||
outstandingPreparedSnapshot: () => coordinator.outstandingCount(),
|
||||
});
|
||||
|
||||
instruments.transactionsTotal.add(1, { outcome: "committed" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a real OTel SDK and a custom snapshot to feed a live dashboard:
|
||||
|
||||
```ts
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { createTwoPcInstruments } from "@suaveplan/api-2pc/instruments";
|
||||
|
||||
const meterProvider = new MeterProvider({ /* readers/exporters */ });
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
|
||||
const instruments = createTwoPcInstruments({
|
||||
meterProvider,
|
||||
tracerProvider: trace.getTracerProvider(),
|
||||
outstandingPreparedSnapshot: () => coordinator.outstandingCount(),
|
||||
});
|
||||
|
||||
const span = instruments.tracer.startSpan("2pc.transaction", {
|
||||
attributes: { "tx.id": txId, participant_count: 2 },
|
||||
});
|
||||
try {
|
||||
// ...
|
||||
instruments.prepareDurationMs.record(prepareElapsedMs, {
|
||||
outcome: "committed",
|
||||
});
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type TwoPcOutcome = "committed" | "aborted" | "in_doubt"` — values
|
||||
attached to the `outcome` attribute on every counter / histogram
|
||||
observation.
|
||||
- `interface TwoPcInstrumentsConfig`
|
||||
- `meterProvider?: MeterProvider`
|
||||
- `tracerProvider?: TracerProvider`
|
||||
- `outstandingPreparedSnapshot: () => number`
|
||||
- `interface TwoPcInstruments` — frozen bundle exposing `meter`,
|
||||
`tracer`, `transactionsTotal`, `prepareDurationMs`,
|
||||
`commitDurationMs`, `outstandingPreparedGauge`.
|
||||
- `createTwoPcInstruments(config): TwoPcInstruments` — factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The observable gauge's callback fires whenever the SDK collects metrics
|
||||
(typically on a fixed interval driven by the exporter). The snapshot
|
||||
returned by `outstandingPreparedSnapshot` is therefore an
|
||||
eventually-consistent value — operators must not treat it as a strict
|
||||
upper bound for admission decisions. The strict upper bound is enforced
|
||||
inside `limits/outstanding-cap.ts`; the gauge is purely a dashboard
|
||||
signal.
|
||||
|
||||
Histogram unit is set explicitly to `"ms"` per OTel naming convention.
|
||||
The two histograms are named after the phases they measure so a single
|
||||
dashboard panel can render side-by-side latency for prepare and commit
|
||||
without re-labelling — operators flagging long prepares typically
|
||||
care more about phase-1 latency because that is when row-level locks
|
||||
are held on every participant.
|
||||
104
docs/api-stack/api/api-2pc/src/limits/outstanding-cap.md
Normal file
104
docs/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.
|
||||
77
docs/api-stack/api/api-2pc/src/limits/timeout-ceiling.md
Normal file
77
docs/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.
|
||||
96
docs/api-stack/api/api-2pc/src/options/options.md
Normal file
96
docs/api-stack/api/api-2pc/src/options/options.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `options/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the boundary-validated option shape consumed by
|
||||
`createTwoPcCoordinator`. Every tunable that affects coordinator
|
||||
behaviour (host instance id, outstanding-prepared cap, default phase-1
|
||||
timeout) lives here, with Zod validation at the seam and explicit
|
||||
defaults pinned to the design doc §9.2 numbers.
|
||||
|
||||
Centralising option resolution in one module keeps the coordinator
|
||||
free of conditional fallback logic — by the time it sees the resolved
|
||||
shape every field is concrete, so subsequent code never has to
|
||||
re-check `undefined`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DEFAULT_TIMEOUT_MS = 5000` — phase-1 deadline default per design
|
||||
doc §9.2.
|
||||
- `TIMEOUT_CEILING_MS = 30000` — hard ceiling enforced at call time
|
||||
(the coordinator's `limits/timeout-ceiling` module imports this).
|
||||
- `DEFAULT_OUTSTANDING_PREPARED_CAP = 256` — admission-control default
|
||||
per design doc §14.
|
||||
- `twoPcCoordinatorOptionsSchema` — Zod schema, `strict()` so typos
|
||||
surface as Zod errors instead of being silently ignored.
|
||||
- `resolveTwoPcCoordinatorOptions(raw)` — validates and fills in
|
||||
defaults; returns the fully-populated `ResolvedTwoPcCoordinatorOptions`
|
||||
shape the coordinator consumes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
resolveTwoPcCoordinatorOptions,
|
||||
} from "@suaveplan/api-2pc/options";
|
||||
|
||||
const resolved = resolveTwoPcCoordinatorOptions({
|
||||
hostInstanceId: "host-01",
|
||||
});
|
||||
|
||||
console.log(resolved.defaultTimeoutMs === DEFAULT_TIMEOUT_MS); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Override every default — useful for canary hosts where operators want
|
||||
a tighter admission cap and a longer prepare window:
|
||||
|
||||
```ts
|
||||
import { resolveTwoPcCoordinatorOptions } from "@suaveplan/api-2pc/options";
|
||||
|
||||
const resolved = resolveTwoPcCoordinatorOptions({
|
||||
hostInstanceId: "canary-eu-west-1",
|
||||
outstandingPreparedCap: 64,
|
||||
defaultTimeoutMs: 15_000,
|
||||
});
|
||||
```
|
||||
|
||||
Calls supplying `defaultTimeoutMs` above `TIMEOUT_CEILING_MS` raise a
|
||||
Zod error at this boundary — the coordinator's runtime ceiling check
|
||||
in `limits/timeout-ceiling.ts` then surfaces the misuse on per-call
|
||||
overrides as well.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const DEFAULT_TIMEOUT_MS: number` — 5000.
|
||||
- `const TIMEOUT_CEILING_MS: number` — 30000.
|
||||
- `const DEFAULT_OUTSTANDING_PREPARED_CAP: number` — 256.
|
||||
- `interface TwoPcCoordinatorOptions` — caller-supplied input shape.
|
||||
- `interface ResolvedTwoPcCoordinatorOptions` — internal shape after
|
||||
defaults.
|
||||
- `const twoPcCoordinatorOptionsSchema: z.ZodType` — strict-shape
|
||||
Zod schema.
|
||||
- `function resolveTwoPcCoordinatorOptions(raw): ResolvedTwoPcCoordinatorOptions`
|
||||
— validates and resolves defaults.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema uses `.strict()` so unknown keys fail validation rather than
|
||||
silently being dropped. The default for `defaultTimeoutMs` is applied
|
||||
post-parse with `?? DEFAULT_TIMEOUT_MS` rather than via
|
||||
`z.default(...)` so the schema continues to reject above-ceiling
|
||||
values regardless of whether the caller supplied the field — Zod's
|
||||
`default` would short-circuit `.max()` checks when the field is
|
||||
omitted, which is the wrong shape for a ceiling-enforced option.
|
||||
|
||||
The module deliberately does NOT re-throw Zod failures as
|
||||
`TwoPcError` — that's the responsibility of the coordinator's
|
||||
public entry points. Keeping this module pure of cross-package error
|
||||
mapping makes it reusable from tests that want a raw Zod surface for
|
||||
property-based shape testing.
|
||||
114
docs/api-stack/api/api-2pc/src/persistence/persistence.md
Normal file
114
docs/api-stack/api/api-2pc/src/persistence/persistence.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# `persistence/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Persists every coordinator state transition into the
|
||||
`suaveplan_2pc_transactions` ledger table. The table is owned by
|
||||
`@suaveplan/api-host` (migration id 2, `saga-2pc-reserved`); this
|
||||
module only reads and writes rows so the host stays the single source
|
||||
of truth for schema evolution.
|
||||
|
||||
The ledger is what makes 2PC durable across coordinator restarts.
|
||||
Without it, a host that crashes mid-prepare would lose track of which
|
||||
participants were `PREPARE TRANSACTION`'d and could not safely choose
|
||||
between commit and rollback on the next boot.
|
||||
|
||||
## Features
|
||||
|
||||
- `TwoPcLedgerStore` — CRUD facade over `suaveplan_2pc_transactions`
|
||||
with statements for every state transition (`insert`,
|
||||
`markAllVotedCommit`, `markAborting`, `markCommitted`, `markAborted`,
|
||||
`markInDoubt`).
|
||||
- `findById` — single-row lookup for state inspection.
|
||||
- `findRecoverable` — scan returning every non-terminal row for the
|
||||
current host instance id. Drives `recovery/`.
|
||||
- `TwoPcLedgerStore.decodeParticipants` — static helper that parses the
|
||||
`participants_json` blob back into a typed array.
|
||||
- Structural `LedgerDb` / `LedgerStatement` interfaces so the store
|
||||
works with `bun:sqlite`, `@suaveplan/db-sqlite`'s thin wrapper, and
|
||||
`better-sqlite3` without any concrete dependency.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Database } from "bun:sqlite";
|
||||
import { TwoPcLedgerStore } from "@suaveplan/api-2pc/persistence";
|
||||
|
||||
const db = new Database(":memory:");
|
||||
// Schema is created by api-host's migration runner — see migrations.ts
|
||||
// migration id 2 (`saga-2pc-reserved`). In tests, run the host runner
|
||||
// against the same database.
|
||||
|
||||
const store = new TwoPcLedgerStore(db as never);
|
||||
store.insert({
|
||||
id: "tx-1",
|
||||
coordinator: "host-instance-01",
|
||||
participants: [
|
||||
{ participantId: "billing", voterMode: "writer", vote: null },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Walk the recovery candidates and decode each blob — the typical shape
|
||||
used inside `recovery/recovery.ts`:
|
||||
|
||||
```ts
|
||||
import { TwoPcLedgerStore } from "@suaveplan/api-2pc/persistence";
|
||||
|
||||
function listRecoverable(store: TwoPcLedgerStore, hostId: string) {
|
||||
const rows = store.findRecoverable(hostId);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
state: row.state,
|
||||
participants: TwoPcLedgerStore.decodeParticipants(row),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
The static `decodeParticipants` is deliberately exposed so callers
|
||||
working with raw rows (e.g. ad-hoc admin scripts) do not have to
|
||||
recreate the JSON contract themselves.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface LedgerStatement` / `interface LedgerDb` — structural
|
||||
SQLite-shaped views.
|
||||
- `interface TwoPcLedgerRow` — exact row shape returned by `findById`
|
||||
/ `findRecoverable`.
|
||||
- `interface LedgerParticipantEntry` — per-participant entry in
|
||||
`participants_json`.
|
||||
- `class TwoPcLedgerStore`
|
||||
- `constructor(db: LedgerDb)`
|
||||
- `insert({ id, coordinator, participants })`
|
||||
- `markAllVotedCommit({ id, participants, preparedAt })`
|
||||
- `markAborting({ id, participants })`
|
||||
- `markCommitted(id, committedAt)`
|
||||
- `markAborted(id, abortedAt)`
|
||||
- `markInDoubt(id)`
|
||||
- `findById(id): TwoPcLedgerRow | null`
|
||||
- `findRecoverable(coordinator): readonly TwoPcLedgerRow[]`
|
||||
- `static decodeParticipants(row): readonly LedgerParticipantEntry[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every UPDATE uses a single statement with the new `state` value
|
||||
matched against `TwoPcCoordinatorState`'s string-literal union — the
|
||||
`satisfies TwoPcCoordinatorState` annotation catches typos at compile
|
||||
time. The store does NOT enforce a state machine on top of the table;
|
||||
that responsibility belongs to the coordinator. Keeping the
|
||||
persistence layer state-machine-free lets recovery use the same store
|
||||
without conflating "what changed in this lifetime" with "what the row
|
||||
looked like after a crash".
|
||||
|
||||
The `participants_json` blob is stored as a JSON string rather than a
|
||||
side table — the coordinator never queries by participant id, only by
|
||||
transaction id, so a JSON blob has zero indexing cost and survives
|
||||
schema evolution painlessly. `decodeParticipants` returns an empty
|
||||
array when the blob is not an array — defensive against rows
|
||||
hand-written by ad-hoc admin scripts.
|
||||
94
docs/api-stack/api/api-2pc/src/phases/commit.md
Normal file
94
docs/api-stack/api/api-2pc/src/phases/commit.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `phases/commit`
|
||||
|
||||
## Purpose
|
||||
|
||||
Phase-2 commit for a single participant. Once every participant has
|
||||
returned `commit` from phase 1 and the coordinator has persisted the
|
||||
`all-voted-commit` state to the ledger, this module emits
|
||||
`COMMIT PREPARED '<gid>'` against each writer participant and records
|
||||
`2pc.commit_duration_ms`.
|
||||
|
||||
Phase 2 is intentionally unbounded by timeout — the protocol cannot
|
||||
tolerate giving up on a commit-prepared mid-flight, because doing so
|
||||
would leave the system in `in_doubt` state and require the recovery
|
||||
runner to resolve it on the next boot.
|
||||
|
||||
## Features
|
||||
|
||||
- Skips participants that did not actually prepare (`outcome.prepared
|
||||
=== false`) — read-only voters and abort-voters never see commit
|
||||
SQL.
|
||||
- Emits a `2pc.commit` span with `tx.id`, `participant.id`, and `gid`
|
||||
attributes.
|
||||
- Records `2pc.commit_duration_ms` tagged with `outcome: "committed"`
|
||||
on success.
|
||||
- Failure preserves the underlying cause on a thrown
|
||||
`TwoPcError(TWOPC_COMMIT_FAILED)`; the coordinator escalates to
|
||||
`TWOPC_IN_DOUBT` once it has tried every participant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { commitPhaseTwo } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
await commitPhaseTwo({
|
||||
txId,
|
||||
participant,
|
||||
outcome: preparedOutcome,
|
||||
instruments,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The coordinator collects per-participant failures rather than
|
||||
short-circuiting — every participant gets attempted exactly once so
|
||||
operators can see the full picture in the ledger:
|
||||
|
||||
```ts
|
||||
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
|
||||
import { commitPhaseTwo } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
const failures: TwoPcError[] = [];
|
||||
for (const outcome of preparedOutcomes) {
|
||||
if (!outcome.prepared) continue;
|
||||
try {
|
||||
await commitPhaseTwo({
|
||||
txId,
|
||||
participant: registry.get(outcome.participantId)!,
|
||||
outcome,
|
||||
instruments,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TwoPcError) failures.push(err);
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
// ledger.markInDoubt(txId); recovery resolves on next boot
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CommitPhaseInput`
|
||||
- `txId: string`
|
||||
- `participant: RegisteredTwoPcParticipant`
|
||||
- `outcome: ParticipantPrepareOutcome` — the phase-1 result.
|
||||
- `instruments: TwoPcInstruments`
|
||||
- `function commitPhaseTwo(input): Promise<void>`
|
||||
- no-op for `outcome.prepared === false`
|
||||
- throws `TwoPcError(TWOPC_COMMIT_FAILED)` on SQL failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The gid stored on the prepare outcome is the single source of truth —
|
||||
this module never rebuilds it. Reusing the prepare-time value
|
||||
defends against the unlikely case where a host-config change between
|
||||
phases would have produced a different gid: the SQL the coordinator
|
||||
needs to send is exactly the one it prepared with.
|
||||
|
||||
The SQL escape (`replace(/'/g, "''")`) matches the prepare-phase
|
||||
treatment so a gid containing the unlikely quote character would
|
||||
still produce valid SQL. The escape is cheap; the defence-in-depth
|
||||
costs nothing on the happy path.
|
||||
85
docs/api-stack/api/api-2pc/src/phases/gid.md
Normal file
85
docs/api-stack/api/api-2pc/src/phases/gid.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# `phases/gid`
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds and parses the global transaction id (gid) string passed to
|
||||
Postgres' `PREPARE TRANSACTION '<gid>'`. The format embeds the host
|
||||
instance id so the recovery scanner can filter `pg_prepared_xacts` to
|
||||
only the rows this coordinator owns — without that anchor, two
|
||||
coordinators sharing a Postgres host would clobber each other's
|
||||
recovery decisions.
|
||||
|
||||
The module is intentionally pure: no SQL, no client. Centralising the
|
||||
gid format here makes the recovery filter and the per-participant
|
||||
prepare path share a single source of truth.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildGid(hostInstanceId, txId, participantId)` — produces
|
||||
`2pc-<host>-<tx>-<participant>`.
|
||||
- `parseGid(gid, hostInstanceId)` — extracts `{ txId, participantId }`
|
||||
for gids written by the supplied host; returns `null` otherwise so
|
||||
recovery skips foreign prepared transactions silently. The
|
||||
hostInstanceId is supplied by the caller (the recovery runner)
|
||||
because the host id itself may contain hyphens and the parser cannot
|
||||
recover it unambiguously from the gid alone.
|
||||
- `gidPrefixFor(hostInstanceId)` — the prefix the recovery SQL filter
|
||||
uses; centralised so SQL and parser never drift.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildGid, parseGid } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
const gid = buildGid("host-01", "tx-abc", "billing");
|
||||
// "2pc-host-01-tx-abc-billing"
|
||||
|
||||
const parts = parseGid(gid, "host-01");
|
||||
// { txId: "tx-abc", participantId: "billing" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Recovery uses the prefix filter to ignore prepared transactions
|
||||
written by other components sharing the database:
|
||||
|
||||
```ts
|
||||
import { gidPrefixFor, parseGid } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
const prefix = gidPrefixFor("host-01");
|
||||
const rows = await db.run<{ gid: string }>(
|
||||
`SELECT gid FROM pg_prepared_xacts WHERE gid LIKE $1`,
|
||||
[`${prefix}%`]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const parts = parseGid(row.gid, "host-01");
|
||||
if (parts === null) continue; // foreign prepared transaction
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildGid(hostInstanceId: string, txId: string, participantId: string): string`
|
||||
- `parseGid(gid: string, hostInstanceId: string): { txId, participantId } | null`
|
||||
- `gidPrefixFor(hostInstanceId: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`parseGid` takes the caller-supplied `hostInstanceId` and strips the
|
||||
prefix `2pc-<host>-` before extracting `txId` and `participantId`.
|
||||
The host instance id is supplied explicitly because hyphens are
|
||||
allowed inside it (production hosts often look like `host-eu-west-1`)
|
||||
and the parser cannot recover the boundary from the gid alone. The
|
||||
recovery runner always knows its own host id, so this constraint is
|
||||
ergonomic in practice.
|
||||
|
||||
Participant ids are constrained by the registry (and by host config)
|
||||
to not contain hyphens, so the LAST hyphen after the prefix
|
||||
unambiguously delimits the `participantId` from the `txId`.
|
||||
|
||||
The function returns `null` rather than throwing on unrecognised gids
|
||||
because the recovery scan iterates over rows that may include prepared
|
||||
transactions written by code outside this package's control — Postgres
|
||||
has no participant-tag column, only the gid string itself, so the
|
||||
shared-database scenario is a legitimate concern.
|
||||
105
docs/api-stack/api/api-2pc/src/phases/prepare.md
Normal file
105
docs/api-stack/api/api-2pc/src/phases/prepare.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `phases/prepare`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements phase 1 of the 2PC protocol for a single participant. The
|
||||
coordinator iterates participants and calls this module for each;
|
||||
unanimous commit votes trigger phase 2, any abort or prepare failure
|
||||
short-circuits to rollback. This module is the only place the
|
||||
`PREPARE TRANSACTION '<gid>'` SQL is emitted, so the gid format
|
||||
(`phases/gid`) and the SQL emission point stay co-located.
|
||||
|
||||
Phase 1 is the latency-sensitive half of 2PC: every participant holds
|
||||
row-level locks for the duration. The module records
|
||||
`2pc.prepare_duration_ms` so operators can spot creeping lock holds
|
||||
before they cause throughput regressions.
|
||||
|
||||
## Features
|
||||
|
||||
- Runs the participant's `prepare` callback, then the
|
||||
`PREPARE TRANSACTION` SQL when the vote is `commit` AND the
|
||||
participant is a writer.
|
||||
- Skips the SQL for read-only voters — they participate in the vote
|
||||
only.
|
||||
- Refuses a read-only voter that returned `writeIntent: true` with
|
||||
`TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`.
|
||||
- Honours an `AbortSignal` for the phase-1 deadline; aborted SQL is
|
||||
remapped to `TWOPC_TIMEOUT`.
|
||||
- Emits a `2pc.prepare` span around the whole call and records a
|
||||
`2pc.prepare_duration_ms` observation tagged with the outcome.
|
||||
- Failure cases preserve the underlying cause on the thrown
|
||||
`TwoPcError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { preparePhaseOne } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
const outcome = await preparePhaseOne({
|
||||
hostInstanceId: "host-01",
|
||||
txId,
|
||||
participant,
|
||||
payload: { invoiceId: "inv-1" },
|
||||
instruments,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (outcome.vote === "abort") {
|
||||
// run rollback for every participant that voted commit
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The coordinator runs this in a loop. Read-only voters interleave
|
||||
freely with writers — the outcome's `prepared` flag tells phase 2
|
||||
whether to send commit/rollback SQL:
|
||||
|
||||
```ts
|
||||
import { preparePhaseOne } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
const outcomes: ParticipantPrepareOutcome[] = [];
|
||||
for (const p of registry.list()) {
|
||||
const o = await preparePhaseOne({
|
||||
hostInstanceId,
|
||||
txId,
|
||||
participant: p,
|
||||
payload: payloadFor(p),
|
||||
instruments,
|
||||
signal,
|
||||
});
|
||||
outcomes.push(o);
|
||||
if (o.vote === "abort") break; // short-circuit
|
||||
}
|
||||
|
||||
// phase 2: commit only those with prepared === true
|
||||
const writers = outcomes.filter((o) => o.prepared);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ParticipantPrepareOutcome` — `{ participantId, vote,
|
||||
prepared, gid }`.
|
||||
- `interface PreparePhaseInput` — `{ hostInstanceId, txId,
|
||||
participant, payload, instruments, signal }`.
|
||||
- `function preparePhaseOne(input): Promise<ParticipantPrepareOutcome>`
|
||||
- throws `TWOPC_PREPARE_FAILED` when the participant callback or
|
||||
SQL raises (signal not aborted),
|
||||
- throws `TWOPC_TIMEOUT` when the signal aborted around the SQL,
|
||||
- throws `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE` when a read-only
|
||||
voter returned `writeIntent: true`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The SQL escape strategy is conservative: gids are built from the host
|
||||
instance id, transaction id, and participant id — all three are
|
||||
already validated at host config / id-generator / registry time — but
|
||||
the function still calls `.replace(/'/g, "''")` before interpolating
|
||||
into the `PREPARE TRANSACTION` statement. The runtime cost is zero on
|
||||
the happy path and the defence-in-depth is cheap. The coordinator
|
||||
never accepts user-supplied SQL fragments.
|
||||
|
||||
The span is started before the prepare callback so the participant's
|
||||
own work shows up in the trace as a child of the `2pc.prepare` span if
|
||||
the participant opens its own spans against the same tracer. The
|
||||
`finally`-block `span.end()` guarantees the span is closed regardless
|
||||
of which branch fires.
|
||||
92
docs/api-stack/api/api-2pc/src/phases/rollback.md
Normal file
92
docs/api-stack/api/api-2pc/src/phases/rollback.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# `phases/rollback`
|
||||
|
||||
## Purpose
|
||||
|
||||
Phase-2 rollback for a single participant. Used both by the
|
||||
coordinator's abort path (vote_abort or prepare failure) and by the
|
||||
recovery runner (orphan with no `all-voted-commit` ledger row).
|
||||
|
||||
The function is idempotent at the protocol level: Postgres returns
|
||||
`42704` (`undefined_object`, "prepared transaction does not exist")
|
||||
for a gid that was already rolled back, and this module catches that
|
||||
specific error and treats it as a no-op. That property is what makes
|
||||
recovery safe to re-run on every boot.
|
||||
|
||||
## Features
|
||||
|
||||
- Emits `ROLLBACK PREPARED '<gid>'` against the participant's
|
||||
connection.
|
||||
- Idempotent against Postgres `42704` — a second rollback against the
|
||||
same gid is a no-op.
|
||||
- Skips participants that did not actually prepare (`outcome.prepared
|
||||
=== false`).
|
||||
- Emits a `2pc.rollback` span with `tx.id`, `participant.id`, and
|
||||
`gid` attributes.
|
||||
- Failure preserves the underlying cause on a thrown
|
||||
`TwoPcError(TWOPC_ROLLBACK_FAILED)`.
|
||||
- Exports `isUnknownPreparedTransaction(cause)` so the recovery
|
||||
runner can apply the same idempotency check without duplicating the
|
||||
SQLSTATE sniff.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { rollbackPhaseTwo } from "@suaveplan/api-2pc/phases";
|
||||
|
||||
await rollbackPhaseTwo({
|
||||
txId,
|
||||
participant,
|
||||
outcome: preparedOutcome,
|
||||
instruments,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Recovery uses the same idempotency check directly to short-circuit
|
||||
when the row was concurrently resolved:
|
||||
|
||||
```ts
|
||||
import {
|
||||
isUnknownPreparedTransaction,
|
||||
rollbackPhaseTwo,
|
||||
} from "@suaveplan/api-2pc/phases";
|
||||
|
||||
try {
|
||||
await rollbackPhaseTwo({ txId, participant, outcome, instruments });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TwoPcError &&
|
||||
err.twoPcCode === TWOPC_CODES.ROLLBACK_FAILED &&
|
||||
isUnknownPreparedTransaction(err.cause)
|
||||
) {
|
||||
// race: another process already rolled this back — treat as success
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RollbackPhaseInput` — same fields as `CommitPhaseInput`.
|
||||
- `function rollbackPhaseTwo(input): Promise<void>`
|
||||
- no-op for `outcome.prepared === false`
|
||||
- throws `TwoPcError(TWOPC_ROLLBACK_FAILED)` on non-idempotency SQL
|
||||
failure.
|
||||
- `function isUnknownPreparedTransaction(cause: unknown): boolean`
|
||||
- matches SQLSTATE `42704` AND the substring `"does not exist"` as
|
||||
a fallback for drivers that omit the SQLSTATE.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The idempotency check sniffs both `cause.code` and `cause.message`
|
||||
because the two postgres clients in use (`postgres`/porsager and
|
||||
`pg.Client`) surface the SQLSTATE differently. Using both lets the
|
||||
module work against either driver without a wrapper layer.
|
||||
|
||||
The function is split into a small public `rollbackPhaseTwo` and an
|
||||
exported `isUnknownPreparedTransaction` helper so recovery can re-use
|
||||
the idempotency predicate without re-implementing it. Recovery sees
|
||||
this exact race regularly: the coordinator and the recovery runner
|
||||
both target the same prepared gid when a host restarts mid-rollback.
|
||||
133
docs/api-stack/api/api-2pc/src/recovery/recovery.md
Normal file
133
docs/api-stack/api/api-2pc/src/recovery/recovery.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# `recovery/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Boot-time orphan resolution for the 2PC coordinator. When a host
|
||||
crashes between phase 1 and phase 2, prepared transactions remain
|
||||
held in `pg_prepared_xacts` on every writer participant. The recovery
|
||||
runner sweeps those rows on the next boot, consults the host-owned
|
||||
ledger, and either commits or rolls back each one — guaranteeing the
|
||||
"once prepared, eventually resolved" half of the 2PC durability
|
||||
property.
|
||||
|
||||
The runner is the only path that should ever resolve an in-doubt
|
||||
transaction. The coordinator's hot path NEVER auto-recovers; it
|
||||
escalates to `TWOPC_IN_DOUBT` and lets the next-boot recovery sweep
|
||||
handle it. That separation keeps the hot path's failure handling
|
||||
small and the recovery path testable in isolation.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveRecovery({ hostInstanceId, ledger, probes, onResolved })` —
|
||||
the entry point. Returns a `RecoveryReport` enumerating committed,
|
||||
rolled-back, skipped, and errored gids.
|
||||
- Idempotent — re-running the function on a host with no outstanding
|
||||
prepared transactions is a no-op (the inner `queryOrphans` returns
|
||||
an empty list).
|
||||
- Treats Postgres `42704` ("does not exist") as success during
|
||||
rollback — covers the race where another process already resolved
|
||||
the orphan.
|
||||
- Marks rows as `in_doubt` on unresolvable errors so the next sweep
|
||||
can retry; errored gids are surfaced on the report rather than
|
||||
thrown.
|
||||
- `buildProbeFromRegistry(registry)` — convenience adapter that
|
||||
spins up one probe per writer participant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildProbeFromRegistry,
|
||||
resolveRecovery,
|
||||
} from "@suaveplan/api-2pc/recovery";
|
||||
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "host-01",
|
||||
ledger,
|
||||
probes: buildProbeFromRegistry(registry),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`committed=${report.committed.length} ` +
|
||||
`rolledBack=${report.rolledBack.length} ` +
|
||||
`errors=${report.errors.length}`
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a custom probe to feed an alternate identifier convention or a
|
||||
replicated-read connection — useful when the recovery sweep should
|
||||
hit a read replica to avoid contending with the live writer:
|
||||
|
||||
```ts
|
||||
import { gidPrefixFor } from "@suaveplan/api-2pc/phases";
|
||||
import { resolveRecovery, type RecoveryProbe } from "@suaveplan/api-2pc/recovery";
|
||||
|
||||
const probes: RecoveryProbe[] = [
|
||||
{
|
||||
participantId: "billing",
|
||||
async queryOrphans(prefix) {
|
||||
const rows = await readReplica.unsafe<{ gid: string }[]>(
|
||||
`SELECT gid FROM pg_prepared_xacts WHERE gid LIKE $1`,
|
||||
[`${prefix}%`]
|
||||
);
|
||||
return rows.map((r) => r.gid);
|
||||
},
|
||||
async commit(gid) {
|
||||
await primary.unsafe(`COMMIT PREPARED $1`, [gid]);
|
||||
},
|
||||
async rollback(gid) {
|
||||
await primary.unsafe(`ROLLBACK PREPARED $1`, [gid]);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "host-01",
|
||||
ledger,
|
||||
probes,
|
||||
onResolved: (e) => log.info(e),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RecoveryProbe`
|
||||
- `participantId: string`
|
||||
- `queryOrphans(prefix: string): Promise<readonly string[]>`
|
||||
- `commit(gid: string): Promise<void>`
|
||||
- `rollback(gid: string): Promise<void>`
|
||||
- `interface RecoveryReport`
|
||||
- `committed: readonly string[]`
|
||||
- `rolledBack: readonly string[]`
|
||||
- `skipped: readonly string[]`
|
||||
- `errors: readonly { gid; error: TwoPcError }[]`
|
||||
- `interface RecoveryInput`
|
||||
- `hostInstanceId, ledger, probes, onResolved?`
|
||||
- `function resolveRecovery(input): Promise<RecoveryReport>`
|
||||
- `function buildProbeFromRegistry(registry): readonly RecoveryProbe[]`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The ledger lookup uses the gid's parsed `txId` as the primary key —
|
||||
the gid format from `phases/gid.ts` is what makes that mapping
|
||||
unambiguous. Rows whose ledger state is `committing` OR `committed`
|
||||
trigger `COMMIT PREPARED`; everything else (including missing rows)
|
||||
triggers `ROLLBACK PREPARED`. That asymmetric default ("rollback by
|
||||
default") is the safe choice — committing a row whose ledger does
|
||||
not explicitly say "all voted commit" would violate the design
|
||||
guarantee.
|
||||
|
||||
The `42704` swallow during rollback is what makes the runner safe to
|
||||
re-execute: if the previous sweep crashed AFTER rolling back the
|
||||
prepared transaction but BEFORE updating the ledger, the second
|
||||
sweep will see the orphan listed in `pg_prepared_xacts` (or not, if
|
||||
truly gone) and will not throw on the "does not exist" follow-up.
|
||||
Errors that cannot be classified as idempotency are captured per-gid
|
||||
and surfaced on the report — operators page-on `errors.length > 0`,
|
||||
not on a generic throw.
|
||||
111
docs/api-stack/api/api-2pc/src/registry/participant-registry.md
Normal file
111
docs/api-stack/api/api-2pc/src/registry/participant-registry.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# `registry/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The participant registry is the install-time admission control between
|
||||
plugins and the 2PC coordinator. Plugins declare
|
||||
`twoPhaseCommit.participates: true` in their manifest; the host
|
||||
registers them with this registry; the registry refuses any writer
|
||||
whose bound database engine cannot honour `PREPARE TRANSACTION`.
|
||||
|
||||
This is where the design doc §9.2 rule "SQLite and SurrealDB MUST NOT
|
||||
participate as writers" is enforced. The capability check is injected
|
||||
as a callback so the registry stays decoupled from
|
||||
`@suaveplan/api-host`'s concrete `CapabilityRegistry`.
|
||||
|
||||
## Features
|
||||
|
||||
- `TwoPcParticipantRegistry.register(participant)` — refuses writers
|
||||
without `db.preparable`; accepts read-only voters regardless of
|
||||
engine.
|
||||
- `get(id)` / `list()` / `size()` / `unregister(id)` — standard
|
||||
registry surface.
|
||||
- Injection seam: `PreparableCapabilityCheck` is a plain
|
||||
`(id: string) => boolean` callback — zero coupling to api-host.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TwoPcParticipantRegistry } from "@suaveplan/api-2pc/registry";
|
||||
|
||||
const isPreparable = (pluginId: string) =>
|
||||
pluginId === "billing" || pluginId === "inventory";
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(isPreparable);
|
||||
registry.register({
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: { engine: "postgres", run: pg.unsafe.bind(pg) },
|
||||
prepare: billingPrepareCallback,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Refusing a SQLite-backed plugin as a writer happens at register-time
|
||||
— wrap the registration in a try/catch to surface the failure to the
|
||||
plugin loader without crashing the host boot:
|
||||
|
||||
```ts
|
||||
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
|
||||
import { TwoPcParticipantRegistry } from "@suaveplan/api-2pc/registry";
|
||||
|
||||
function safeRegisterParticipants(
|
||||
registry: TwoPcParticipantRegistry,
|
||||
participants: ReadonlyArray<RegisteredTwoPcParticipant>
|
||||
) {
|
||||
const failures: { id: string; reason: string }[] = [];
|
||||
for (const p of participants) {
|
||||
try {
|
||||
registry.register(p);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TwoPcError &&
|
||||
err.twoPcCode === TWOPC_CODES.PARTICIPANT_NOT_PREPARABLE
|
||||
) {
|
||||
failures.push({ id: p.id, reason: err.message });
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
```
|
||||
|
||||
The same plugin can then re-register as a `read-only` voter — the
|
||||
registry accepts read-only voters regardless of engine, because the
|
||||
coordinator never sends them prepare/commit SQL.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type PreparableCapabilityCheck = (participantId: string) => boolean`
|
||||
— capability resolver injected at construction.
|
||||
- `class TwoPcParticipantRegistry`
|
||||
- `constructor(isPreparable: PreparableCapabilityCheck)`
|
||||
- `register(participant): void` — throws
|
||||
`TWOPC_PARTICIPANT_NOT_PREPARABLE` on writer refusal.
|
||||
- `get(id): RegisteredTwoPcParticipant | undefined`
|
||||
- `list(): readonly RegisteredTwoPcParticipant[]`
|
||||
- `size(): number`
|
||||
- `unregister(id): boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry refuses a writer at `register`-time rather than at
|
||||
`run2pc`-time. This is deliberate — admission failures should surface
|
||||
during plugin install, not when the first 2PC transaction is attempted
|
||||
in production. The capability check fires only for writers; read-only
|
||||
voters are accepted unconditionally because the coordinator drops their
|
||||
connection from phase-2 SQL traffic. The second-half guard against
|
||||
read-only voters that try to write a payload lives in
|
||||
`phases/prepare.ts` (raising `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`
|
||||
when the participant returns `writeIntent: true`).
|
||||
|
||||
`unregister` exists for hot-reload paths: when a plugin is removed
|
||||
from the running host, the registry drops its participant entry so
|
||||
subsequent `run2pc` calls cannot accidentally route to it.
|
||||
100
docs/api-stack/api/api-2pc/src/testing/conformance.md
Normal file
100
docs/api-stack/api/api-2pc/src/testing/conformance.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# `testing/`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides `run2pcParticipantConformance(factory)` — a runnable test
|
||||
suite that pins the contract every 2PC writer participant MUST honour.
|
||||
Adapter authors drop a single import + invocation into a
|
||||
`tests/conformance/run-2pc-participant.test.ts` file in their package;
|
||||
the harness handles the rest.
|
||||
|
||||
Shipping the harness on this package's `./testing` subpath keeps
|
||||
participants accountable to the same prepare/commit/rollback semantics
|
||||
the coordinator depends on at runtime. Without this harness, drift
|
||||
between participant implementations would only surface during
|
||||
integration testing — too late.
|
||||
|
||||
## Features
|
||||
|
||||
- Six pinned invariants (engine identifier, voter mode, prepare-then-
|
||||
prepare-SQL, commit-prepared, rollback-prepared, idempotency-on-
|
||||
second-rollback).
|
||||
- Per-scenario factory: every test starts from a fresh participant +
|
||||
teardown so the suite is isolation-safe under parallel runs.
|
||||
- Uses `@suaveplan/testing/runner` — works under both Vitest and
|
||||
Bun:test without re-importing.
|
||||
- Generates unique gids per scenario so re-runs do not collide.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// In an adapter package's tests/conformance/run-2pc-participant.test.ts
|
||||
import { run2pcParticipantConformance } from "@suaveplan/api-2pc/testing";
|
||||
import { createMyParticipant, teardownMyParticipant } from "../helpers";
|
||||
|
||||
run2pcParticipantConformance({
|
||||
async create() {
|
||||
const participant = await createMyParticipant();
|
||||
return {
|
||||
participant,
|
||||
teardown: () => teardownMyParticipant(participant),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The harness exposes no surface beyond `run2pcParticipantConformance` —
|
||||
deliberately. Adapter-specific assertions belong in adapter test
|
||||
files, not in the conformance harness. If your adapter needs to
|
||||
exercise behaviour the suite does not cover (e.g. cancellation,
|
||||
custom serialization), add a sibling test file:
|
||||
|
||||
```ts
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import { run2pcParticipantConformance } from "@suaveplan/api-2pc/testing";
|
||||
|
||||
describe("my-participant", () => {
|
||||
run2pcParticipantConformance({ create: createMyParticipant });
|
||||
|
||||
it("cancels a stuck prepare on signal abort", { timeout: 30_000 }, async () => {
|
||||
// adapter-specific assertion
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TwoPcParticipantFactory`
|
||||
- `create(): Promise<{ participant: RegisteredTwoPcParticipant; teardown: () => Promise<void> }>`
|
||||
- `function run2pcParticipantConformance(factory): void`
|
||||
- Registers six `describe`/`it` blocks against the surrounding
|
||||
runner. Returns nothing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness deliberately does NOT use a shared Postgres connection
|
||||
across scenarios. Each scenario calls `factory.create()` to get a
|
||||
fresh participant; the factory is expected to wire its own connection
|
||||
(directly to a Postgres container, against a shared pool, or against
|
||||
an `env-bridge` passthrough URL). This isolation prevents one
|
||||
scenario's leftover prepared transactions from contaminating the
|
||||
next.
|
||||
|
||||
Gids embed `Date.now()` + a random suffix so re-runs within the same
|
||||
millisecond never collide on the underlying `pg_prepared_xacts`
|
||||
table. The cleanup `ROLLBACK PREPARED` at the end of the
|
||||
prepare-only scenario is best-effort: failure to roll back leaves
|
||||
the gid prepared until the next teardown sweep, but the harness's
|
||||
own teardown handle is the primary cleanup path.
|
||||
|
||||
The harness is intentionally NOT exported from the package root (`./`
|
||||
subpath) — only `./testing`. This mirrors the convention used by
|
||||
sibling packages (`@suaveplan/i18n-shared/testing`,
|
||||
`@suaveplan/api-host/testing`) so production code can not accidentally
|
||||
depend on test-only symbols.
|
||||
108
docs/api-stack/api/api-2pc/src/types/types.types.md
Normal file
108
docs/api-stack/api/api-2pc/src/types/types.types.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `types/`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared cross-module type definitions for the 2PC coordinator. Holds
|
||||
the `TwoPcCoordinatorState` state-machine alphabet, the
|
||||
`PreparableConnection` and `RegisteredTwoPcParticipant` participant
|
||||
shapes, the `Run2PcInput` / `Run2PcOutcome` public input/output
|
||||
contracts, and the `TwoPcPrepareResult` per-participant prepare
|
||||
return type.
|
||||
|
||||
Centralising these types here prevents the cyclic-import smell that
|
||||
would otherwise emerge from `coordinator/`, `phases/`, `recovery/`,
|
||||
and `registry/` all needing to know the same wire shapes. The
|
||||
sibling modules import from this barrel and stay decoupled from each
|
||||
other at the type level.
|
||||
|
||||
## Features
|
||||
|
||||
- `TwoPcCoordinatorState` — string-literal union covering
|
||||
`voting`, `committing`, `aborting`, `committed`, `aborted`,
|
||||
`in_doubt`.
|
||||
- `PreparableConnection` — structural connection shape exposing
|
||||
`engine` (a `DatabaseEngine` from `@suaveplan/api-types/db`) and
|
||||
`run(sql, params?)` returning a `Promise<R>`.
|
||||
- `RegisteredTwoPcParticipant` — coordinator-side participant view
|
||||
carrying `id`, `voterMode`, `connection`, and an async
|
||||
`prepare(txId, payload) => TwoPcPrepareResult` callback.
|
||||
- `TwoPcPrepareResult` — `{ vote, writeIntent }` returned by a
|
||||
participant's `prepare` callback.
|
||||
- `Run2PcInput` / `Run2PcOutcome` — public input/output contracts
|
||||
accepted and returned by `run2pc`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Run2PcInput,
|
||||
RegisteredTwoPcParticipant,
|
||||
} from "@suaveplan/api-2pc";
|
||||
|
||||
const input: Run2PcInput = {
|
||||
participants: [{ id: "billing", input: { invoiceId: "inv-1" } }],
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
const billing: RegisteredTwoPcParticipant = {
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: { engine: "postgres", run: pg.unsafe.bind(pg) },
|
||||
prepare: async (_txId) => ({ vote: "commit", writeIntent: true }),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Read-only voter typing — combine `voterMode: "read-only"` with a
|
||||
`writeIntent: false` invariant on the `prepare` return shape:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RegisteredTwoPcParticipant,
|
||||
TwoPcPrepareResult,
|
||||
} from "@suaveplan/api-2pc";
|
||||
|
||||
async function auditPrepare(): Promise<TwoPcPrepareResult> {
|
||||
const ok = await checkConsistency();
|
||||
return { vote: ok ? "commit" : "abort", writeIntent: false };
|
||||
}
|
||||
|
||||
const audit: RegisteredTwoPcParticipant = {
|
||||
id: "audit",
|
||||
voterMode: "read-only",
|
||||
connection: { engine: "surrealdb", run: surrealUnsafe },
|
||||
prepare: () => auditPrepare(),
|
||||
};
|
||||
```
|
||||
|
||||
The coordinator refuses a read-only voter whose `prepare` returns
|
||||
`writeIntent: true` with `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`
|
||||
— the static type doesn't enforce that pairing, but the runtime
|
||||
boundary does.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type TwoPcCoordinatorState`
|
||||
- `interface PreparableConnection`
|
||||
- `interface RegisteredTwoPcParticipant`
|
||||
- `interface TwoPcPrepareResult`
|
||||
- `interface Run2PcInput`
|
||||
- `interface Run2PcOutcome`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every interface is `readonly` on every field. This is deliberate —
|
||||
the coordinator caches inputs (participant lookups, ledger entries)
|
||||
and a mutation by accident would corrupt in-flight state. The
|
||||
strictness is enforced at the type level via `readonly` plus
|
||||
`ReadonlyArray<...>` and at the runtime level by the boundary Zod
|
||||
schemas in `options/` (for caller-facing inputs).
|
||||
|
||||
The `Run2PcOutcome.state` field uses `Extract<TwoPcCoordinatorState,
|
||||
"committed" | "aborted" | "in_doubt">` so callers do not have to
|
||||
exhaustively switch over the in-flight states (`voting`,
|
||||
`committing`, `aborting`) that never appear on a returned outcome.
|
||||
The structural typing of `PreparableConnection` keeps the package
|
||||
independent of `pg` and `postgres` (porsager) — both clients can be
|
||||
adapted at the seam with a one-line wrapper.
|
||||
Reference in New Issue
Block a user