8.2 KiB
Capability Spec: api-2pc
Package: @suaveplan/api-2pc
Status: SHIPPED (0.1.0)
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 PREPAREDon every participant AND the transaction ends incommitted
Scenario: any vote-abort rolls back everywhere
- WHEN at least one participant votes abort
- THEN the coordinator issues
ROLLBACK PREPAREDon every participant that returnedvote_commitAND emitsTWOPC_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: trueAND 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
run2pcis called withouttimeoutMs - 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_EXCEEDEDBEFORE 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 PREPAREDto every prepared participant AND emitsTWOPC_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 PREPAREDis issued AND the row is moved tocommitted
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 PREPAREDis 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.transactionspan per transaction lifetime (attributes:tx.id,outcome,participant_count).2pc.prepare,2pc.commit,2pc.rollbackspans per participant phase.2pc.transactions_totalcounter labelled{ outcome }.2pc.prepare_duration_mshistogram labelled{ outcome }.2pc.commit_duration_mshistogram labelled{ outcome }.2pc.outstanding_preparedobservable 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
MeterProviderobserves one observation in2pc.prepare_duration_msAND one in2pc.commit_duration_msAND 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"