Files
skills/docs/api-stack/api/api-2pc/src/phases/prepare.md
2026-05-29 08:20:56 +01:00

3.6 KiB

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

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:

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.