Multi-Write in YugabyteDB: Testing Concurrent Writes, Conflicts, and Consistency

Applications in different regions can connect to the same YugabyteDB universe and write to the same tables. But what happens when those applications try to insert the same primary key… or update a row that another transaction is already changing?

Let’s make those applications compete for the same rows and inspect the results.

In this Tip, we’ll use yb_multi_app_simulator.sh to start multiple application workers through different YSQL endpoints. Each worker attempts the same set of primary keys, handles the expected duplicate-key condition, and records its committed work. The script then checks every key and reports PASS or FAIL.

Multiple Entry Points, One Transactional Database

All endpoints in this demo belong to one YugabyteDB universe. Applications share the same tables, primary-key constraints, and transaction system across those endpoints.

The Test Environment

For this demo, I used yugabyted to set up a local three-node YugabyteDB universe. Each node was configured with a different region and zone placement label.

Here is the resulting topology:

				
					SELECT host, cloud, region, zone
FROM yb_servers()
ORDER BY host;
				
			

Output:

				
					.  host    | cloud |  region   |    zone
-----------+-------+-----------+------------
 127.0.0.1 | aws   | us-east-1 | us-east-1a
 127.0.0.2 | aws   | us-east-2 | us-east-2a
 127.0.0.3 | aws   | us-west-2 | us-west-2a
				
			

These are loopback addresses on a local test machine. The region names are placement labels. Connecting through these endpoints exercises different YSQL entry points, but does not reproduce the latency of applications deployed in those AWS regions.

On a geographically distributed universe, replace them with reachable endpoints in your actual regions. Running all clients from one machine still leaves the client-to-server network paths different from applications deployed in each region.

What Each Application Does

Each endpoint gets one worker. All workers run concurrently, and each worker processes keys 1 through N sequentially.

For each key, a worker opens an explicit transaction and:

  • 1. Attempts an INSERT with a visit count of 1.
  • 2. If that primary key already exists, catches unique_violation and increments the existing count using UPDATE ... RETURNING.
  • 3. Records its operation and the YSQL server’s region.
  • 4. Deletes the row when its visit count reaches the configured number of workers.
  • 5. Commits the data changes and success events together.

The counter advances inside the database:

				
					UPDATE yb_multi_app_demo.target_rows
SET visit_count = visit_count + 1
WHERE run_id = p_run
  AND id = p_id
RETURNING visit_count INTO v_count;
				
			

This avoids an application reading a value, calculating a replacement, and later overwriting a newer value with that stale calculation.

With three workers, each key progresses through visits 1, 2, and 3. The worker that reaches 3 also deletes it in the same transaction. The winning endpoint can vary by key.

The script creates three tables in the dedicated yb_multi_app_demo schema: target_rows, events, and attempt_errors. Every run receives a UUID, so previous results remain available without dropping or truncating tables.

Confirm Read Committed Is Enabled

Before running the simulator, check the effective transaction isolation level using this YugabyteDB function:

				
					SELECT yb_get_effective_transaction_isolation_level();
				
			

Expected result:

				
					 .yb_get_effective_transaction_isolation_level
----------------------------------------------
 read committed
(1 row)
				
			

The read committed result confirms the effective isolation level for that connection. Run the check through each YSQL endpoint used by the simulator.

The simulator explicitly requests Read Committed for every worker transaction. To check that same transaction mode yourself, run:

				
					BEGIN ISOLATION LEVEL READ COMMITTED;

SELECT yb_get_effective_transaction_isolation_level();

COMMIT;
				
			

The function should return read committed inside that transaction. This checks the effective isolation level rather than relying only on the requested SQL isolation setting.

Isolation Level and Conflict Policy Are Separate

The function verifies the effective isolation level. It does not verify the conflict policy. Check the enable_wait_queues configuration separately when demonstrating Wait-on-Conflict behavior.

Related Tip: Verify Effective Transaction Isolation Level in YSQL, which also documents this function.

Run the Simulator

Save the complete script at the end of this Tip as yb_multi_app_simulator.sh. It requires Bash, ysqlsh, connectivity to the supplied endpoints, and permission to create the demo schema, tables, and function.

