144 lines
8.2 KiB
Markdown
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"`
|