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

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 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"