Make it executable and start three workers:

				
					chmod +x yb_multi_app_simulator.sh

./yb_multi_app_simulator.sh \
  --nodes "127.0.0.1 127.0.0.2 127.0.0.3" \
  --txns 10
				
			

That requests 30 logical transactions: three workers, each processing ten keys. Increasing --txns increases work per worker; adding endpoints increases the worker count. You can repeat an endpoint to run multiple workers through the same node.

The default --hold-ms 100 keeps each transaction open briefly before committing to encourage overlapping writes. It does not guarantee that every transaction waits for a lock. Use --hold-ms 0 to remove that deliberate delay.

For a remote universe with TLS:

				
					./yb_multi_app_simulator.sh \
  --nodes "east1-db.example.com east2-db.example.com west2-db.example.com" \
  --port 5433 \
  --db yugabyte \
  --user demo_user \
  --certs /path/to/root.crt \
  --sslmode verify-full \
  --txns 25 \
  --retries 5
				
			

Use endpoint names that match the server certificate. Supply credentials through .pgpass, PGPASSFILE, or PGPASSWORD; workers do not prompt interactively. Client-certificate authentication is supported with --sslcert and --sslkey.

Without TLS options, the script defaults to sslmode=disable for the loopback demo. Supplying --certs defaults the mode to verify-full.

Every configuration option also has a YB_DEMO_* environment variable. Command-line options take precedence:

OptionEnvironment Variable
--nodesYB_DEMO_NODES
--portYB_DEMO_PORT
--dbYB_DEMO_DB
--userYB_DEMO_USER
--txnsYB_DEMO_TXNS
--retriesYB_DEMO_RETRIES
--certsYB_DEMO_CERTS
--sslmodeYB_DEMO_SSLMODE
--sslcertYB_DEMO_SSLCERT
--sslkeyYB_DEMO_SSLKEY
--hold-msYB_DEMO_HOLD_MS
--lock-timeout-msYB_DEMO_LOCK_TIMEOUT_MS
--ysqlshYB_DEMO_YSQLSH

The script sets connection parameters explicitly and ignores unrelated libpq service and PGOPTIONS defaults. Authentication settings are intentionally inherited.

Know the Expected Results Before Running

For A workers and N keys, a successful run should produce:

Measurement Expected 3 Workers, 10 Keys
Committed worker transactionsA × N30
INSERTsN10
UPDATEs(A − 1) × N20
DELETEsN10
PK collision events in committed transactions(A − 1) × N20
Remaining target rows for this run00

Why 20 UPDATEs, rather than ten? The last worker also increments the counter before deleting the row. That transaction performs both an UPDATE and a DELETE. Thirty transactions therefore execute forty successful target-table DML operations.

These expectations apply to one worker, too: each key is inserted and then deleted in the same transaction.

Inspect the Evidence

The script prints operation totals, results by worker and server region, the event history for the first three keys, and any client-visible attempt errors.

The following output was captured from a three-worker, ten-key run on the example YugabyteDB cluster. Its run UUID was b3bf1e3d-4b9c-4a9c-b849-74bab12e5879:

				
					. operation   | committed_events
--------------+------------------
 DELETE       |               10
 INSERT       |               10
 PK_COLLISION |               20
 UPDATE       |               20

PASS: 30 worker transactions committed; every key reached 3 visits and was deleted.
Expected/verified: 10 INSERTs, 20 UPDATEs, 10 DELETEs, 20 PK collisions, 0 remaining rows.
				
			

The run completed in five seconds with zero failed workers. Its worker summary was:

				
					.app_id | node_endpoint | server_region | inserts | updates | deletes | pk_collisions
--------+---------------+---------------+---------+---------+---------+---------------
      1 | 127.0.0.1     | us-east-1     |      10 |       0 |       0 |             0
      2 | 127.0.0.2     | us-east-2     |       0 |      10 |      10 |            10
      3 | 127.0.0.3     | us-west-2     |       0 |      10 |       0 |            10
				
			

For all ten keys in this run, App 1 inserted the row, App 3 performed the second visit, and App 2 performed the final UPDATE and DELETE. The completion messages also appeared in the order App 1, App 3, App 2. Other runs can distribute those roles differently.

