Files
2026-05-27 23:29:53 +01:00

144 lines
8.2 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-2pc (api-2pc)
**Package**: `@suaveplan/api-2pc`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Two-phase commit driven against Postgres `PREPARE TRANSACTION`
The package SHALL expose `run2pc({ participants, timeoutMs? }, ctx)`. Phase 1 sends `prepare` to every participant; each participant runs its work inside a transaction and issues `PREPARE TRANSACTION '<participantTxId>'`, returning `vote_commit` or `vote_abort`. Phase 2: if all participants vote commit, the coordinator issues `COMMIT PREPARED` everywhere; if any participant votes abort OR the prepare-phase deadline expires, the coordinator issues `ROLLBACK PREPARED` everywhere.
#### Scenario: unanimous commit completes successfully
- **WHEN** every participant votes commit
- **THEN** the coordinator issues `COMMIT PREPARED` on every participant AND the transaction ends in `committed`
#### Scenario: any vote-abort rolls back everywhere
- **WHEN** at least one participant votes abort
- **THEN** the coordinator issues `ROLLBACK PREPARED` on every participant that returned `vote_commit` AND emits `TWOPC_VOTE_ABORT`
**Verified by:** `src/coordinator/coordinator.test.ts::"unanimous commit issues COMMIT PREPARED on every participant"` AND `src/coordinator/coordinator.test.ts::"any vote-abort rolls back everywhere with TWOPC_VOTE_ABORT"`
---
### Requirement: SQLite and SurrealDB cannot participate as writers
The participant registry SHALL refuse registration of a writing participant whose bound DB engine does not advertise the `db.preparable` capability. SQLite and SurrealDB plugins MAY register as read-only voters that contribute a `vote_commit`/`vote_abort` based on their own consistency check but MUST NOT be sent prepare/commit SQL. Attempting to bind one as a writer fails at install with `TWOPC_PARTICIPANT_NOT_PREPARABLE`.
#### Scenario: SQLite plugin is rejected as a writer
- **WHEN** a plugin bound to SQLite declares `twoPhaseCommit.participates: true` AND is registered as a write participant
- **THEN** install fails with `TWOPC_PARTICIPANT_NOT_PREPARABLE`
#### Scenario: SurrealDB plugin participates as a read-only voter
- **WHEN** a SurrealDB-bound plugin is registered with `voterMode: "read-only"`
- **THEN** registration succeeds AND the coordinator collects its vote in phase 1 but never sends commit/rollback SQL
#### Scenario: read-only voter that attempts a write is refused
- **WHEN** a read-only voter's prepare callback returns a write-intent payload
- **THEN** the coordinator fails with `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`
**Verified by:** `src/registry/participant-registry.test.ts::"rejects SQLite writer with TWOPC_PARTICIPANT_NOT_PREPARABLE"` AND `src/registry/participant-registry.test.ts::"accepts SurrealDB plugin as read-only voter"` AND `src/registry/participant-registry.test.ts::"refuses write-intent from read-only voter"`
---
### Requirement: Timeout default and hard ceiling
The default `timeoutMs` SHALL be 5000ms. The hard ceiling SHALL be 30000ms — calls supplying `timeoutMs > 30000` MUST be refused at call time with `TWOPC_TIMEOUT_CEILING_EXCEEDED` (NOT silently clamped — the operational warning in §9.2 is that long prepares hold row-level locks; the coordinator surfaces the misuse, not hides it). Exceeding the runtime deadline during phase 1 triggers rollback with `TWOPC_TIMEOUT`.
#### Scenario: default timeout applies when none is supplied
- **WHEN** `run2pc` is called without `timeoutMs`
- **THEN** the effective deadline is 5000ms
#### Scenario: timeoutMs above ceiling is refused
- **WHEN** `run2pc({ ..., timeoutMs: 30001 })` is called
- **THEN** the call rejects with `TWOPC_TIMEOUT_CEILING_EXCEEDED` BEFORE any participant is contacted
#### Scenario: deadline expiry during prepare triggers rollback
- **WHEN** any participant has not returned a vote before the deadline
- **THEN** the coordinator issues `ROLLBACK PREPARED` to every prepared participant AND emits `TWOPC_TIMEOUT`
**Verified by:** `src/limits/timeout-ceiling.test.ts::"default timeoutMs is 5000"` AND `src/limits/timeout-ceiling.test.ts::"timeoutMs above 30000 rejects with TWOPC_TIMEOUT_CEILING_EXCEEDED"` AND `src/coordinator/coordinator.test.ts::"deadline expiry during prepare triggers rollback with TWOPC_TIMEOUT"`
---
### Requirement: Outstanding-prepared cap
The coordinator SHALL enforce a default cap of 256 outstanding prepared transactions across all in-flight calls. This default is the spec'd implementation choice for design doc §14's open question on coordinator throttling — it is operator-tunable via host config but the default ships at 256. Exceeding the cap SHALL fail new `run2pc` calls with `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` until in-flight transactions complete.
#### Scenario: 257th concurrent transaction is refused
- **WHEN** 256 transactions are in phase 1 or phase 2 AND a 257th call begins
- **THEN** the 257th call rejects with `TWOPC_OUTSTANDING_LIMIT_EXCEEDED`
#### Scenario: cap recovers as transactions complete
- **WHEN** an in-flight transaction completes AND a new call arrives
- **THEN** the new call is admitted
**Verified by:** `src/limits/outstanding-cap.test.ts::"257th concurrent transaction is refused"` AND `src/limits/outstanding-cap.test.ts::"slot recovers as transactions complete"`
---
### Requirement: Persisted state and boot-time orphan resolution
Every coordinator transition SHALL be persisted to `suaveplan_2pc_transactions` (schema owned by `api-host`). On coordinator boot the recovery runner MUST query every participant's `pg_prepared_xacts` for transactions tagged with the host's instance ID. For each orphan: if the ledger row records `all-voted-commit`, issue `COMMIT PREPARED`; otherwise issue `ROLLBACK PREPARED`. Transactions without a corresponding ledger row MUST be rolled back (safe default — no commit without explicit log).
#### Scenario: orphan with "all-voted-commit" ledger row is committed
- **WHEN** the coordinator boots AND finds a prepared transaction whose ledger row is `all-voted-commit`
- **THEN** `COMMIT PREPARED` is issued AND the row is moved to `committed`
#### Scenario: orphan with no ledger row is rolled back
- **WHEN** the coordinator boots AND finds a prepared transaction with no matching ledger row
- **THEN** `ROLLBACK PREPARED` is issued
**Verified by:** `src/recovery/recovery.test.ts::"orphan with all-voted-commit ledger row is committed"` AND `src/recovery/recovery.test.ts::"orphan with no ledger row is rolled back"`
---
### Requirement: OTel instruments expose prepare and commit durations
The coordinator SHALL emit, against an injected OTel meter and tracer:
- `2pc.transaction` span per transaction lifetime (attributes: `tx.id`, `outcome`, `participant_count`).
- `2pc.prepare`, `2pc.commit`, `2pc.rollback` spans per participant phase.
- `2pc.transactions_total` counter labelled `{ outcome }`.
- `2pc.prepare_duration_ms` histogram labelled `{ outcome }`.
- `2pc.commit_duration_ms` histogram labelled `{ outcome }`.
- `2pc.outstanding_prepared` observable gauge.
Operators rely on `prepare_duration_ms` to spot lock-holding times before they cause throughput regressions.
#### Scenario: successful commit emits both prepare and commit histograms
- **WHEN** a 2PC run completes successfully
- **THEN** an in-memory `MeterProvider` observes one observation in `2pc.prepare_duration_ms` AND one in `2pc.commit_duration_ms` AND the outstanding gauge briefly rises then falls
**Verified by:** `src/instruments/instruments.test.ts::"emits prepare_duration_ms and commit_duration_ms on successful commit"`
---
### Requirement: Conformance suite for participants
The package SHALL publish `run2pcParticipantConformance(participantFactory)` on the `./testing` subpath, pinning prepare-callback signature, vote semantics, idempotent commit/rollback contract, and the requirement that participants advertise `db.preparable`.
#### Scenario: reference participant passes the suite
- **WHEN** the suite runs against the in-package reference Postgres participant
- **THEN** every invariant passes
**Verified by:** `src/coordinator/coordinator.spec-conformance.test.ts::"reference Postgres participant passes run2pcParticipantConformance"`