{ "assetKey": "a05-f026-enqueue-race", "kind": "terminal", "command": "set -e\nmkdir -p /tmp/probe\nCT=recovery-race-probe\ndocker rm -f \"$CT\" >/dev/null 2>&1 || true\ndocker run -d --name \"$CT\" --network \"container:workmachine\" \\\n -e POSTGRES_PASSWORD=probe -e POSTGRES_USER=probe -e POSTGRES_DB=probe \\\n postgres:16-alpine -p 55432 >/dev/null\nfor i in $(seq 1 40); do\n docker inspect -f '{{.State.Running}}' \"$CT\" | grep -q true || { echo '컨테이너가 뜨지 않았다'; exit 1; }\n docker exec \"$CT\" pg_isready -U probe -p 55432 >/dev/null 2>&1 && break\n sleep 1\ndone\ndocker exec \"$CT\" psql -U probe -p 55432 -d probe -tAc 'select version()' | cut -c1-24\nM=adapter/outbound/persistence-jpa/src/main/resources/db/migration\nfor d in postgresql jpa/core jpa/fileserver; do\n for f in $(ls \"$M/$d\"/V*.sql | sort -V); do\n docker exec -i \"$CT\" psql -U probe -p 55432 -d probe -q -v ON_ERROR_STOP=1 < \"$f\" >/dev/null 2>&1 || true\n done\ndone\n\necho\necho '# 열린 항목이 없는 상태에서 두 연결을 장벽으로 맞춰 동시에 푼다. 여섯 번 반복.'\necho '# 시계 출처만 DB 로 바꿨다. 조건과 열은 저장소 질의 그대로다.'\ncat > /tmp/probe/cp.sh <<'CPSH'\n# 락파일의 한 구성에 실린 좌표 전부를 캐시 경로로 바꾼다. Gradle 실행 없이 재현된다.\nLOCK=${1:-adapter/outbound/persistence-jpa/gradle.lockfile}\nCONF=${2:-postgresqlIntegrationTestRuntimeClasspath}\nCACHE=/root/.gradle/caches/modules-2/files-2.1\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",$CONF,\"*) ;; *) continue ;; esac\n group=${coord%%:*}; rest=${coord#*:}; name=${rest%%:*}; ver=${rest##*:}\n jar=$(find \"$CACHE/$group/$name/$ver\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$LOCK\")\necho \"${cp#:}\"\nCPSH\ncat > /tmp/probe/RecoveryRaceProbe.java <<'JAVA'\nimport java.sql.Connection;\nimport java.sql.DriverManager;\nimport java.sql.ResultSet;\nimport java.sql.Statement;\nimport java.util.UUID;\nimport java.util.concurrent.CountDownLatch;\n\n/** 두 호출자가 동시에 enqueue 를 부르는 상황을 그 구조 그대로 돌린다. */\npublic class RecoveryRaceProbe {\n\n private static final String REFRESH =\n \"update fs_recovery_item set reason_code = ?, attempt = attempt + 1, updated_at = now()\"\n + \" where file_id = ? and status = 'PENDING'\";\n\n private static final String INSERT =\n \"insert into fs_recovery_item (recovery_id, file_id, reason_code, status, attempt,\"\n + \" created_at, updated_at) values (?, ?, ?, 'PENDING', 0, now(), now())\";\n\n public static void main(String[] args) throws Exception {\n int wins = 0;\n int violations = 0;\n int extraRows = 0;\n for (int round = 0; round < 6; round++) {\n runOnce(round);\n }\n }\n\n private static void runOnce(int round) throws Exception {\n String url = \"jdbc:postgresql://127.0.0.1:55432/probe\";\n UUID file = UUID.randomUUID();\n try (Connection setup = DriverManager.getConnection(url, \"probe\", \"probe\");\n Statement s = setup.createStatement()) {\n s.execute(\"delete from fs_recovery_item\");\n s.execute(\"delete from fs_file\");\n s.execute(\"insert into fs_file (file_id, namespace, state, original_name, created_at,\"\n + \" updated_at) values ('\" + file + \"', 'probe', 'CREATED', 'probe.txt', now(), now())\");\n }\n\n var started = new CountDownLatch(2);\n var go = new CountDownLatch(1);\n String[] result = new String[2];\n\n Runnable enqueue = () -> {\n int slot = Integer.parseInt(Thread.currentThread().getName());\n try (Connection db = DriverManager.getConnection(url, \"probe\", \"probe\")) {\n db.setAutoCommit(false);\n started.countDown();\n go.await();\n int updated;\n try (var refresh = db.prepareStatement(REFRESH)) {\n refresh.setString(1, \"reason-\" + slot);\n refresh.setObject(2, file);\n updated = refresh.executeUpdate();\n }\n if (updated == 0) {\n try (var insert = db.prepareStatement(INSERT)) {\n insert.setObject(1, UUID.randomUUID());\n insert.setObject(2, file);\n insert.setString(3, \"reason-\" + slot);\n insert.executeUpdate();\n }\n }\n db.commit();\n result[slot] = updated > 0 ? \"갱신으로 흡수\" : \"삽입 성공\";\n } catch (Exception failure) {\n result[slot] = failure.getClass().getSimpleName() + \": \"\n + String.valueOf(failure.getMessage()).split(\"\\n\")[0];\n }\n };\n\n Thread a = new Thread(enqueue, \"0\");\n Thread b = new Thread(enqueue, \"1\");\n a.start();\n b.start();\n started.await();\n go.countDown();\n a.join();\n b.join();\n\n int rows;\n try (Connection db = DriverManager.getConnection(url, \"probe\", \"probe\");\n Statement s = db.createStatement();\n ResultSet r = s.executeQuery(\"select count(*) from fs_recovery_item\")) {\n r.next();\n rows = r.getInt(1);\n }\n String winner = result[0].startsWith(\"삽입\") ? \"A\" : \"B\";\n String loser = winner.equals(\"A\") ? result[1] : result[0];\n System.out.printf(\"%d 회차: 이긴 쪽 %s · 진 쪽 %s · 남은 행 %d%n\",\n round + 1, winner, loser.replaceFirst(\"^PSQLException: ERROR: \", \"\"), rows);\n }\n}\nJAVA\nCP=\"$(bash /tmp/probe/cp.sh adapter/outbound/persistence-jpa/gradle.lockfile postgresqlIntegrationTestRuntimeClasspath)\"\njavac -encoding UTF-8 -nowarn -cp \"$CP\" -d /tmp/probe /tmp/probe/RecoveryRaceProbe.java\njava -Dstdout.encoding=UTF-8 -cp \"$CP:/tmp/probe\" RecoveryRaceProbe\n\ndocker rm -f \"$CT\" >/dev/null 2>&1 || true", "cwd": "/shared/codebase/clean-architecture-backend-template/src", "exitCode": 0, "executedAt": "2026-09-02T05:03:15+00:00", "sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "raw": "evidence/raw/a05-f026-enqueue-race.txt", "svg": "evidence/rendered/a05-f026-enqueue-race.svg", "rawSha256": "34e5cc0ec2bac51e03ab43d12e09fb18cda5ccfc3b9bf004adbe6930421524bb", "lines": 10, "redaction": "none — 탐침 데이터만, 자격증명 없음" }