Here is the captured history for key 1:

				
					.target_id | step | event_no | app_id | server_region |  operation   |          event_time
-----------+------+----------+--------+---------------+--------------+-------------------------------
         1 |    1 |        2 |      1 | us-east-1     | INSERT       | 2026-10-26 11:57:16.754423+00
         1 |    2 |        1 |      3 | us-west-2     | PK_COLLISION | 2026-10-26 11:57:17.348216+00
         1 |    2 |        2 |      3 | us-west-2     | UPDATE       | 2026-10-26 11:57:17.355938+00
         1 |    3 |        1 |      2 | us-east-2     | PK_COLLISION | 2026-10-26 11:57:17.564669+00
         1 |    3 |        2 |      2 | us-east-2     | UPDATE       | 2026-10-26 11:57:17.572114+00
         1 |    3 |        3 |      2 | us-east-2     | DELETE       | 2026-10-26 11:57:17.577593+00
				
			

The final error-table query returned zero rows. The twenty PK collision events were handled inside transactions that successfully committed; they were not twenty failed worker transactions. No client-visible failed transaction attempts were recorded in this run. These logs do not measure lock-wait duration or count internal statement retries.

Inspect one key yourself by connecting with ysqlsh and assigning the run UUID printed by the script:

				
					\set run_id 'paste-the-run-uuid-here'

SELECT target_id, step, event_no, app_id,
       server_region, operation, event_time
FROM yb_multi_app_demo.events
WHERE run_id = :'run_id'::uuid
  AND target_id = 1
ORDER BY step, event_no;
				
			

For three workers, the logical event sequence is:

Step Operations Outcome
1 INSERT Row created with a visit count of 1.
2 PK_COLLISION → UPDATE Duplicate key handled; visit count advances to 2.
3 PK_COLLISION → UPDATE → DELETE Visit count reaches 3; the row is deleted in the same transaction.

The step value comes from the row’s visit count. It describes this key’s progression. The script validates every key’s operation counts, distinct participating workers, and steps… not just global totals. It also checks that each worker completed its requested workload and that no target rows remain.

Success events commit in the same transaction as the target-row changes. Their atomic visibility follows YugabyteDB’s distributed transaction mechanism; see the transactional I/O path.

Compare a Single-Worker Run

Now run one worker through a single endpoint:

				
					./yb_multi_app_simulator.sh --nodes "127.0.0.1" --txns 5
				
			

The captured run UUID was 6a7340fc-73a6-4c89-860e-a8c7c05f36ed. It completed in two seconds with zero failed workers:

				
					.operation | committed_events
-----------+------------------
 DELETE    |                5
 INSERT    |                5

PASS: 5 worker transactions committed; every key reached 1 visits and was deleted.
Expected/verified: 5 INSERTs, 0 UPDATEs, 5 DELETEs, 0 PK collisions, 0 remaining rows.
				
			

With one worker, the initial INSERT already reaches the configured worker count of 1. That same transaction deletes the row, so each key produces an INSERT and a DELETE, with no UPDATE or duplicate-key fallback. The retained event history records both operations even though the target row is gone after commit.

The topology query still lists all three cluster nodes. It reports cluster membership; --nodes "127.0.0.1" controls the worker’s connection endpoint and does not resize the universe or restrict replication to that node.

On the second run, setup also printed notices that the schema and relations already existed. Those are expected from CREATE ... IF NOT EXISTS. The script reused its demo objects and assigned a new run UUID, retaining the earlier run’s data and keeping each run’s checks separate.

Log Retries Where They Can Survive a Rollback

A failed transaction cannot preserve an error row that it writes before rolling back. PL/pgSQL exception blocks also roll back database changes made inside the failed block. See PL/pgSQL exception handling.

The simulator therefore records unsuccessful attempts through a separate connection after the failing client exits. It uses bounded retries with exponential backoff and jitter for 40001, 40P01, and 55P03. --retries 5 permits the initial attempt plus five retries.

Other failures stop that worker. In particular, a connection failure near COMMIT can leave the outcome unknown, so the script stops rather than blindly repeating the transaction. Production applications need an idempotency or outcome-reconciliation strategy for that case.

