Distributed systems · advanced
Transaction plus external side effect split-brain: locate the commit boundary
Diagnose split-brain incidents where a database commit and an external side effect (HTTP call, message publish, email send, payment capture) cannot be atomically rolled back together. Use commit-boundary inspection, PostgreSQL statistics views, and idempotency tokens to localize where state diverged.
The symptoms
- •Database row exists for an order/payment/user but the downstream side effect (email queued, message published, webhook delivered, payment captured) did not fire or fired twice.
- •Reconciliation report shows a positive delta on the database side and a negative or duplicate delta on the external system side for the same business identifier.
- •Application logs show COMMIT followed by an exception in the next statement, with a partial HTTP response body or partially parsed message envelope already accepted by the peer.
- •PostgreSQL pg_stat_database xact_commit increases but the application error stream reports a thrown exception after the commit log line.
- •Idempotency token is missing or scoped only to the database write, not the external call.
- •Retry or job-queue replay produces duplicate external effects even though the database row is uniquely constrained.
Likely causes
- •Code commits the database transaction and only then invokes the external side effect, so any failure between COMMIT and the call leaves permanent divergence.
- •External system lacks a transactional outbox or two-phase commit handshake, so the boundary between durable state and observable action is a one-way door.
- •Auto-commit mode is implicit and the application treats multiple statements as one logical unit, so partial rollback semantics are not what the operator assumes.
- •Retry loop on the external call is not de-duplicated against an idempotency key, so a 5xx replay duplicates the side effect while the database row remains unique.
- •Connection pool returns the session to the pool after COMMIT but before the external call resolves, creating interleavings where a sibling transaction reads a not-yet-visible-but-soon-to-be state.
- •Clock or ordering assumptions on the external system mean a logically earlier call is processed after a logically later one, inverting the order around the commit boundary.
First ten minutes
- 01Freeze retries and disable any background worker that fans out side effects, so further divergence does not occur while you inspect.
- 02Capture the exact commit log line: identifier, transaction start timestamp from pg_stat_activity if available, and the byte offset or row count at COMMIT.
- 03Read the application trace from the top, not the bottom: identify the first line that says COMMIT or issues a commit-implicit DDL, then read the next non-trivial statement.
- 04Pull the external system receipt for the business identifier: HTTP status, request ID, delivery timestamp, and any retry or replay counter.
- 05Compare COMMIT-to-side-effect wall-clock gap against the external system's stated idempotency window; a gap longer than that window is a strong tell.
- 06Decide which side is the source of truth for this business action before changing anything; record the decision in the incident note.
Evidence to collect
- •PostgreSQL commit count and rollback count around the incident window via pg_stat_database, scoped to the database that owns the affected table.
- •Application log lines for the suspect transaction: BEGIN, COMMIT/ROLLBACK markers, the first statement after COMMIT, and any thrown exception class with stack frame.
- •External system access log or message broker delivery log keyed by the business identifier and any idempotency key the client supplied.
- •Connection pool checkout and return timestamps for the connection that ran the transaction, to test whether the session crossed the side-effect boundary.
- •Configuration snapshot: autocommit setting, isolation level, transaction manager, retry policy, and idempotency key generation rule.
- •Order of operations document or sequence diagram, if any, with the COMMIT line clearly marked.
Where to look
- •The commit boundary in code: the line where the transaction manager commits and any statement that immediately follows it.
- •The application process or worker boundary: any thread, queue consumer, or scheduled job that performs both the database write and the external call in the same logical unit.
- •The PostgreSQL statistics boundary: pg_stat_database for commit/rollback counters and pg_stat_activity for in-flight transactions on the affected database.
- •The external system boundary: HTTP access logs, message broker delivery logs, payment gateway transaction lookups, email service provider event streams.
- •The retry and queue boundary: the de-duplication store, dead-letter queue, and idempotency cache used by the caller.
- •The reconciliation boundary: nightly jobs that diff database state against the external system, which is where the divergence first becomes visible.
Diagnostic steps
- 01Read the code path linearly from request entry to final return, marking each persistence boundary (BEGIN, COMMIT, ROLLBACK) and each outbound call (HTTP, message publish, email send).
- 02Compare commit count to side-effect call count for the suspect window; a delta larger than the documented retry policy suggests replay without de-duplication.
- 03Inspect the external system for the business identifier: if exactly one record exists and it predates the database commit, the external call ran before commit but was acknowledged after.
- 04Inspect the external system for duplicates: if more than one record exists with the same idempotency key, the retry path bypassed the idempotency cache.
- 05Verify autocommit by querying the application configuration or the driver settings on the suspect connection; implicit autocommit changes rollback semantics.
- 06Check pg_stat_activity for a backend stuck in idle in transaction during the incident window, which indicates the COMMIT was deferred past the external call.
- 07Confirm whether the external system supports an idempotency contract and whether the caller actually sends a stable key; absence of a key is a structural cause, not a transient fault.
- 08Decide boundary ownership: who owns the source of truth, and what compensating action (database correction vs external reversal) is permitted by policy.
Common mistakes
- •Treating COMMIT and the external call as one atomic unit when they are not, and writing rollback logic that assumes the database can reverse the external effect.
- •Logging only the success path, so when COMMIT succeeds and the external call fails, there is no record of which side actually committed first.
- •Adding retries without an idempotency key, which converts a single partial failure into multiple duplicate side effects while the database row stays unique.
- •Reading the stack trace bottom-up and concluding the database is at fault, when the root cause is a thrown exception in the statement after COMMIT.
- •Running a compensating transaction on the database without first checking whether the external system already executed the action, compounding the split-brain state.
- •Assuming the reconciliation job is wrong when it reports divergence; the job is usually the first honest signal that the boundary is leaking.
Safe fixes
- •Move the external call behind a transactional outbox: write the side-effect intent into a database row inside the same transaction, then have a separate worker drain the outbox and invoke the external system with an idempotency key.
- •Add a stable idempotency key derived from the business identifier plus a per-attempt nonce, and pass it on every retry of the external call.
- •If an outbox is not feasible, scope a compensating action narrowly: for each affected business identifier, query the external system first, then apply the smallest database correction that restores consistency.
- •Bound the retry window on the external call to the documented idempotency window of the peer system; beyond that window, treat the call as needing human review.
- •Capture the commit timestamp from the database and persist it alongside the outbox row, so the worker can prove ordering if the peer reorders by clock.
- •Document the source-of-truth decision per business action in the runbook, so operators do not improvise conflicting compensations during an incident.
Prove the fix
- 01Replay a controlled test transaction: induce a forced failure between COMMIT and the external call, and verify that no external side effect is observed; the outbox row should be the only artifact.
- 02Replay a controlled retry: force the external call to return 5xx twice and succeed on the third attempt, and verify the peer records exactly one side effect for the supplied idempotency key.
- 03Diff pg_stat_database xact_commit against the outbox-drained counter over a full reconciliation window; the two counters should agree within the documented retry policy.
- 04Run the reconciliation job against a fresh dataset and confirm zero delta on the affected business identifier within the idempotency window.
- 05Inspect an alert rule that fires when COMMIT-to-side-effect wall-clock gap exceeds the peer's idempotency window, and confirm it triggers on the synthetic fault and not on healthy traffic.
Prevention and next steps
- •Adopt the outbox pattern as the default for any code path that combines a database write with an external side effect; review exceptions in design review.
- •Make idempotency keys mandatory at the client SDK level for all external calls, not optional at the call site.
- •Emit a structured log line on COMMIT and a matching line on the external call completion, with the same correlation identifier, so divergence is grep-able.
- •Add a regression test that forces a fault between COMMIT and the external call and asserts no observable external side effect, kept in the suite that runs on every change.
- •Track commit-to-side-effect gap as a service-level indicator, with an alert when it exceeds the minimum idempotency window across integrated peers.
Safe commands and checks
psql -d <dbname> -c "SELECT datname, xact_commit, xact_rollback, conflicts FROM pg_stat_database WHERE datname = current_database();"
psql -d <dbname> -c "SELECT pid, state, query_start, xact_start FROM pg_stat_activity WHERE datname = current_database() AND state IN ('idle in transaction','active');"
psql -d <dbname> -c "SELECT COUNT(*) FROM <outbox_table> WHERE dispatched_at IS NULL;"
psql -d <dbname> -c "SELECT id, business_key, idempotency_key, created_at, dispatched_at, last_error FROM <outbox_table> ORDER BY created_at DESC LIMIT 50;"
grep -nE 'BEGIN|COMMIT|ROLLBACK' <application_log_path> | grep -A1 '<correlation_id>'
grep -nE 'idempotency-key|Idempotency-Key|<idempotency_header>' <application_log_path> | tail -n 200