A PK_COLLISION success event means the application handled the expected duplicate-key condition and committed its fallback work. It does not count lock waits, discarded attempts, or internal database retries. Lock timeouts are reported only when they actually occur; an empty error table is a valid outcome.

Read the Region Summary Carefully

The recorded server_region identifies the YSQL server executing that transaction. It does not establish the physical location of the client process.

Likewise, a worker with more INSERTs is simply the worker that inserted more keys first during this run. Startup timing, scheduling, network paths, and tablet placement can affect the result. Rows can span multiple tablets with different leaders. Use actual tablet metadata to inspect leadership; the worker summary does not identify it.

 

The timestamps need care as well. now() is the transaction-start timestamp; clock_timestamp() records wall-clock time when called. The script uses timestamptz with clock_timestamp() and displays UTC. Neither timestamp is a global commit-order trace. See PostgreSQL date/time functions.

To display retained events in your local time zone:

				
					SET TIME ZONE 'America/Chicago';

SELECT target_id, step, server_region, operation, event_time
FROM yb_multi_app_demo.events
WHERE run_id = :'run_id'::uuid
ORDER BY target_id, step, event_no;
				
			

What a Passing Run Demonstrates

A passing run confirms that, for the requested workload, every worker contributed once to each key, the committed counter progression reached the worker count, and the final transaction deleted each row. The audit data gives you something concrete to inspect after the target rows are gone.

An empty target table alone would not establish that: no successful writes would also leave an empty table.

This is a focused concurrency demonstration. It does not certify every isolation property, test crash recovery, establish WAN latency, or measure production throughput. Its elapsed time includes client process creation, connections, audit writes, and the optional deliberate delay.

For ordinary application upserts, consider INSERT ... ON CONFLICT DO UPDATE. The explicit exception handler here makes the duplicate-key path visible for teaching.

Final Takeaway

Multiple Applications Can Share the Same Rows

Applications connecting through different YugabyteDB endpoints participate in the same transaction system. Use atomic SQL updates, deliberate conflict handling, and checks against committed data to demonstrate the outcome. The useful result is a verified history for every key, backed by a clear PASS or FAIL.

Cleanup

When you no longer need any of the retained demo runs, remove the dedicated schema:

				
					DROP SCHEMA yb_multi_app_demo CASCADE;
				
			

Complete Simulator Script

The script below produced the successful three-worker and single-worker YugabyteDB runs shown above. Bash syntax and client-level success, retry, exhaustion, permanent-error, argument-precedence, and failure-reporting paths were also checked during preparation. The captured cluster runs did not exercise client-visible transaction failures or the retry path.

yb_multi_app_simulator.sh
				
					#!/usr/bin/env bash
# YugabyteDB Tips: concurrent applications, shared keys, checked outcomes.
set -euo pipefail

NODES=${YB_DEMO_NODES:-"127.0.0.1 127.0.0.2 127.0.0.3"}
DB=${YB_DEMO_DB:-yugabyte}
DB_USER=${YB_DEMO_USER:-yugabyte}
PORT=${YB_DEMO_PORT:-5433}
NUM_TXNS=${YB_DEMO_TXNS:-10}
MAX_RETRIES=${YB_DEMO_RETRIES:-5}
CERTS=${YB_DEMO_CERTS:-}
SSL_MODE=${YB_DEMO_SSLMODE:-}
SSL_CERT=${YB_DEMO_SSLCERT:-}
SSL_KEY=${YB_DEMO_SSLKEY:-}
HOLD_MS=${YB_DEMO_HOLD_MS:-100}
LOCK_MS=${YB_DEMO_LOCK_TIMEOUT_MS:-5000}
CLIENT=${YB_DEMO_YSQLSH:-ysqlsh}

die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
usage() {
    cat <<'HELP'
Usage: yb_multi_app_simulator.sh [options]
  --nodes "host1 host2 ..."  One worker per endpoint; all in ONE universe
  --port N                  YSQL listener port (5433)
  --db NAME                 Database (yugabyte)
  --user NAME               Database role (yugabyte)
  --txns N                  Logical transactions per worker (10)
  --retries N               Extra attempts after a known aborted transaction (5)
  --certs FILE              Trusted CA file; defaults sslmode to verify-full
  --sslmode MODE            disable, require, verify-ca, or verify-full
  --sslcert FILE            Optional client certificate
  --sslkey FILE             Optional client private key
  --hold-ms N               Delay before commit to encourage overlap (100)
  --lock-timeout-ms N       Per-statement lock wait limit (5000)
  --ysqlsh PATH             Client executable (ysqlsh)
  -h, --help                Show help

Options override matching YB_DEMO_* environment variables; see the Tip.
Use .pgpass / PGPASSFILE / PGPASSWORD for authentication; no password prompts.
Without TLS options the local-demo default is sslmode=disable.
All runs use yb_multi_app_demo; each run has a separate UUID. No data is dropped.
HELP
}

while (($#)); do
    case $1 in
        -h|--help) usage; exit 0 ;;
        --nodes|--port|--db|--user|--txns|--retries|--certs|--sslmode|--sslcert|--sslkey|--hold-ms|--lock-timeout-ms|--ysqlsh)
            (($# >= 2)) && [[ -n $2 && $2 != --* ]] || die "Missing value for $1"
            case $1 in
                --nodes) NODES=$2 ;; --port) PORT=$2 ;; --db) DB=$2 ;;
                --user) DB_USER=$2 ;; --txns) NUM_TXNS=$2 ;;
                --retries) MAX_RETRIES=$2 ;; --certs) CERTS=$2 ;;
                --sslmode) SSL_MODE=$2 ;; --sslcert) SSL_CERT=$2 ;;
                --sslkey) SSL_KEY=$2 ;; --hold-ms) HOLD_MS=$2 ;;
                --lock-timeout-ms) LOCK_MS=$2 ;; --ysqlsh) CLIENT=$2 ;;
            esac
            shift 2 ;;
        *) die "Unknown option: $1" ;;
    esac
done
for value in "$PORT" "$NUM_TXNS" "$MAX_RETRIES" "$HOLD_MS" "$LOCK_MS"; do
    [[ $value =~ ^(0|[1-9][0-9]{0,8})$ ]] || die "Expected a nonnegative decimal integer: $value"
done
((PORT > 0 && PORT <= 65535 && NUM_TXNS > 0 && LOCK_MS > 0)) || die "Invalid port, transaction count, or lock timeout"
((MAX_RETRIES <= 20 && HOLD_MS <= 10000 && LOCK_MS <= 20000)) || die "Use retries <= 20, hold-ms <= 10000, lock-timeout-ms <= 20000"
read -r -a NODE_ARRAY <<< "$NODES"
APP_COUNT=${#NODE_ARRAY[@]}
((APP_COUNT > 0)) || die "At least one endpoint is required"
FIRST_NODE=${NODE_ARRAY[0]}
for node in "${NODE_ARRAY[@]}"; do
    [[ $node != -* && $node != *','* ]] || die "Supply one hostname or IP per endpoint"
done
if [[ -z $SSL_MODE ]]; then
    SSL_MODE=disable
    [[ -z $CERTS ]] || SSL_MODE=verify-full
fi
case $SSL_MODE in disable|require|verify-ca|verify-full) ;; *) die "Invalid sslmode: $SSL_MODE" ;; esac
for cert_file in "$CERTS" "$SSL_CERT" "$SSL_KEY"; do
    [[ -z $cert_file || -r $cert_file ]] || die "Cannot read certificate/key: $cert_file"
done
[[ -z $SSL_CERT && -z $SSL_KEY || -n $SSL_CERT && -n $SSL_KEY ]] || die "Provide both --sslcert and --sslkey"
command -v "$CLIENT" >/dev/null || die "Client not found: $CLIENT"

# Explicit connection settings override unrelated shell libpq defaults.
# Password / passfile settings are intentionally inherited.
sql() {
    local node=$1; shift
    env -u PGHOSTADDR -u PGSERVICE -u PGSERVICEFILE -u PGOPTIONS \
        PGSSLMODE="$SSL_MODE" PGSSLROOTCERT="$CERTS" \
        PGSSLCERT="$SSL_CERT" PGSSLKEY="$SSL_KEY" PGCONNECT_TIMEOUT=10 \
        PGAPPNAME=yb_multi_app_simulator PGTZ=UTC \
        "$CLIENT" -X -w -q -h "$node" -p "$PORT" -U "$DB_USER" -d "$DB" \
        -v ON_ERROR_STOP=1 -v VERBOSITY=verbose -P pager=off "$@"
}

printf 'YugabyteDB Multi-App Concurrency Simulator\n'
printf 'Workers: %s | Transactions per worker: %s | Hold: %s ms\n' "$APP_COUNT" "$NUM_TXNS" "$HOLD_MS"
sql "$FIRST_NODE" -c 'SELECT host, cloud, region, zone FROM yb_servers() ORDER BY host;'
for node in "${NODE_ARRAY[@]}"; do
    sql "$node" -c "SELECT version(), yb_server_region() AS server_region;" >/dev/null
done

# The narrow exception handler catches only the intended target-row collision.
sql "$FIRST_NODE" <<'SQL'
CREATE SCHEMA IF NOT EXISTS yb_multi_app_demo;
CREATE TABLE IF NOT EXISTS yb_multi_app_demo.target_rows (
    run_id uuid NOT NULL, id int NOT NULL,
    visit_count int NOT NULL CHECK (visit_count > 0),
    PRIMARY KEY ((run_id, id) HASH)
);
CREATE TABLE IF NOT EXISTS yb_multi_app_demo.events (
    run_id uuid NOT NULL, target_id int NOT NULL,
    step int NOT NULL, event_no int NOT NULL,
    app_id int NOT NULL, node_endpoint text NOT NULL,
    server_region text NOT NULL, operation text NOT NULL,
    event_time timestamptz NOT NULL DEFAULT clock_timestamp(),
    isolation_level text NOT NULL DEFAULT current_setting('transaction_isolation'),
    PRIMARY KEY ((run_id, target_id) HASH, step ASC, event_no ASC)
);
CREATE TABLE IF NOT EXISTS yb_multi_app_demo.attempt_errors (
    run_id uuid NOT NULL, app_id int NOT NULL, target_id int NOT NULL,
    attempt int NOT NULL, node_endpoint text NOT NULL,
    server_region text NOT NULL DEFAULT yb_server_region(),
    event_time timestamptz NOT NULL DEFAULT clock_timestamp(),
    sqlstate text NOT NULL, status text NOT NULL, detail text NOT NULL,
    PRIMARY KEY ((run_id, app_id) HASH, target_id ASC, attempt ASC)
);
CREATE OR REPLACE FUNCTION yb_multi_app_demo.apply_one(
    p_run uuid, p_app int, p_node text, p_id int, p_apps int, p_hold int
) RETURNS void LANGUAGE plpgsql AS $$
DECLARE
    v_count int := 1;
    v_collision boolean := false;
    v_region text := yb_server_region();
BEGIN
    BEGIN
        INSERT INTO yb_multi_app_demo.target_rows VALUES (p_run, p_id, 1);
    EXCEPTION WHEN unique_violation THEN
        v_collision := true;
    END;

    IF v_collision THEN
        UPDATE yb_multi_app_demo.target_rows
        SET visit_count = visit_count + 1
        WHERE run_id = p_run AND id = p_id
        RETURNING visit_count INTO v_count;
        IF NOT FOUND THEN
            RAISE EXCEPTION 'Expected target row is missing' USING ERRCODE = 'P0001';
        END IF;
        INSERT INTO yb_multi_app_demo.events
            (run_id, target_id, step, event_no, app_id, node_endpoint, server_region, operation)
        VALUES (p_run, p_id, v_count, 1, p_app, p_node, v_region, 'PK_COLLISION');
    END IF;

    INSERT INTO yb_multi_app_demo.events
        (run_id, target_id, step, event_no, app_id, node_endpoint, server_region, operation)
    VALUES (p_run, p_id, v_count, 2, p_app, p_node, v_region,
            CASE WHEN v_collision THEN 'UPDATE' ELSE 'INSERT' END);

    IF v_count = p_apps THEN
        DELETE FROM yb_multi_app_demo.target_rows WHERE run_id = p_run AND id = p_id;
        INSERT INTO yb_multi_app_demo.events
            (run_id, target_id, step, event_no, app_id, node_endpoint, server_region, operation)
        VALUES (p_run, p_id, v_count, 3, p_app, p_node, v_region, 'DELETE');
    ELSIF v_count > p_apps THEN
        RAISE EXCEPTION 'Visit count exceeded worker count';
    END IF;
    PERFORM pg_sleep(p_hold / 1000.0);
END;
$$;
SQL

RUN_ID=$(sql "$FIRST_NODE" -Atc 'SELECT gen_random_uuid();')
printf 'Run ID: %s\n' "$RUN_ID"
WORK_DIR=$(mktemp -d)
PIDS=()
cleanup() { rm -rf -- "$WORK_DIR"; }
stop_workers() {
    for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done
    wait || true
    exit 130
}
trap cleanup EXIT
trap stop_workers INT TERM

worker() {
    local node=$1 app=$2 row attempt state status delay cap output
    while [[ ! -f $WORK_DIR/start ]]; do sleep 0.02; done
    for ((row=1; row<=NUM_TXNS; row++)); do
        for ((attempt=1; attempt<=MAX_RETRIES+1; attempt++)); do
            if output=$(sql "$node" -v run="$RUN_ID" -v app="$app" -v node="$node" \
                -v row="$row" -v apps="$APP_COUNT" -v hold="$HOLD_MS" -v lock_ms="$LOCK_MS" 2>&1 <<'SQL'
BEGIN ISOLATION LEVEL READ COMMITTED;
SET LOCAL statement_timeout = '30s';
SET LOCAL lock_timeout = :'lock_ms';
SELECT yb_multi_app_demo.apply_one(:'run'::uuid, :app, :'node', :row, :apps, :hold);
COMMIT;
SQL
            ); then
                break
            fi
            state=$(printf '%s\n' "$output" | sed -nE 's/^.*(ERROR|FATAL):[[:space:]]+([A-Z0-9]{5}):.*/\2/p' | head -n 1)
            state=${state:-UNKNOWN}
            status=FAILED
            case $state in
                40001|40P01|55P03) ((attempt > MAX_RETRIES)) || status=RETRY ;;
            esac
            # A fresh connection/transaction keeps this log after the failed attempt rolls back.
            sql "$node" -v run="$RUN_ID" -v app="$app" -v node="$node" -v row="$row" \
                -v attempt="$attempt" -v state="$state" -v status="$status" -v detail="$output" <<'SQL' || {
INSERT INTO yb_multi_app_demo.attempt_errors
    (run_id, app_id, target_id, attempt, node_endpoint, sqlstate, status, detail)
VALUES (:'run'::uuid, :app, :row, :attempt, :'node', :'state', :'status', :'detail');
SQL
                printf 'App %s: could not persist attempt error\n' "$app" >&2
            }
            printf 'App %s key %s attempt %s: %s [%s]\n' "$app" "$row" "$attempt" "$state" "$status" >&2
            if [[ $status != RETRY ]]; then
                printf '%s\n' "$output" >&2
                return 1
            fi
            cap=$((50 * (1 << (attempt < 6 ? attempt - 1 : 5))))
            printf -v delay '%d.%03d' "$((cap / 1000))" "$((cap % 1000 + RANDOM % 100))"
            sleep "$delay"
        done
    done
    printf 'App %s completed %s transactions through %s\n' "$app" "$NUM_TXNS" "$node"
}

START_SECONDS=$SECONDS
for ((app=1; app<=APP_COUNT; app++)); do
    worker "${NODE_ARRAY[app-1]}" "$app" &
    PIDS+=("$!")
done
touch "$WORK_DIR/start"
FAILED_WORKERS=0
for pid in "${PIDS[@]}"; do
    if ! wait "$pid"; then FAILED_WORKERS=$((FAILED_WORKERS + 1)); fi
done
printf 'Elapsed workload time: %s seconds | Failed workers: %s\n' "$((SECONDS - START_SECONDS))" "$FAILED_WORKERS"

sql "$FIRST_NODE" -v run="$RUN_ID" <<'SQL'
SELECT operation, count(*) AS committed_events
FROM yb_multi_app_demo.events WHERE run_id = :'run'::uuid
GROUP BY operation ORDER BY operation;

SELECT app_id, node_endpoint, server_region,
       count(*) FILTER (WHERE operation = 'INSERT') AS inserts,
       count(*) FILTER (WHERE operation = 'UPDATE') AS updates,
       count(*) FILTER (WHERE operation = 'DELETE') AS deletes,
       count(*) FILTER (WHERE operation = 'PK_COLLISION') AS pk_collisions
FROM yb_multi_app_demo.events WHERE run_id = :'run'::uuid
GROUP BY app_id, node_endpoint, server_region ORDER BY app_id, server_region;

SELECT target_id, step, event_no, app_id, server_region, operation, event_time
FROM yb_multi_app_demo.events WHERE run_id = :'run'::uuid AND target_id <= 3
ORDER BY target_id, step, event_no;

SELECT app_id, target_id, attempt, sqlstate, status, event_time
FROM yb_multi_app_demo.attempt_errors WHERE run_id = :'run'::uuid
ORDER BY app_id, target_id, attempt;
SQL

CHECK=$(sql "$FIRST_NODE" -At -v run="$RUN_ID" -v apps="$APP_COUNT" -v n="$NUM_TXNS" <<'SQL'
WITH e AS (
    SELECT * FROM yb_multi_app_demo.events WHERE run_id = :'run'::uuid
), per_key AS (
    SELECT target_id,
        count(*) FILTER (WHERE operation = 'INSERT') AS ins,
        count(*) FILTER (WHERE operation = 'UPDATE') AS upd,
        count(*) FILTER (WHERE operation = 'DELETE') AS del,
        count(*) FILTER (WHERE operation = 'PK_COLLISION') AS pk,
        count(DISTINCT app_id) FILTER (WHERE operation IN ('INSERT','UPDATE')) AS apps,
        count(DISTINCT step) FILTER (WHERE operation IN ('INSERT','UPDATE')) AS steps,
        min(step) AS first_step, max(step) AS last_step
    FROM e GROUP BY target_id
), per_app AS (
    SELECT app_id, count(*) AS completed
    FROM e WHERE operation IN ('INSERT','UPDATE') GROUP BY app_id
)
SELECT CASE WHEN
    (SELECT count(*) FROM per_key) = :n
    AND NOT EXISTS (SELECT 1 FROM per_key WHERE target_id NOT BETWEEN 1 AND :n
        OR ins <> 1 OR upd <> :apps - 1 OR del <> 1 OR pk <> :apps - 1
        OR apps <> :apps OR steps <> :apps OR first_step <> 1 OR last_step <> :apps)
    AND (SELECT count(*) FROM per_app) = :apps
    AND NOT EXISTS (SELECT 1 FROM per_app WHERE app_id NOT BETWEEN 1 AND :apps OR completed <> :n)
    AND NOT EXISTS (SELECT 1 FROM yb_multi_app_demo.target_rows WHERE run_id = :'run'::uuid)
    THEN 'PASS' ELSE 'FAIL' END;
SQL
)
if [[ $CHECK == PASS && $FAILED_WORKERS == 0 ]]; then
    printf 'PASS: %s worker transactions committed; every key reached %s visits and was deleted.\n' \
        "$((APP_COUNT * NUM_TXNS))" "$APP_COUNT"
    printf 'Expected/verified: %s INSERTs, %s UPDATEs, %s DELETEs, %s PK collisions, 0 remaining rows.\n' \
        "$NUM_TXNS" "$(((APP_COUNT - 1) * NUM_TXNS))" "$NUM_TXNS" "$(((APP_COUNT - 1) * NUM_TXNS))"
else
    die "FAIL: worker errors or workload invariants failed; inspect run $RUN_ID"
fi
				
			

Have Fun!

After driving from Pittsburgh to Dallas last week, we’d seen enough orange construction barrels to last a lifetime. Then we met their boss at a rest stop. Apparently, if you leave road construction unfinished long enough, it assembles its own Frankenstein. 😂🚧