From dd1892192d7890d3d2110eae183da8201345fa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 16:43:45 +0900 Subject: [PATCH 1/9] test: verify OpenProxy routing and session contract (#354) --- backend/build.gradle | 23 +- .../opensql/OpenSqlOpenProxyContractTest.java | 232 +++++++++++ .../opensql-contract-evidence/node1.json | 55 +++ .../opensql-contract-evidence/node2.json | 78 ++++ .../opensql-contract-evidence/node3.json | 78 ++++ .../prepared-cache-delta.json | 42 ++ .../proxy-a-admin.json | 51 +++ .../proxy-b-admin.json | 58 +++ ...proxy-routing-session-contract-20260926.md | 61 +++ scripts/opensql/capture_ha_contract.py | 374 ++++++++++++++++++ scripts/opensql/test_capture_ha_contract.py | 166 ++++++++ 11 files changed, 1217 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java create mode 100644 docs/test-results/opensql-contract-evidence/node1.json create mode 100644 docs/test-results/opensql-contract-evidence/node2.json create mode 100644 docs/test-results/opensql-contract-evidence/node3.json create mode 100644 docs/test-results/opensql-contract-evidence/prepared-cache-delta.json create mode 100644 docs/test-results/opensql-contract-evidence/proxy-a-admin.json create mode 100644 docs/test-results/opensql-contract-evidence/proxy-b-admin.json create mode 100644 docs/test-results/opensql-openproxy-routing-session-contract-20260926.md create mode 100644 scripts/opensql/capture_ha_contract.py create mode 100644 scripts/opensql/test_capture_ha_contract.py diff --git a/backend/build.gradle b/backend/build.gradle index 085dff9..bc477c0 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -61,7 +61,7 @@ dependencies { tasks.named('test') { useJUnitPlatform { - excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'opensql-ha-connection' + excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'opensql-ha-connection', 'opensql-ha-contract' } } @@ -512,3 +512,24 @@ tasks.register('openSqlHaConnectionTest', Test) { } } } + +tasks.register('openSqlOpenProxyContractTest', Test) { + group = 'verification' + description = '실제 OpenProxy 두 대의 라우팅, prepared statement, 시간대 계약을 검증합니다.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'opensql-ha-contract' + } + maxParallelForks = 1 + outputs.upToDateWhen { false } + doFirst { + // 1. 외부 환경이 빠졌을 때 로컬 기본 DB에 잘못 연결해 통과하는 것을 막는다. + ['OPENSQL_PROXY_A_JDBC_URL', 'OPENSQL_PROXY_B_JDBC_URL', + 'OPENSQL_APP_USER', 'OPENSQL_APP_PASSWORD'].each { name -> + if (!System.getenv(name)?.trim()) { + throw new GradleException("필수 OpenProxy 계약 환경 변수가 비어 있습니다: ${name}") + } + } + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java b/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java new file mode 100644 index 0000000..70f59bf --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java @@ -0,0 +1,232 @@ +package com.opensource.docgrid.opensql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Set; +import java.util.HashSet; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.postgresql.PGStatement; + +/** + * 실제 두 OpenProxy에서 트랜잭션 라우팅, pgJDBC 프로토콜, 시간대 경계를 확인한다. + * + *

외부 3노드 전용 수동 시험이다. 설정·스키마를 바꾸지 않으며 역할과 backend 개수만 출력한다. + * SQL이 어느 물리 노드에 갔는지는 별도 프록시·DB 통계와 교차 확인한다. + */ +@Tag("opensql-ha-contract") +@DisplayName("OpenProxy 라우팅·세션 계약") +class OpenSqlOpenProxyContractTest { + + private static final String[] PROXIES = {"OPENSQL_PROXY_A_JDBC_URL", "OPENSQL_PROXY_B_JDBC_URL"}; + + @Test + @Timeout(60) + @DisplayName("자동 커밋 조회와 명시적 읽기·쓰기 트랜잭션의 DB 역할을 구분한다") + void routesQueriesByTransactionContract() throws SQLException { + for (String proxy : PROXIES) { + // 1. 자동 커밋 조회의 실제 결과는 기록한다. 읽기 분산을 미리 가정하지 않는다. + try (Connection connection = connect(proxy, null)) { + report(proxy, "autocommit-select", isStandby(connection)); + } + + // 2. SELECT 다음 UPDATE를 같은 명시적 트랜잭션에서 실행해 primary 고정을 확인한다. + try (Connection connection = connect(proxy, null)) { + connection.setAutoCommit(false); + try { + boolean standby = isStandby(connection); + report(proxy, "read-write-transaction", standby); + assertThat(standby).isFalse(); + try (PreparedStatement update = connection.prepareStatement( + "UPDATE documents SET id = id WHERE id = ?")) { + update.setLong(1, -1L); + assertThat(update.executeUpdate()).isZero(); + } + } finally { + connection.rollback(); + } + } + + // 3. read-only 트랜잭션은 primary를 보장한다고 가정하지 않고 실제 역할을 기록한다. + try (Connection connection = connect(proxy, null)) { + connection.setReadOnly(true); + connection.setAutoCommit(false); + try { + boolean standby = isStandby(connection); + report(proxy, "read-only-transaction", standby); + } finally { + connection.rollback(); + } + } + } + } + + @Test + @Timeout(60) + @DisplayName("Worker의 FOR UPDATE SKIP LOCKED 조회는 standby에서 거부되지 않는다") + void workerLockingSelectUsesPrimary() throws SQLException { + for (String proxy : PROXIES) { + try (Connection connection = connect(proxy, null)) { + // 1. 실제 Worker가 사용하는 세 테이블에서 빈 결과만 잠금 조회한다. + for (String table : new String[] {"embedding_jobs", "sync_outbox_events", "rag_responses"}) { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT id FROM " + table + " WHERE 1 = 0 " + + "ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")) { + assertThat(result.next()).isFalse(); + } + // 2. standby는 FOR UPDATE를 실행할 수 없으므로 성공 자체가 primary 라우팅 증거다. + System.out.printf("CONTRACT_LOCK proxy=%s table=%s route=primary%n", alias(proxy), table); + } + } + } + } + + @Test + @Timeout(90) + @DisplayName("기본·강제 server-side prepared statement를 여러 트랜잭션에서 반복한다") + void preparedStatementsSurviveTransactionPooling() throws SQLException { + for (String proxy : PROXIES) { + for (int threshold : new int[] {5, 1}) { + // 1. 같은 물리 JDBC 연결과 PreparedStatement를 유지한 채 트랜잭션만 바꾼다. + try (Connection connection = connect(proxy, threshold)) { + connection.setAutoCommit(false); + Set backends = new HashSet<>(); + long preparedOnLastBackend = 0; + try (PreparedStatement statement = connection.prepareStatement("SELECT ?::integer")) { + PGStatement pgStatement = statement.unwrap(PGStatement.class); + assertThat(pgStatement.getPrepareThreshold()).isEqualTo(threshold); + for (int value = 1; value <= 15; value++) { + statement.setInt(1, value); + try (ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + assertThat(result.getInt(1)).isEqualTo(value); + } + backends.add(backendIdentity(connection)); + if (value == 15) { + // 2. 같은 트랜잭션에 묶어 pg_prepared_statements를 같은 backend에서 읽는다. + preparedOnLastBackend = queryLong(connection, + "SELECT count(*) FROM pg_prepared_statements"); + } + connection.commit(); + } + // 3. 드라이버가 server-side prepare 단계에 들어갔는지 확인한다. + assertThat(pgStatement.isUseServerPrepare()).isTrue(); + } finally { + connection.rollback(); + } + System.out.printf("CONTRACT_PREPARED proxy=%s threshold=%d executions=15 " + + "distinct_backends=%d prepared_on_last_backend=%d%n", + alias(proxy), threshold, backends.size(), preparedOnLastBackend); + } + } + } + } + + @Test + @Timeout(60) + @DisplayName("timestamp와 timestamptz의 한국 시간 자정 경계를 보존한다") + void timeZoneBoundaryIsStableAcrossProxies() throws SQLException { + LocalDateTime wallClock = LocalDateTime.of(2026, 9, 27, 0, 0, 1); + OffsetDateTime instant = wallClock.atOffset(ZoneOffset.ofHours(9)); + String baseline = null; + for (String proxy : PROXIES) { + try (Connection connection = connect(proxy, null)) { + // 1. timestamp는 벽시계 시간을, timestamptz는 같은 순간을 돌려줘야 한다. + String timeZone = queryString(connection, "SHOW TimeZone"); + if (baseline == null) { + baseline = timeZone; + } else { + assertThat(timeZone).isEqualTo(baseline); + } + try (PreparedStatement statement = connection.prepareStatement( + "SELECT ?::timestamp, ?::timestamptz")) { + statement.setObject(1, wallClock); + statement.setObject(2, instant); + try (ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + assertThat(result.getObject(1, LocalDateTime.class)).isEqualTo(wallClock); + assertThat(result.getObject(2, OffsetDateTime.class).toInstant()) + .isEqualTo(instant.toInstant()); + } + } + + // 2. SET LOCAL은 트랜잭션 밖 다음 요청으로 새지 않아야 한다. + connection.setAutoCommit(false); + try (Statement statement = connection.createStatement()) { + statement.execute("SET LOCAL TIME ZONE 'Pacific/Honolulu'"); + assertThat(queryString(connection, "SHOW TimeZone")).isEqualTo("Pacific/Honolulu"); + } finally { + connection.rollback(); + } + connection.setAutoCommit(true); + assertThat(queryString(connection, "SHOW TimeZone")).isEqualTo(timeZone); + System.out.printf("CONTRACT_TIMEZONE proxy=%s setting=%s boundary=pass%n", + alias(proxy), timeZone); + } + } + } + + private Connection connect(String name, Integer threshold) throws SQLException { + String url = System.getenv(name); + if (threshold != null) { + url += (url.contains("?") ? "&" : "?") + "prepareThreshold=" + threshold; + } + return DriverManager.getConnection(url, System.getenv("OPENSQL_APP_USER"), + System.getenv("OPENSQL_APP_PASSWORD")); + } + + private boolean isStandby(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT pg_is_in_recovery()")) { + assertThat(result.next()).isTrue(); + return result.getBoolean(1); + } + } + + private String backendIdentity(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT pg_postmaster_start_time()::text || ':' || pg_backend_pid()")) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } + + private long queryLong(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getLong(1); + } + } + + private String queryString(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } + + private void report(String proxy, String operation, boolean standby) { + System.out.printf("CONTRACT_ROUTE proxy=%s operation=%s role=%s%n", + alias(proxy), operation, standby ? "standby" : "primary"); + } + + private String alias(String proxy) { + return proxy.equals(PROXIES[0]) ? "proxy-a" : "proxy-b"; + } +} diff --git a/docs/test-results/opensql-contract-evidence/node1.json b/docs/test-results/opensql-contract-evidence/node1.json new file mode 100644 index 0000000..e8c53f2 --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/node1.json @@ -0,0 +1,55 @@ +{ + "node": "node1", + "os": { + "architecture": "x86_64", + "id": "rocky", + "version_id": "9.7" + }, + "patroni_dynamic": { + "check_timeline": null, + "failsafe_mode": true, + "loop_wait": 10, + "maximum_lag_on_failover": 1048576, + "primary_start_timeout": null, + "primary_stop_timeout": null, + "retry_timeout": 10, + "synchronous_mode": null, + "synchronous_mode_strict": null, + "ttl": 30 + }, + "patroni_members": [ + { + "node": "node1", + "role": "Leader", + "state": "running" + }, + { + "node": "node2", + "role": "Replica", + "state": "streaming" + }, + { + "node": "node3", + "role": "Replica", + "state": "streaming" + } + ], + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": null, + "openproxy_revision": null, + "patroni": "patroni 4.0.5", + "psql_client": "psql (PostgreSQL) 17.8" + }, + "runtime": { + "container_restart_policy": "unless-stopped", + "host_bootstrap_unit": { + "ActiveState": "inactive", + "Restart": "on-failure", + "RestartUSec": "15s" + }, + "openproxy_live_process_count": 0, + "openproxy_parent_is_container_pid1": null + } +} diff --git a/docs/test-results/opensql-contract-evidence/node2.json b/docs/test-results/opensql-contract-evidence/node2.json new file mode 100644 index 0000000..d56190e --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/node2.json @@ -0,0 +1,78 @@ +{ + "node": "node2", + "openproxy": { + "general": { + "connect_timeout": 10000, + "prepared_statements_cache_size": 1000 + }, + "pools.docgrid": { + "default_role": "primary", + "pool_mode": "transaction", + "query_parser_enabled": true, + "query_parser_read_write_splitting": true + }, + "pools.docgrid.shards.0": { + "patroni_port": "8008", + "use_patroni": true + }, + "pools.docgrid.users.0": { + "pool_size": 5 + } + }, + "openproxy_service_template": { + "Restart": "always", + "RestartSec": "1" + }, + "os": { + "architecture": "x86_64", + "id": "rocky", + "version_id": "9.7" + }, + "patroni_dynamic": { + "check_timeline": null, + "failsafe_mode": true, + "loop_wait": 10, + "maximum_lag_on_failover": 1048576, + "primary_start_timeout": null, + "primary_stop_timeout": null, + "retry_timeout": 10, + "synchronous_mode": null, + "synchronous_mode_strict": null, + "ttl": 30 + }, + "patroni_members": [ + { + "node": "node1", + "role": "Leader", + "state": "running" + }, + { + "node": "node2", + "role": "Replica", + "state": "streaming" + }, + { + "node": "node3", + "role": "Replica", + "state": "streaming" + } + ], + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": "openproxy 1.1.3", + "openproxy_revision": "revision number: 723", + "patroni": "patroni 4.0.5", + "psql_client": "psql (PostgreSQL) 17.8" + }, + "runtime": { + "container_restart_policy": "unless-stopped", + "host_bootstrap_unit": { + "ActiveState": "inactive", + "Restart": "on-failure", + "RestartUSec": "15s" + }, + "openproxy_live_process_count": 1, + "openproxy_parent_is_container_pid1": true + } +} diff --git a/docs/test-results/opensql-contract-evidence/node3.json b/docs/test-results/opensql-contract-evidence/node3.json new file mode 100644 index 0000000..da62263 --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/node3.json @@ -0,0 +1,78 @@ +{ + "node": "node3", + "openproxy": { + "general": { + "connect_timeout": 10000, + "prepared_statements_cache_size": 1000 + }, + "pools.docgrid": { + "default_role": "primary", + "pool_mode": "transaction", + "query_parser_enabled": true, + "query_parser_read_write_splitting": true + }, + "pools.docgrid.shards.0": { + "patroni_port": "8008", + "use_patroni": true + }, + "pools.docgrid.users.0": { + "pool_size": 5 + } + }, + "openproxy_service_template": { + "Restart": "always", + "RestartSec": "1" + }, + "os": { + "architecture": "x86_64", + "id": "rocky", + "version_id": "9.7" + }, + "patroni_dynamic": { + "check_timeline": null, + "failsafe_mode": true, + "loop_wait": 10, + "maximum_lag_on_failover": 1048576, + "primary_start_timeout": null, + "primary_stop_timeout": null, + "retry_timeout": 10, + "synchronous_mode": null, + "synchronous_mode_strict": null, + "ttl": 30 + }, + "patroni_members": [ + { + "node": "node1", + "role": "Leader", + "state": "running" + }, + { + "node": "node2", + "role": "Replica", + "state": "streaming" + }, + { + "node": "node3", + "role": "Replica", + "state": "streaming" + } + ], + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": "openproxy 1.1.3", + "openproxy_revision": "revision number: 723", + "patroni": "patroni 4.0.5", + "psql_client": "psql (PostgreSQL) 17.8" + }, + "runtime": { + "container_restart_policy": "unless-stopped", + "host_bootstrap_unit": { + "ActiveState": "inactive", + "Restart": "on-failure", + "RestartUSec": "15s" + }, + "openproxy_live_process_count": 1, + "openproxy_parent_is_container_pid1": true + } +} diff --git a/docs/test-results/opensql-contract-evidence/prepared-cache-delta.json b/docs/test-results/opensql-contract-evidence/prepared-cache-delta.json new file mode 100644 index 0000000..37a25ab --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/prepared-cache-delta.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "experiment": "prepared-statement-repeat", + "executions_per_proxy": 30, + "notes": "Counters are cumulative snapshots, not isolated per-query traces.", + "proxies": { + "proxy-a": { + "before": { + "hit": 526, + "miss": 212, + "eviction": 0 + }, + "after": { + "hit": 750, + "miss": 292, + "eviction": 0 + }, + "delta": { + "hit": 224, + "miss": 80, + "eviction": 0 + } + }, + "proxy-b": { + "before": { + "hit": 514, + "miss": 204, + "eviction": 0 + }, + "after": { + "hit": 738, + "miss": 284, + "eviction": 0 + }, + "delta": { + "hit": 224, + "miss": 80, + "eviction": 0 + } + } + } +} diff --git a/docs/test-results/opensql-contract-evidence/proxy-a-admin.json b/docs/test-results/opensql-contract-evidence/proxy-a-admin.json new file mode 100644 index 0000000..1feb2d1 --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/proxy-a-admin.json @@ -0,0 +1,51 @@ +{ + "effective_config": { + "connect_timeout": 10000, + "pools.docgrid.default_role": "primary", + "pools.docgrid.load_balancing_mode": "Random", + "pools.docgrid.pool_mode": "Transaction", + "pools.docgrid.prepared_statements_cache_size": 0, + "pools.docgrid.primary_reads_enabled": false, + "pools.docgrid.query_parser_enabled": true, + "pools.docgrid.query_parser_read_write_splitting": true, + "shutdown_timeout": 60000 + }, + "node": "node2", + "servers": [ + { + "prepare_cache_eviction": 0, + "prepare_cache_hit": 499, + "prepare_cache_miss": 194, + "prepare_cache_size": 97, + "role": "primary" + }, + { + "prepare_cache_eviction": 0, + "prepare_cache_hit": 27, + "prepare_cache_miss": 18, + "prepare_cache_size": 9, + "role": "replica_1" + } + ], + "stats": [ + { + "errors": 0, + "queries": 378, + "role": "primary", + "transactions": 129 + }, + { + "errors": 0, + "queries": 42, + "role": "replica_0", + "transactions": 30 + }, + { + "errors": 0, + "queries": 47, + "role": "replica_1", + "transactions": 33 + } + ], + "proxy": "proxy-a" +} diff --git a/docs/test-results/opensql-contract-evidence/proxy-b-admin.json b/docs/test-results/opensql-contract-evidence/proxy-b-admin.json new file mode 100644 index 0000000..f803685 --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/proxy-b-admin.json @@ -0,0 +1,58 @@ +{ + "effective_config": { + "connect_timeout": 10000, + "pools.docgrid.default_role": "primary", + "pools.docgrid.load_balancing_mode": "Random", + "pools.docgrid.pool_mode": "Transaction", + "pools.docgrid.prepared_statements_cache_size": 0, + "pools.docgrid.primary_reads_enabled": false, + "pools.docgrid.query_parser_enabled": true, + "pools.docgrid.query_parser_read_write_splitting": true, + "shutdown_timeout": 60000 + }, + "node": "node3", + "servers": [ + { + "prepare_cache_eviction": 0, + "prepare_cache_hit": 490, + "prepare_cache_miss": 188, + "prepare_cache_size": 94, + "role": "primary" + }, + { + "prepare_cache_eviction": 0, + "prepare_cache_hit": 15, + "prepare_cache_miss": 10, + "prepare_cache_size": 5, + "role": "replica_0" + }, + { + "prepare_cache_eviction": 0, + "prepare_cache_hit": 9, + "prepare_cache_miss": 6, + "prepare_cache_size": 3, + "role": "replica_1" + } + ], + "stats": [ + { + "errors": 0, + "queries": 697, + "role": "primary", + "transactions": 265 + }, + { + "errors": 0, + "queries": 119, + "role": "replica_0", + "transactions": 83 + }, + { + "errors": 0, + "queries": 105, + "role": "replica_1", + "transactions": 89 + } + ], + "proxy": "proxy-b" +} diff --git a/docs/test-results/opensql-openproxy-routing-session-contract-20260926.md b/docs/test-results/opensql-openproxy-routing-session-contract-20260926.md new file mode 100644 index 0000000..664c4cc --- /dev/null +++ b/docs/test-results/opensql-openproxy-routing-session-contract-20260926.md @@ -0,0 +1,61 @@ +# OpenSQL 3노드 OpenProxy 라우팅·세션 계약 실측 (2026-09-26) + +## 목적과 판정 범위 + +두 OpenProxy를 거친 실제 SQL의 primary/standby 역할, 트랜잭션 풀에서의 pgJDBC named prepared statement, 시간대 경계, HA 관련 설치·동적 설정을 **운영 데이터를 바꾸지 않고** 확인했다. 이 문서는 장애 전환, 물리 standby별 부하 분산, Hikari/JPA 전체 경로의 무중단성을 합격 처리하지 않는다. 기존 Hikari/JPA 쓰기 검증은 [별도 결과](opensql-openproxy-hikari-troubleshooting-20260924.md)를 참조한다. + +공개 증거는 논리 별칭 `node1~3`, `proxy-a/b`만 사용한다. 프로젝트 ID, IP, 관리자·앱 암호, 라이선스 파일은 포함하지 않는다. 수집 당시 `node1`이 leader, `node2/3`이 streaming replica였고, 프록시는 각각 `node2/3`에서 실행됐다. 세 컨테이너 모두 Rocky Linux 9.7 `x86_64`였다. + +실행 코드 기준은 `origin/develop@98324caeb176`에 이 PR의 테스트·수집기 변경을 더한 상태다. 최종 전체 계약 시험은 2026-09-26 07:37 UTC에 4개 테스트/실패 0건으로 완료됐다. 머신 고유명이 들어 있는 원본 XML은 공개하지 않았다. + +## 재현 절차 + +1. 설치된 각 컨테이너에서 [`capture_ha_contract.py`](../../scripts/opensql/capture_ha_contract.py)의 `collect --node node1|node2|node3`를 실행한다. 이 동작은 설치 TOML의 허용된 키, `patronictl show-config/list`, 버전과 OS만 출력한다. 원본 TOML 전체를 복사하지 않는다. +2. 각 VM 호스트에서 같은 스크립트의 `runtime --node ...`를 실행해 Docker 재시작 정책, 호스트 bootstrap unit, 살아 있는 OpenProxy 프로세스의 부모 PID를 별도로 기록한다. 컨테이너 안의 systemd 템플릿을 실제 감독자로 해석하지 않는다. +3. `node2/3` 컨테이너에서 `admin --node ...`를 실행한다. 수집기는 관리자 암호를 컨테이너 안에서만 읽고 서비스 포트로 `SHOW CONFIG`, `SHOW STATS`, `SHOW SERVERS`를 읽는다. 허용된 설정값과 역할별 숫자만 출력한다. +4. 세 `collect` 결과를 `assemble node1.json node2.json node3.json`으로 대조한다. OS·Patroni 동적 설정·두 프록시의 설치 설정/버전 불일치와 민감 문자열은 실패시킨다. 공개 스냅샷의 canonical SHA-256은 `b9bb7dbd246c53265fe23cadd507e8224b92fc45bda16c836f340c92cac9841c`이다. +5. A/B JDBC URL과 최소 권한 앱 계정 환경 변수를 주입한 뒤 `./backend/gradlew -p backend openSqlOpenProxyContractTest`를 실행한다. 일반 `test`에서는 외부 클러스터 태그를 제외한다. URL의 `connectTimeout=5&socketTimeout=15`는 **이번 수동 시험에만** 적용했고, 앱 운영 URL 변경은 아니다. SSH 터널은 이 시험의 접근 경로일 뿐 장애·성능 측정 경로로 사용하지 않는다. + +수집기의 비밀값 차단·중복 설정 감지·스냅샷 일치성은 `python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 6개 시험/실패 0건을 확인했다. + +공개 원본: [node1](opensql-contract-evidence/node1.json), [node2](opensql-contract-evidence/node2.json), [node3](opensql-contract-evidence/node3.json), [proxy-a 관리값](opensql-contract-evidence/proxy-a-admin.json), [proxy-b 관리값](opensql-contract-evidence/proxy-b-admin.json), [캐시 카운터 전후](opensql-contract-evidence/prepared-cache-delta.json). 원본 Gradle XML은 로컬 빌드 산출물이라 커밋하지 않았다. + +## 설치·실행 계약 + +| 항목 | 실측 결과 | 해석 | +| --- | --- | --- | +| 제품 | OpenProxy 1.1.3 revision 723, Patroni 4.0.5, etcd 3.6.5, PostgreSQL client 17.8 | 두 프록시의 설치 버전 동일 | +| Patroni 동적 설정 | `ttl=30`, `loop_wait=10`, `retry_timeout=10`, `maximum_lag_on_failover=1048576`, `failsafe_mode=true` | `primary_start_timeout`, 동기 복제 모드는 동적 설정에 명시되지 않음. 미설정을 `0`으로 해석하지 않는다. | +| DocGrid 풀 | `pool_mode=transaction`, `default_role=primary`, query parser와 read/write splitting 활성, `primary_reads_enabled=false`, standby 선택 모드 `Random` | A/B 설치 파일과 관리 콘솔의 공통 항목이 일치한다. 물리 standby별 요청 비율을 이번 SQL 시험으로 판정하지는 않는다. | +| 캐시 설정 | 설치 TOML 일반 섹션 `prepared_statements_cache_size=1000`; `SHOW CONFIG`의 **풀 항목**은 `0` | 두 표기가 상충한다. `0`을 “실제 캐시 완전 비활성”이라고 단정하지 않는다. 아래 실측과 공급사 확인이 필요하다. | +| 실행 감독 | 컨테이너 정책 `unless-stopped`; 호스트 bootstrap unit `inactive`; 살아 있는 OpenProxy의 부모는 컨테이너 PID 1 | 패키지에 동봉된 `Restart=always`, `RestartSec=1` systemd **예시 파일**이 현재 프로세스를 감독한다는 증거가 없다. 프로세스 종료 시 자동 재기동을 가정하지 않는다. | + +관리 설정의 `admin_port=6433`은 컨테이너 localhost에서 열려 있지 않았고, 실제 관리 `SHOW` 명령은 서비스 포트 `6432`로 성공했다. 이 차이는 설치 빌드의 관리 포트 의미를 추가 확인할 항목이다. + +## SQL 라우팅 결과 + +| SQL/세션 조건 | proxy-a | proxy-b | 근거·주의 | +| --- | --- | --- | --- | +| 자동 커밋 `SELECT pg_is_in_recovery()` | standby | standby | 각 SQL이 돌려준 DB 역할. 어느 물리 standby였는지는 판정하지 않음. | +| 명시적 트랜잭션의 `SELECT → UPDATE` | primary | primary | `UPDATE ... WHERE id=-1`은 0행이고 트랜잭션을 rollback함. | +| JDBC read-only 트랜잭션의 SELECT | standby | standby | **`@Transactional(readOnly=true)`만으로 primary 일관성을 보장할 수 없다는 반례.** Spring 자체의 실제 라우팅은 후속 앱 통합 시험에서 재확인해야 함. | +| `FOR UPDATE SKIP LOCKED` 빈 결과 조회 | primary 성공 | primary 성공 | Embedding Job·Outbox·RAG 응답 테이블 각각 0행 잠금 조회. standby는 `FOR UPDATE`를 실행할 수 없으므로 성공이 primary 라우팅의 증거다. 실제 Worker claim 트랜잭션 전체를 시험한 것은 아님. | + +따라서 권한 회수 후 최신 역할을 반드시 읽어야 하는 경로에 read-only 어노테이션만 붙이는 수정은 금지한다. 전용 primary 경로 또는 명시적인 라우팅 정책을 별도로 설계·검증해야 한다. OpenProxy가 잘못 동작했다는 결론이 아니라, 애플리케이션의 일관성 경계를 제품 라우팅과 맞춰야 한다는 결론이다. + +## pgJDBC prepared statement와 시간대 + +각 프록시에서 `prepareThreshold=5`와 `1`로 같은 JDBC PreparedStatement를 15회, 트랜잭션을 매번 커밋하며 반복했다. 네 조합 모두 결과값이 정확했고, 드라이버의 server-prepare 플래그가 켜졌으며, 마지막 트랜잭션의 DB `pg_prepared_statements`도 0보다 컸다. `SHOW SERVERS`에는 캐시 hit/miss가 관측됐다. 별도 반복 시험 전후의 합산 카운터는 양 프록시 모두 **hit +224, miss +80, eviction 0**이었다. 이는 캐시 경로가 사용된다는 강한 정황이지만, 카운터는 프록시 전체의 누적값이며 다른 트래픽을 완전히 격리하지 않았다. + +한계가 중요하다. 반복 중 각 JDBC 연결에서 관측한 DB backend는 **1개**였다. 따라서 **서로 다른 backend로 옮겨 간 named statement의 재준비/캐시 적중은 아직 검증하지 않았다.** `SHOW CONFIG` 풀 값 `0`과 일반 TOML `1000`의 의미도 이번 시험만으로 확정하지 않는다. 공급사에 우선 확인하고, 이후 backend 교체를 강제한 회귀 시험을 추가한다. + +양 프록시에서 `SHOW TimeZone=Asia/Seoul`이었고, 한국 시간 자정 직후의 `timestamp` 벽시계값과 `timestamptz` 순간값이 왕복 보존됐다. 트랜잭션 안의 `SET LOCAL TIME ZONE 'Pacific/Honolulu'`는 rollback 뒤 다음 요청에 남지 않았다. 전체 애플리케이션의 날짜 직렬화나 모든 세션 설정을 보증하는 결과는 아니다. + +## 다음 작업의 전제와 미검증 사항 + +- OpenProxy 프로세스 종료·지속 stop·패킷 DROP은 서로 다른 장애다. 현재 실제 supervisor 상태를 기준으로 각각 따로 주입한다. +- Patroni `failsafe_mode=true`이므로 etcd 정족수 상실 시 무조건 쓰기 정지를 기대하지 않는다. `primary_start_timeout`은 동적 설정에 없으므로 기본값·실제 failover 시간을 별도 검증한다. +- 읽기 권한·역할 조회를 primary에 고정할 설계는 이 PR에 포함하지 않는다. standby 읽기 분산이 존재한다는 사실만으로 권한 회수의 즉시성을 주장할 수 없다. +- 이번 실측은 라우팅·세션 계약의 기준선이다. 프록시 A/B 장애 전환, 리더 상실, Worker 멱등 복구, 실제 부하·p95/p99는 후속 시험이다. + +참조: [OpenProxy 설정·관리 콘솔](https://docs.tibero.com/tmaxopensql.en/installation/configuration/openproxy), [OpenProxy 읽기/쓰기 라우팅과 캐시](https://docs.tibero.com/tmaxopensql.en/administration/openproxy/load-balancing), [pgJDBC server-side prepare](https://jdbc.postgresql.org/documentation/server-prepare/), [Patroni 동적 설정](https://patroni.readthedocs.io/en/latest/dynamic_configuration.html). diff --git a/scripts/opensql/capture_ha_contract.py b/scripts/opensql/capture_ha_contract.py new file mode 100644 index 0000000..aaeeb8d --- /dev/null +++ b/scripts/opensql/capture_ha_contract.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Collect only allowlisted OpenSQL HA settings and assemble a public-safe fingerprint.""" + +from __future__ import annotations + +import argparse +import ast +import csv +import hashlib +import io +import json +import os +import platform +import re +import subprocess +import sys +from pathlib import Path + + +NODE = re.compile(r"node[123]\Z") +PRIVATE_VALUE = re.compile(r"(?:\b\d{1,3}(?:\.\d{1,3}){3}\b|@|password|secret|token|license)", re.I) +VERSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._+():-]{0,127}\Z") +PROXY_FIELDS = { + "general": {"prepared_statements_cache_size", "worker_threads", "connect_timeout", + "healthcheck_timeout", "healthcheck_delay", "shutdown_timeout", "ban_time", + "renew_interval"}, + "pools.docgrid": {"pool_mode", "default_role", "query_parser_enabled", + "query_parser_read_write_splitting"}, + "pools.docgrid.users.0": {"pool_size", "statement_timeout"}, + "pools.docgrid.shards.0": {"use_patroni", "patroni_port"}, +} +PATRONI_FIELDS = {"ttl", "loop_wait", "retry_timeout", "primary_start_timeout", + "primary_stop_timeout", "maximum_lag_on_failover", "check_timeline", + "synchronous_mode", "synchronous_mode_strict", "failsafe_mode"} +SERVICE_FIELDS = {"Restart", "RestartSec", "KillSignal", "TimeoutStopSec"} +ADMIN_CONFIG_FIELDS = {"prepared_statements_cache_size", "connect_timeout", + "shutdown_timeout", "pools.docgrid.pool_mode", + "pools.docgrid.default_role", "pools.docgrid.query_parser_enabled", + "pools.docgrid.query_parser_read_write_splitting", + "pools.docgrid.primary_reads_enabled", + "pools.docgrid.load_balancing_mode", + "pools.docgrid.prepared_statements_cache_size"} + + +class ContractError(ValueError): + """Reject ambiguous or potentially identifying evidence before publication.""" + + +def scalar(raw: str): + """Parse only the small TOML/YAML scalar subset used by contract settings.""" + value = raw.split("#", 1)[0].strip() + if value in ("true", "false"): + return value == "true" + if re.fullmatch(r"-?\d+", value): + return int(value) + if value.startswith(('"', "'")): + try: + parsed = ast.literal_eval(value) + except (SyntaxError, ValueError) as error: + raise ContractError("설정 문자열을 해석할 수 없습니다") from error + if isinstance(parsed, str): + return parsed + raise ContractError("허용된 단일 설정값 형식이 아닙니다") + + +def safe_value(value): + """Keep internal addresses, credentials, and paths out of every generated result.""" + if isinstance(value, str) and PRIVATE_VALUE.search(value): + raise ContractError("공개할 수 없는 설정값이 포함되었습니다") + return value + + +def proxy_settings(path: Path): + """Read only named scalar keys; never copy a credential-bearing TOML section.""" + settings = {section: {} for section in PROXY_FIELDS} + section = None + for line in path.read_text(encoding="utf-8").splitlines(): + heading = re.fullmatch(r"\s*\[([A-Za-z0-9_.]+)\]\s*(?:#.*)?", line) + if heading: + section = heading.group(1) + continue + if section not in PROXY_FIELDS: + continue + assignment = re.match(r"\s*([A-Za-z_][A-Za-z_0-9]*)\s*=\s*(.*)", line) + if not assignment or assignment.group(1) not in PROXY_FIELDS[section]: + continue + key = assignment.group(1) + if key in settings[section]: + raise ContractError(f"OpenProxy 설정 중복: {section}.{key}") + settings[section][key] = safe_value(scalar(assignment.group(2))) + if not settings["pools.docgrid"] or not settings["pools.docgrid.shards.0"]: + raise ContractError("DocGrid OpenProxy 풀을 찾지 못했습니다") + return settings + + +def patroni_settings(text: str): + """Extract top-level dynamic keys from patronictl without serializing its full output.""" + settings = {} + for line in text.splitlines(): + match = re.fullmatch(r"([a-z_]+):\s*(.*?)\s*", line) + if not match or match.group(1) not in PATRONI_FIELDS: + continue + key = match.group(1) + if key in settings: + raise ContractError(f"Patroni 동적 설정 중복: {key}") + settings[key] = safe_value(scalar(match.group(2))) + if not settings: + raise ContractError("Patroni 동적 설정을 읽지 못했습니다") + return {key: settings.get(key) for key in sorted(PATRONI_FIELDS)} + + +def service_template(path: Path): + """Label packaged systemd values as a template, not as the active supervisor.""" + if not path.exists(): + return None + values = {} + section = None + for line in path.read_text(encoding="utf-8").splitlines(): + heading = re.fullmatch(r"\s*\[([^]]+)\]\s*", line) + if heading: + section = heading.group(1) + elif section == "Service": + match = re.fullmatch(r"\s*([A-Za-z]+)\s*=\s*([^#\s]+)\s*", line) + if match and match.group(1) in SERVICE_FIELDS: + values[match.group(1)] = safe_value(match.group(2)) + return values + + +def version(binary: Path, *options): + """Report only a short version line; suppress arbitrary command output on failure.""" + if not binary.is_file(): + return None + try: + result = subprocess.run([str(binary), *options], capture_output=True, text=True, + timeout=5, check=True) + except (OSError, subprocess.SubprocessError): + return None + first = result.stdout.splitlines()[0].strip() if result.stdout.splitlines() else "" + return first if VERSION.fullmatch(first) else None + + +def os_release(path: Path): + """Expose only distro identity and release, excluding host-specific metadata.""" + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + match = re.fullmatch(r"(ID|VERSION_ID)=(.*)", line) + if match: + values[match.group(1).lower()] = safe_value(scalar(match.group(2))) + return values + + +def patroni_members(text: str): + """Normalize member names and roles without exposing REST addresses.""" + members = json.loads(text) + if not isinstance(members, list) or len(members) != 3: + raise ContractError("Patroni 멤버 세 개를 확인하지 못했습니다") + normalized = [] + for member in members: + name = member.get("Member") + match = re.fullmatch(r"(?:docgrid-)?node([123])|postgresql([123])", name or "") + if not match: + raise ContractError("Patroni 멤버 이름을 안전한 별칭으로 바꿀 수 없습니다") + role = member.get("Role") + state = member.get("State") + if role not in {"Leader", "Replica", "Sync Standby", "Standby Leader"} or not isinstance(state, str): + raise ContractError("Patroni 역할 또는 상태를 판별할 수 없습니다") + normalized.append({"node": "node" + (match.group(1) or match.group(2)), + "role": role, "state": safe_value(state)}) + if {member["node"] for member in normalized} != {"node1", "node2", "node3"}: + raise ContractError("Patroni 멤버 이름이 중복되거나 누락되었습니다") + return sorted(normalized, key=lambda member: member["node"]) + + +def collect(args): + """Build one read-only node snapshot from installed configuration and binaries.""" + if not NODE.fullmatch(args.node): + raise ContractError("노드 별칭은 node1~node3이어야 합니다") + root = args.install_root + document = { + "schema_version": 1, + "node": args.node, + "os": {**os_release(args.os_release), "architecture": platform.machine()}, + "versions": { + "openproxy": version(root / "bin/openproxy", "--version"), + "openproxy_revision": version(root / "bin/openproxy", "--revision"), + "patroni": version(root / "bin/patroni", "--version"), + "etcd": version(root / "bin/etcd", "--version"), + "psql_client": version(root / "bin/psql", "--version"), + }, + "patroni_dynamic": patroni_settings(subprocess.run( + [str(root / "bin/patronictl"), "-c", str(root / "etc/patroni/patroni.yml"), + "show-config"], capture_output=True, text=True, timeout=10, + check=True).stdout), + "patroni_members": patroni_members(subprocess.run( + [str(root / "bin/patronictl"), "-c", str(root / "etc/patroni/patroni.yml"), + "list", "--format", "json"], capture_output=True, text=True, timeout=10, + check=True).stdout), + } + proxy = root / "etc/openproxy/openproxy.toml" + if proxy.exists(): + document["openproxy"] = proxy_settings(proxy) + document["openproxy_service_template"] = service_template( + root / "etc/openproxy/openproxy.service") + return document + + +def runtime(args): + """Report the active container and host unit, distinct from a packaged unit template.""" + if not NODE.fullmatch(args.node): + raise ContractError("노드 별칭은 node1~node3이어야 합니다") + container = f"docgrid-{args.node}" + inspect = subprocess.run(["sudo", "-n", "docker", "inspect", "--format", + "{{.HostConfig.RestartPolicy.Name}}", container], + capture_output=True, text=True, timeout=5, check=True) + unit = subprocess.run(["systemctl", "show", f"docgrid-opensql@{container}.service", + "-p", "ActiveState", "-p", "Restart", "-p", "RestartUSec"], + capture_output=True, text=True, timeout=5, check=True) + fields = dict(line.split("=", 1) for line in unit.stdout.splitlines() if "=" in line) + processes = subprocess.run(["sudo", "-n", "docker", "exec", container, + "ps", "-eo", "ppid=,stat=,comm="], + capture_output=True, text=True, timeout=5, check=True) + openproxy_parents = [] + for line in processes.stdout.splitlines(): + columns = line.split() + if len(columns) >= 3 and columns[2] == "openproxy" and not columns[1].startswith("Z"): + openproxy_parents.append(columns[0]) + if inspect.stdout.strip() not in {"always", "unless-stopped", "no", "on-failure"}: + raise ContractError("컨테이너 재시작 정책을 판별할 수 없습니다") + return {"node": args.node, "container_restart_policy": inspect.stdout.strip(), + "host_bootstrap_unit": {key: safe_value(fields.get(key)) for key in + ("ActiveState", "Restart", "RestartUSec")}, + "openproxy_live_process_count": len(openproxy_parents), + "openproxy_parent_is_container_pid1": all(parent == "1" for parent in openproxy_parents) + if openproxy_parents else None} + + +def admin_credentials(path: Path): + """Read administrator credentials only in container memory, never in collector output.""" + fields = {} + section = None + for line in path.read_text(encoding="utf-8").splitlines(): + heading = re.fullmatch(r"\s*\[([A-Za-z0-9_.]+)\]\s*(?:#.*)?", line) + if heading: + section = heading.group(1) + continue + if section != "general": + continue + match = re.match(r"\s*(port|admin_port|admin_username|admin_password)\s*=\s*(.*)", line) + if match: + fields[match.group(1)] = scalar(match.group(2)) + if not isinstance(fields.get("port"), int) or not all( + isinstance(fields.get(key), str) for key in ("admin_username", "admin_password")): + raise ContractError("OpenProxy 관리자 접속 정보를 읽지 못했습니다") + return fields + + +def admin_rows(root: Path, credentials: dict, command: str): + """Run a read-only SHOW command; keep psql stdout/stderr and password in memory.""" + environment = os.environ.copy() + environment["PGPASSWORD"] = credentials["admin_password"] + try: + result = subprocess.run( + [str(root / "bin/psql"), "-X", "-w", "--csv", "-P", "footer=off", + "-h", "127.0.0.1", "-p", str(credentials["port"]), + "-d", "openproxy", "-U", credentials["admin_username"], "-c", command], + capture_output=True, text=True, timeout=10, check=True, env=environment) + except subprocess.CalledProcessError as error: + reason = ("authentication" if "authentication failed" in error.stderr.lower() else + "connection" if "connection refused" in error.stderr.lower() else + "psql-command") + raise ContractError(f"{command} 조회 실패 ({reason})") from error + return list(csv.DictReader(io.StringIO(result.stdout))) + + +def admin_snapshot(args): + """Expose only effective contract settings and role-level counters from OpenProxy.""" + if args.node not in {"node2", "node3"}: + raise ContractError("관리 콘솔은 node2 또는 node3에서만 수집합니다") + root = args.install_root + credentials = admin_credentials(root / "etc/openproxy/openproxy.toml") + config = {} + for row in admin_rows(root, credentials, "SHOW CONFIG"): + key = row.get("key", "").strip() + if key in ADMIN_CONFIG_FIELDS: + config[key] = safe_value(scalar(row.get("value", "").strip())) + if not config: + raise ContractError("OpenProxy 실제 설정을 확인하지 못했습니다") + stats = [] + for row in admin_rows(root, credentials, "SHOW STATS"): + if row.get("database") != "docgrid": + continue + instance = row.get("instance", "") + match = re.fullmatch(r"docgrid_shard_\d+_(primary|replica_\d+)", instance) + if not match: + raise ContractError("OpenProxy 통계 역할을 안전하게 정규화할 수 없습니다") + stats.append({"role": match.group(1), "queries": int(row["total_query_count"]), + "transactions": int(row["total_xact_count"]), + "errors": int(row["total_errors"])}) + servers = [] + for row in admin_rows(root, credentials, "SHOW SERVERS"): + if row.get("database_name") != "docgrid": + continue + match = re.fullmatch(r"docgrid_shard_\d+_(primary|replica_\d+)", + row.get("address_id", "")) + if not match: + raise ContractError("OpenProxy 서버 역할을 안전하게 정규화할 수 없습니다") + servers.append({"role": match.group(1), + "prepare_cache_hit": int(row["prepare_cache_hit"]), + "prepare_cache_miss": int(row["prepare_cache_miss"]), + "prepare_cache_eviction": int(row["prepare_cache_eviction"]), + "prepare_cache_size": int(row["prepare_cache_size"])}) + return {"node": args.node, "effective_config": config, + "stats": sorted(stats, key=lambda row: row["role"]), + "servers": sorted(servers, key=lambda row: row["role"])} + + +def assemble(paths): + """Compare contract-critical A/B values and hash a canonical, sanitized snapshot.""" + nodes = [json.loads(path.read_text(encoding="utf-8")) for path in paths] + if {node.get("node") for node in nodes} != {"node1", "node2", "node3"}: + raise ContractError("node1~node3 결과가 각각 하나씩 필요합니다") + for node in nodes: + if node.get("schema_version") != 1 or PRIVATE_VALUE.search(json.dumps(node)): + raise ContractError("스냅샷 형식 또는 공개 가능성을 확인할 수 없습니다") + by_name = {node["node"]: node for node in nodes} + expected_os = {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"} + if any(node.get("os") != expected_os for node in nodes): + raise ContractError("세 노드의 Rocky Linux 9.7 x86_64 구성이 일치하지 않습니다") + if "openproxy" in by_name["node1"]: + raise ContractError("node1에 예상하지 않은 OpenProxy 설정이 있습니다") + if by_name["node2"].get("openproxy") != by_name["node3"].get("openproxy"): + raise ContractError("두 OpenProxy의 계약 관련 설정이 다릅니다") + if by_name["node2"].get("versions", {}).get("openproxy") != by_name["node3"].get("versions", {}).get("openproxy"): + raise ContractError("두 OpenProxy의 설치 버전이 다릅니다") + if len({json.dumps(node.get("patroni_dynamic"), sort_keys=True) for node in nodes}) != 1: + raise ContractError("Patroni 동적 설정의 노드별 조회 결과가 다릅니다") + snapshot = {"schema_version": 1, "nodes": [by_name[name] for name in sorted(by_name)]} + canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8") + return {"snapshot": snapshot, "config_sha256": hashlib.sha256(canonical).hexdigest()} + + +def main(argv=None): + """Keep collection and local assembly explicit so raw configs are never transferred.""" + parser = argparse.ArgumentParser(description=__doc__) + actions = parser.add_subparsers(dest="action", required=True) + one = actions.add_parser("collect") + one.add_argument("--node", required=True) + one.add_argument("--install-root", type=Path, default=Path("/var/lib/docgrid/opensql")) + one.add_argument("--os-release", type=Path, default=Path("/etc/os-release")) + host = actions.add_parser("runtime") + host.add_argument("--node", required=True) + admin = actions.add_parser("admin") + admin.add_argument("--node", required=True) + admin.add_argument("--install-root", type=Path, default=Path("/var/lib/docgrid/opensql")) + many = actions.add_parser("assemble") + many.add_argument("node_json", nargs=3, type=Path) + args = parser.parse_args(argv) + try: + data = (collect(args) if args.action == "collect" else + runtime(args) if args.action == "runtime" else + admin_snapshot(args) if args.action == "admin" else assemble(args.node_json)) + print(json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2)) + return 0 + except ContractError as error: + print(f"HA 계약 수집 실패: {error}", file=sys.stderr) + return 1 + except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + print(f"HA 계약 수집 실패: {type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/opensql/test_capture_ha_contract.py b/scripts/opensql/test_capture_ha_contract.py new file mode 100644 index 0000000..3dfdb9a --- /dev/null +++ b/scripts/opensql/test_capture_ha_contract.py @@ -0,0 +1,166 @@ +"""Check that OpenSQL contract snapshots are deterministic and credential-free.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("capture_ha_contract.py") +SPEC = importlib.util.spec_from_file_location("capture_ha_contract", SCRIPT) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("HA 계약 수집기를 읽을 수 없습니다") +CONTRACT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CONTRACT) + + +class ContractSnapshotTest(unittest.TestCase): + """Guard allowlisted extraction and A/B contract comparisons before remote use.""" + + def setUp(self): + """Place realistic secrets next to public settings in an isolated config.""" + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.config = self.root / "openproxy.toml" + self.config.write_text(""" +[general] +prepared_statements_cache_size = 1000 +renew_interval = 5000 +admin_password = "do-not-publish-admin" + +[pools.docgrid] +pool_mode = "transaction" +query_parser_enabled = true +query_parser_read_write_splitting = true + +[pools.docgrid.users.0] +username = "docgrid_app" +password = "do-not-publish-app" +pool_size = 5 + +[pools.docgrid.shards.0] +servers = [ + ["10.1.2.3", 5432, "auto"], + ["10.1.2.4", 5432, "auto"] +] +database = "docgrid" +use_patroni = true +patroni_port = "8008" +""", encoding="utf-8") + + def test_proxy_output_omits_passwords_and_addresses(self): + """Read only contract keys, even when secrets share the same section.""" + settings = CONTRACT.proxy_settings(self.config) + serialized = json.dumps(settings) + self.assertEqual(1000, settings["general"]["prepared_statements_cache_size"]) + self.assertEqual("transaction", settings["pools.docgrid"]["pool_mode"]) + self.assertNotIn("do-not-publish", serialized) + self.assertNotIn("10.1.2", serialized) + + def test_duplicate_or_sensitive_allowlisted_value_is_rejected(self): + """Do not silently choose one duplicate or print an identifying value.""" + self.config.write_text(self.config.read_text() + "\n[pools.docgrid]\npool_mode = \"session\"\n") + with self.assertRaisesRegex(CONTRACT.ContractError, "중복"): + CONTRACT.proxy_settings(self.config) + self.config.write_text(self.config.read_text().replace( + 'pool_mode = "transaction"', 'pool_mode = "10.1.2.3"').replace( + '\n[pools.docgrid]\npool_mode = "session"\n', '')) + with self.assertRaisesRegex(CONTRACT.ContractError, "공개"): + CONTRACT.proxy_settings(self.config) + + def test_patroni_top_level_only_and_missing_keys_are_explicit(self): + """A nested PostgreSQL option must not impersonate a dynamic HA setting.""" + dynamic = CONTRACT.patroni_settings(""" +loop_wait: 10 +primary_start_timeout: 300 +failsafe_mode: false +postgresql: + parameters: + password: do-not-publish +""") + self.assertEqual(300, dynamic["primary_start_timeout"]) + self.assertFalse(dynamic["failsafe_mode"]) + self.assertIsNone(dynamic["ttl"]) + self.assertNotIn("do-not-publish", json.dumps(dynamic)) + + def test_assembly_is_order_independent_and_rejects_proxy_drift(self): + """One canonical SHA-256 represents the same three sanitized nodes.""" + paths = [] + for node in ("node1", "node2", "node3"): + path = self.root / f"{node}.json" + data = {"schema_version": 1, "node": node, + "os": {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"}, + "patroni_dynamic": {"ttl": 30}} + if node != "node1": + data["openproxy"] = {"pool_mode": "transaction"} + data["versions"] = {"openproxy": "openproxy 1.1.3"} + path.write_text(json.dumps(data), encoding="utf-8") + paths.append(path) + first = CONTRACT.assemble(paths) + self.assertEqual(first, CONTRACT.assemble(list(reversed(paths)))) + changed = json.loads(paths[2].read_text()) + changed["openproxy"]["pool_mode"] = "session" + paths[2].write_text(json.dumps(changed), encoding="utf-8") + with self.assertRaisesRegex(CONTRACT.ContractError, "설정이 다릅니다"): + CONTRACT.assemble(paths) + + def test_assembly_rejects_os_and_patroni_drift(self): + """A snapshot from a different OS or DCS state cannot prove one contract.""" + paths = [] + for node in ("node1", "node2", "node3"): + path = self.root / f"{node}.json" + path.write_text(json.dumps({"schema_version": 1, "node": node, + "os": {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"}, + "patroni_dynamic": {"ttl": 30}, + **({"openproxy": {"pool_mode": "transaction"}} if node != "node1" else {})}), + encoding="utf-8") + paths.append(path) + changed = json.loads(paths[2].read_text()) + changed["os"]["architecture"] = "aarch64" + paths[2].write_text(json.dumps(changed), encoding="utf-8") + with self.assertRaisesRegex(CONTRACT.ContractError, "Rocky Linux"): + CONTRACT.assemble(paths) + changed["os"]["architecture"] = "x86_64" + changed["patroni_dynamic"]["ttl"] = 60 + paths[2].write_text(json.dumps(changed), encoding="utf-8") + with self.assertRaisesRegex(CONTRACT.ContractError, "Patroni"): + CONTRACT.assemble(paths) + + def test_admin_snapshot_whitelists_effective_config_and_counters(self): + """Do not copy credentials or backend addresses from administrator tables.""" + general = self.config.read_text().replace( + 'admin_password = "do-not-publish-admin"', + 'admin_port = 6433\nport = 6432\nadmin_username = "admin"\n' + 'admin_password = "do-not-publish-admin"') + self.config.write_text(general, encoding="utf-8") + credentials = CONTRACT.admin_credentials(self.config) + self.assertEqual(6432, credentials["port"]) + rows = { + "SHOW CONFIG": [{"key": "pools.docgrid.prepared_statements_cache_size", "value": "0"}, + {"key": "pools.docgrid.pool_mode", "value": '"Transaction"'}, + {"key": "admin_password", "value": "do-not-publish-admin"}], + "SHOW STATS": [{"database": "docgrid", "instance": "docgrid_shard_0_replica_0", + "total_query_count": "7", "total_xact_count": "5", "total_errors": "0", + "user": "do-not-publish-app"}], + "SHOW SERVERS": [{"database_name": "docgrid", "address_id": "docgrid_shard_0_replica_0", + "prepare_cache_hit": "3", "prepare_cache_miss": "1", + "prepare_cache_eviction": "0", "prepare_cache_size": "1", + "user": "do-not-publish-app"}], + } + with patch.object(CONTRACT, "admin_rows", side_effect=lambda _root, _auth, sql: rows[sql]): + with patch.object(CONTRACT, "admin_credentials", return_value=credentials): + data = CONTRACT.admin_snapshot(SimpleNamespace(node="node2", install_root=self.root)) + self.assertEqual(0, data["effective_config"]["pools.docgrid.prepared_statements_cache_size"]) + self.assertEqual("Transaction", data["effective_config"]["pools.docgrid.pool_mode"]) + self.assertEqual(7, data["stats"][0]["queries"]) + self.assertNotIn("do-not-publish", json.dumps(data)) + + +if __name__ == "__main__": + unittest.main() From 8e862697bd7279ce2e3cdc06d214ca97abfd92ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 16:47:20 +0900 Subject: [PATCH 2/9] test: use documentation IPs in contract fixtures --- scripts/opensql/test_capture_ha_contract.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/opensql/test_capture_ha_contract.py b/scripts/opensql/test_capture_ha_contract.py index 3dfdb9a..2084e32 100644 --- a/scripts/opensql/test_capture_ha_contract.py +++ b/scripts/opensql/test_capture_ha_contract.py @@ -46,8 +46,8 @@ def setUp(self): [pools.docgrid.shards.0] servers = [ - ["10.1.2.3", 5432, "auto"], - ["10.1.2.4", 5432, "auto"] + ["192.0.2.1", 5432, "auto"], + ["192.0.2.2", 5432, "auto"] ] database = "docgrid" use_patroni = true @@ -61,7 +61,7 @@ def test_proxy_output_omits_passwords_and_addresses(self): self.assertEqual(1000, settings["general"]["prepared_statements_cache_size"]) self.assertEqual("transaction", settings["pools.docgrid"]["pool_mode"]) self.assertNotIn("do-not-publish", serialized) - self.assertNotIn("10.1.2", serialized) + self.assertNotIn("192.0.2", serialized) def test_duplicate_or_sensitive_allowlisted_value_is_rejected(self): """Do not silently choose one duplicate or print an identifying value.""" @@ -69,7 +69,7 @@ def test_duplicate_or_sensitive_allowlisted_value_is_rejected(self): with self.assertRaisesRegex(CONTRACT.ContractError, "중복"): CONTRACT.proxy_settings(self.config) self.config.write_text(self.config.read_text().replace( - 'pool_mode = "transaction"', 'pool_mode = "10.1.2.3"').replace( + 'pool_mode = "transaction"', 'pool_mode = "192.0.2.1"').replace( '\n[pools.docgrid]\npool_mode = "session"\n', '')) with self.assertRaisesRegex(CONTRACT.ContractError, "공개"): CONTRACT.proxy_settings(self.config) From 92d9b51d0cb0589c2a260a6b75f186bee411aa15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 21:33:57 +0900 Subject: [PATCH 3/9] docs: add author prefix to OpenSQL test results --- ...evidence-runbook.md => gimin-opensql-ha-evidence-runbook.md} | 0 ... gimin-opensql-openproxy-hikari-troubleshooting-20260924.md} | 2 +- ...imin-opensql-openproxy-routing-session-contract-20260926.md} | 2 +- ...ery-e2e.md => gimin-opensql-reconnect-lease-recovery-e2e.md} | 0 ...very-runbook.md => gimin-opensql-single-recovery-runbook.md} | 0 ...3.md => gimin-opensql-three-node-app-connection-20260923.md} | 2 +- ...> gimin-opensql-three-node-license-installation-20260923.md} | 2 +- ...=> gimin-opensql-three-node-live-app-connection-20260924.md} | 2 +- ... => gimin-opensql-three-node-runtime-activation-20260923.md} | 2 +- 9 files changed, 6 insertions(+), 6 deletions(-) rename docs/test-results/{opensql-ha-evidence-runbook.md => gimin-opensql-ha-evidence-runbook.md} (100%) rename docs/test-results/{opensql-openproxy-hikari-troubleshooting-20260924.md => gimin-opensql-openproxy-hikari-troubleshooting-20260924.md} (98%) rename docs/test-results/{opensql-openproxy-routing-session-contract-20260926.md => gimin-opensql-openproxy-routing-session-contract-20260926.md} (98%) rename docs/test-results/{opensql-reconnect-lease-recovery-e2e.md => gimin-opensql-reconnect-lease-recovery-e2e.md} (100%) rename docs/test-results/{opensql-single-recovery-runbook.md => gimin-opensql-single-recovery-runbook.md} (100%) rename docs/test-results/{opensql-three-node-app-connection-20260923.md => gimin-opensql-three-node-app-connection-20260923.md} (97%) rename docs/test-results/{opensql-three-node-license-installation-20260923.md => gimin-opensql-three-node-license-installation-20260923.md} (95%) rename docs/test-results/{opensql-three-node-live-app-connection-20260924.md => gimin-opensql-three-node-live-app-connection-20260924.md} (95%) rename docs/test-results/{opensql-three-node-runtime-activation-20260923.md => gimin-opensql-three-node-runtime-activation-20260923.md} (97%) diff --git a/docs/test-results/opensql-ha-evidence-runbook.md b/docs/test-results/gimin-opensql-ha-evidence-runbook.md similarity index 100% rename from docs/test-results/opensql-ha-evidence-runbook.md rename to docs/test-results/gimin-opensql-ha-evidence-runbook.md diff --git a/docs/test-results/opensql-openproxy-hikari-troubleshooting-20260924.md b/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md similarity index 98% rename from docs/test-results/opensql-openproxy-hikari-troubleshooting-20260924.md rename to docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md index 637a336..f300776 100644 --- a/docs/test-results/opensql-openproxy-hikari-troubleshooting-20260924.md +++ b/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md @@ -19,7 +19,7 @@ Flyway ─ 별도 마이그레이션 계정 ─ DB 3노드 중 현재 primary 초기 `docgrid` 프록시 풀은 `pool_mode="session"`, `default_role="primary"`, `query_parser_enabled=false`였다. 일반 JDBC 연결에서 프록시를 통해 인증·조회가 된다고 해서 앱의 Hikari 연결도 primary에 있다고 볼 수 없었다. Hikari로 실제 연결한 뒤 `pg_is_in_recovery()`를 실행하자 `true`가 나왔다. 즉, 연결은 성공했지만 쓰기 불가능한 standby에 있었다. -기존 진단에서는 단독 JDBC 연결의 **첫 명령**을 `SET SERVER ROLE TO 'primary'`로 보내면 primary에 도달했다. 그러나 앱에서는 Hikari/드라이버가 `connection-init-sql`보다 먼저 초기 검증·메타데이터 조회를 수행했다. 세션 풀에서는 처음 선택된 서버 연결이 클라이언트 세션 동안 유지되므로, 나중에 역할을 지정하는 방식으로는 문제를 고칠 수 없었다. 이 동작은 [이전 실접속 기록](opensql-three-node-live-app-connection-20260924.md)에 남겨 두었다. +기존 진단에서는 단독 JDBC 연결의 **첫 명령**을 `SET SERVER ROLE TO 'primary'`로 보내면 primary에 도달했다. 그러나 앱에서는 Hikari/드라이버가 `connection-init-sql`보다 먼저 초기 검증·메타데이터 조회를 수행했다. 세션 풀에서는 처음 선택된 서버 연결이 클라이언트 세션 동안 유지되므로, 나중에 역할을 지정하는 방식으로는 문제를 고칠 수 없었다. 이 동작은 [이전 실접속 기록](gimin-opensql-three-node-live-app-connection-20260924.md)에 남겨 두었다. 공급사도 세션 풀에서 첫 조회가 replica를 선택하면 이후 primary 전용 SQL이 실패할 수 있다고 설명한다. 다만 **왜 현재 빌드에서 `default_role="primary"`가 첫 연결을 primary로 고정하지 못했는지 내부 구현 원인까지 확인한 것은 아니다.** 여기서 확정한 것은 Hikari 연결의 실제 대상과 재현 가능한 실패다. [OpenProxy 읽기 분산 문서](https://docs.tibero.com/tmaxopensql.en/administration/openproxy/load-balancing) diff --git a/docs/test-results/opensql-openproxy-routing-session-contract-20260926.md b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md similarity index 98% rename from docs/test-results/opensql-openproxy-routing-session-contract-20260926.md rename to docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md index 664c4cc..a5829e4 100644 --- a/docs/test-results/opensql-openproxy-routing-session-contract-20260926.md +++ b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md @@ -2,7 +2,7 @@ ## 목적과 판정 범위 -두 OpenProxy를 거친 실제 SQL의 primary/standby 역할, 트랜잭션 풀에서의 pgJDBC named prepared statement, 시간대 경계, HA 관련 설치·동적 설정을 **운영 데이터를 바꾸지 않고** 확인했다. 이 문서는 장애 전환, 물리 standby별 부하 분산, Hikari/JPA 전체 경로의 무중단성을 합격 처리하지 않는다. 기존 Hikari/JPA 쓰기 검증은 [별도 결과](opensql-openproxy-hikari-troubleshooting-20260924.md)를 참조한다. +두 OpenProxy를 거친 실제 SQL의 primary/standby 역할, 트랜잭션 풀에서의 pgJDBC named prepared statement, 시간대 경계, HA 관련 설치·동적 설정을 **운영 데이터를 바꾸지 않고** 확인했다. 이 문서는 장애 전환, 물리 standby별 부하 분산, Hikari/JPA 전체 경로의 무중단성을 합격 처리하지 않는다. 기존 Hikari/JPA 쓰기 검증은 [별도 결과](gimin-opensql-openproxy-hikari-troubleshooting-20260924.md)를 참조한다. 공개 증거는 논리 별칭 `node1~3`, `proxy-a/b`만 사용한다. 프로젝트 ID, IP, 관리자·앱 암호, 라이선스 파일은 포함하지 않는다. 수집 당시 `node1`이 leader, `node2/3`이 streaming replica였고, 프록시는 각각 `node2/3`에서 실행됐다. 세 컨테이너 모두 Rocky Linux 9.7 `x86_64`였다. diff --git a/docs/test-results/opensql-reconnect-lease-recovery-e2e.md b/docs/test-results/gimin-opensql-reconnect-lease-recovery-e2e.md similarity index 100% rename from docs/test-results/opensql-reconnect-lease-recovery-e2e.md rename to docs/test-results/gimin-opensql-reconnect-lease-recovery-e2e.md diff --git a/docs/test-results/opensql-single-recovery-runbook.md b/docs/test-results/gimin-opensql-single-recovery-runbook.md similarity index 100% rename from docs/test-results/opensql-single-recovery-runbook.md rename to docs/test-results/gimin-opensql-single-recovery-runbook.md diff --git a/docs/test-results/opensql-three-node-app-connection-20260923.md b/docs/test-results/gimin-opensql-three-node-app-connection-20260923.md similarity index 97% rename from docs/test-results/opensql-three-node-app-connection-20260923.md rename to docs/test-results/gimin-opensql-three-node-app-connection-20260923.md index 2684d2b..8115e9d 100644 --- a/docs/test-results/opensql-three-node-app-connection-20260923.md +++ b/docs/test-results/gimin-opensql-three-node-app-connection-20260923.md @@ -1,6 +1,6 @@ # OpenSQL 3노드 DocGrid 접속 준비·검증 — 2026-09-23 -> 이 문서는 2026-09-23 당시의 준비 상태를 기록한다. 실제 앱 기동·마이그레이션 결과와 변경된 앱 접속 경로는 [후속 실접속 검증](opensql-three-node-live-app-connection-20260924.md)을 참조한다. +> 이 문서는 2026-09-23 당시의 준비 상태를 기록한다. 실제 앱 기동·마이그레이션 결과와 변경된 앱 접속 경로는 [후속 실접속 검증](gimin-opensql-three-node-live-app-connection-20260924.md)을 참조한다. ## 범위와 현재 결과 diff --git a/docs/test-results/opensql-three-node-license-installation-20260923.md b/docs/test-results/gimin-opensql-three-node-license-installation-20260923.md similarity index 95% rename from docs/test-results/opensql-three-node-license-installation-20260923.md rename to docs/test-results/gimin-opensql-three-node-license-installation-20260923.md index 75f8086..b4e1d41 100644 --- a/docs/test-results/opensql-three-node-license-installation-20260923.md +++ b/docs/test-results/gimin-opensql-three-node-license-installation-20260923.md @@ -2,7 +2,7 @@ ## 범위와 결과 -Google Cloud의 Rocky Linux 9.7 (Blue Onyx) `x86_64` 컨테이너 3개에 각각 발급된 체험판 라이선스를 영속 볼륨에 배치하고, 실행 중인 컨테이너 안에서 파일을 확인했다. 이 단계에서는 **OpenSQL DBMS를 아직 설치하지 않았으므로 라이선스의 런타임 활성화를 검증한 것이 아니다.** 이후 설치·실행 결과는 [3노드 실행 검증](opensql-three-node-runtime-activation-20260923.md)에 별도로 기록했다. +Google Cloud의 Rocky Linux 9.7 (Blue Onyx) `x86_64` 컨테이너 3개에 각각 발급된 체험판 라이선스를 영속 볼륨에 배치하고, 실행 중인 컨테이너 안에서 파일을 확인했다. 이 단계에서는 **OpenSQL DBMS를 아직 설치하지 않았으므로 라이선스의 런타임 활성화를 검증한 것이 아니다.** 이후 설치·실행 결과는 [3노드 실행 검증](gimin-opensql-three-node-runtime-activation-20260923.md)에 별도로 기록했다. 공개 문서에는 프로젝트 ID, 실제 호스트명·사설 IP, 파일의 절대 경로, 라이선스 원본과 해시를 포함하지 않는다. 이 값들은 각 노드에 설치할 라이선스가 서로 뒤바뀌지 않았는지 비공개 환경에서 대조하는 데만 사용했다. diff --git a/docs/test-results/opensql-three-node-live-app-connection-20260924.md b/docs/test-results/gimin-opensql-three-node-live-app-connection-20260924.md similarity index 95% rename from docs/test-results/opensql-three-node-live-app-connection-20260924.md rename to docs/test-results/gimin-opensql-three-node-live-app-connection-20260924.md index f86d1cd..efbb4a3 100644 --- a/docs/test-results/opensql-three-node-live-app-connection-20260924.md +++ b/docs/test-results/gimin-opensql-three-node-live-app-connection-20260924.md @@ -2,7 +2,7 @@ ## 실행 범위 -기존 Rocky Linux 9.7 `x86_64` VM 세 대의 OpenSQL 클러스터에서 DocGrid 앱 계정과 마이그레이션 계정을 분리해 실접속했다. 세 VM은 같은 영역에 있다. 프로젝트 식별자, 사설 IP, 서버 경로, 암호는 이 문서에 기록하지 않는다. [전날 접속 준비 결과](opensql-three-node-app-connection-20260923.md)의 미실행 항목을 실제로 검증한 기록이다. +기존 Rocky Linux 9.7 `x86_64` VM 세 대의 OpenSQL 클러스터에서 DocGrid 앱 계정과 마이그레이션 계정을 분리해 실접속했다. 세 VM은 같은 영역에 있다. 프로젝트 식별자, 사설 IP, 서버 경로, 암호는 이 문서에 기록하지 않는다. [전날 접속 준비 결과](gimin-opensql-three-node-app-connection-20260923.md)의 미실행 항목을 실제로 검증한 기록이다. | 항목 | 실행 결과 | |---|---| diff --git a/docs/test-results/opensql-three-node-runtime-activation-20260923.md b/docs/test-results/gimin-opensql-three-node-runtime-activation-20260923.md similarity index 97% rename from docs/test-results/opensql-three-node-runtime-activation-20260923.md rename to docs/test-results/gimin-opensql-three-node-runtime-activation-20260923.md index eb6c675..fd26895 100644 --- a/docs/test-results/opensql-three-node-runtime-activation-20260923.md +++ b/docs/test-results/gimin-opensql-three-node-runtime-activation-20260923.md @@ -2,7 +2,7 @@ ## 결과와 검증 범위 -Google Cloud의 Rocky Linux 9.7 `x86_64` 컨테이너 3개에 OpenSQL을 설치하고 실행했다. 각 PostgreSQL 프로세스에 해당 노드의 `OPENSQL_LICENSE_PATH`가 전달됐고, `opensql_license` 모듈을 로드한 상태에서 SQL 접속과 쿼리가 성공했다. 앞선 [라이선스 배치 검증](opensql-three-node-license-installation-20260923.md)의 후속 단계다. +Google Cloud의 Rocky Linux 9.7 `x86_64` 컨테이너 3개에 OpenSQL을 설치하고 실행했다. 각 PostgreSQL 프로세스에 해당 노드의 `OPENSQL_LICENSE_PATH`가 전달됐고, `opensql_license` 모듈을 로드한 상태에서 SQL 접속과 쿼리가 성공했다. 앞선 [라이선스 배치 검증](gimin-opensql-three-node-license-installation-20260923.md)의 후속 단계다. 이 결과는 **라이선스 경로 전달·모듈 로드·DBMS 실행 및 쿼리 처리**를 확인한 것이다. 공급사의 별도 라이선스 상태 조회 명령이나 만료·재부팅 상황까지 검증했다는 뜻은 아니다. 공개 문서에는 프로젝트 ID, 실제 호스트명·사설 IP, 운영 파일 경로, 라이선스 원본과 해시를 기재하지 않는다. From 5ec0d53499b53ee8d8a15bc92864a2f78d321f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 22:00:20 +0900 Subject: [PATCH 4/9] test: complete OpenProxy routing and session contract checks --- .../opensql/OpenSqlOpenProxyContractTest.java | 55 ++++- scripts/opensql/capture_ha_contract.py | 194 +++++++++++++++++- scripts/opensql/capture_live_ha_contract.sh | 62 ++++++ scripts/opensql/test_capture_ha_contract.py | 101 +++++++++ 4 files changed, 401 insertions(+), 11 deletions(-) create mode 100755 scripts/opensql/capture_live_ha_contract.sh diff --git a/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java b/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java index 70f59bf..89535a6 100644 --- a/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java +++ b/backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java @@ -10,9 +10,11 @@ import java.sql.Statement; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; -import java.util.Set; import java.util.HashSet; +import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -21,7 +23,7 @@ import org.postgresql.PGStatement; /** - * 실제 두 OpenProxy에서 트랜잭션 라우팅, pgJDBC 프로토콜, 시간대 경계를 확인한다. + * 실제 두 OpenProxy에서 트랜잭션 라우팅, SQL/pgJDBC 준비문, 시간대 경계를 확인한다. * *

외부 3노드 전용 수동 시험이다. 설정·스키마를 바꾸지 않으며 역할과 backend 개수만 출력한다. * SQL이 어느 물리 노드에 갔는지는 별도 프록시·DB 통계와 교차 확인한다. @@ -124,6 +126,7 @@ void preparedStatementsSurviveTransactionPooling() throws SQLException { } // 3. 드라이버가 server-side prepare 단계에 들어갔는지 확인한다. assertThat(pgStatement.isUseServerPrepare()).isTrue(); + assertThat(preparedOnLastBackend).isPositive(); } finally { connection.rollback(); } @@ -135,6 +138,39 @@ void preparedStatementsSurviveTransactionPooling() throws SQLException { } } + @Test + @Timeout(60) + @DisplayName("SQL-level PREPARE/EXECUTE를 pgJDBC 프로토콜 준비문과 구별한다") + void sqlLevelPrepareIsObservedSeparately() throws SQLException { + String baseline = null; + for (String proxy : PROXIES) { + try (Connection connection = connect(proxy, null)) { + connection.setAutoCommit(false); + // 1. SQL PREPARE는 드라이버의 반복 실행 임계값과 무관한 서버 명령이다. + String name = "contract_" + UUID.randomUUID().toString().replace("-", ""); + String outcome; + try (Statement statement = connection.createStatement()) { + statement.execute("PREPARE " + name + "(integer) AS SELECT $1::integer"); + try (ResultSet result = statement.executeQuery("EXECUTE " + name + "(41)")) { + assertThat(result.next()).isTrue(); + assertThat(result.getInt(1)).isEqualTo(41); + } + // 2. 같은 트랜잭션 안에서 명시적으로 제거해 풀의 다른 요청에 남기지 않는다. + statement.execute("DEALLOCATE " + name); + outcome = "same_transaction_pass"; + } finally { + connection.rollback(); + } + if (baseline == null) { + baseline = outcome; + } else { + assertThat(outcome).isEqualTo(baseline); + } + System.out.printf("CONTRACT_SQL_PREPARE proxy=%s result=%s%n", alias(proxy), outcome); + } + } + } + @Test @Timeout(60) @DisplayName("timestamp와 timestamptz의 한국 시간 자정 경계를 보존한다") @@ -142,14 +178,19 @@ void timeZoneBoundaryIsStableAcrossProxies() throws SQLException { LocalDateTime wallClock = LocalDateTime.of(2026, 9, 27, 0, 0, 1); OffsetDateTime instant = wallClock.atOffset(ZoneOffset.ofHours(9)); String baseline = null; + String serverVersion = null; + ZoneId jvmTimeZone = ZoneId.systemDefault(); for (String proxy : PROXIES) { try (Connection connection = connect(proxy, null)) { - // 1. timestamp는 벽시계 시간을, timestamptz는 같은 순간을 돌려줘야 한다. + // 1. JVM·DB와 OS 시간대가 달라도 양 프록시의 날짜 변환은 같아야 한다. String timeZone = queryString(connection, "SHOW TimeZone"); + String observedServerVersion = connection.getMetaData().getDatabaseProductVersion(); if (baseline == null) { baseline = timeZone; + serverVersion = observedServerVersion; } else { assertThat(timeZone).isEqualTo(baseline); + assertThat(observedServerVersion).isEqualTo(serverVersion); } try (PreparedStatement statement = connection.prepareStatement( "SELECT ?::timestamp, ?::timestamptz")) { @@ -173,8 +214,12 @@ void timeZoneBoundaryIsStableAcrossProxies() throws SQLException { } connection.setAutoCommit(true); assertThat(queryString(connection, "SHOW TimeZone")).isEqualTo(timeZone); - System.out.printf("CONTRACT_TIMEZONE proxy=%s setting=%s boundary=pass%n", - alias(proxy), timeZone); + System.out.printf("CONTRACT_TIMEZONE proxy=%s jvm=%s db=%s boundary=pass%n", + alias(proxy), jvmTimeZone, timeZone); + System.out.printf("CONTRACT_SERVER proxy=%s postgresql=%s%n", + alias(proxy), observedServerVersion); + System.out.printf("CONTRACT_DRIVER proxy=%s pgjdbc=%s%n", + alias(proxy), connection.getMetaData().getDriverVersion()); } } } diff --git a/scripts/opensql/capture_ha_contract.py b/scripts/opensql/capture_ha_contract.py index aaeeb8d..e5a7c99 100644 --- a/scripts/opensql/capture_ha_contract.py +++ b/scripts/opensql/capture_ha_contract.py @@ -14,6 +14,7 @@ import re import subprocess import sys +import xml.etree.ElementTree as ET from pathlib import Path @@ -139,6 +140,16 @@ def version(binary: Path, *options): return first if VERSION.fullmatch(first) else None +def opensql_version(binary: Path): + """Extract the product release from a multiline, banner-style version response.""" + result = subprocess.run([str(binary), "--version"], capture_output=True, text=True, + timeout=5, check=True) + match = re.search(r"^OpenSQL version (v\d+(?:\.\d+)+)$", result.stdout, re.MULTILINE) + if not match: + raise ContractError("OpenSQL 제품 버전을 판별할 수 없습니다") + return match.group(1) + + def os_release(path: Path): """Expose only distro identity and release, excluding host-specific metadata.""" values = {} @@ -149,6 +160,106 @@ def os_release(path: Path): return values +def time_zone(): + """Record the OS clock label and offset without publishing host metadata.""" + result = subprocess.run(["date", "+%Z%z"], capture_output=True, text=True, + timeout=5, check=True) + value = result.stdout.strip() + if not re.fullmatch(r"[A-Za-z_+/:-]{2,40}[+-]\d{4}", value): + raise ContractError("OS 시간대 표기를 판별할 수 없습니다") + return value + + +def etcd_timing_inputs(root: Path, proc_root: Path = Path("/proc")): + """Resolve installed defaults against the one running etcd process's safe timing inputs.""" + processes = [] + for entry in proc_root.iterdir(): + if not entry.name.isdecimal(): + continue + try: + if (entry / "comm").read_text(encoding="utf-8").strip() == "etcd": + processes.append(entry) + except (OSError, UnicodeError): + continue + if len(processes) != 1: + raise ContractError("실행 중인 etcd 프로세스 하나를 확인하지 못했습니다") + command = processes[0].joinpath("cmdline").read_bytes().decode("utf-8").split("\0") + environment = processes[0].joinpath("environ").read_bytes().decode("utf-8").split("\0") + if any(argument.startswith("--config-file") for argument in command): + raise ContractError("별도 etcd 설정 파일의 우선순위를 확인해야 합니다") + help_result = subprocess.run([str(root / "bin/etcd"), "--help"], + capture_output=True, text=True, timeout=5, check=True) + help_text = help_result.stdout + help_result.stderr + timing = {} + for flag, variable, output in (("heartbeat-interval", "ETCD_HEARTBEAT_INTERVAL", + "heartbeat_interval_ms"), + ("election-timeout", "ETCD_ELECTION_TIMEOUT", + "election_timeout_ms")): + default = re.search(r"--" + flag + r"\s+'(\d+)'", help_text) + if not default: + raise ContractError(f"설치된 etcd의 기본값을 확인하지 못했습니다: {flag}") + explicit = [value.split("=", 1)[1] for value in environment + if value.startswith(variable + "=")] + for index, argument in enumerate(command): + if argument.startswith("--" + flag + "="): + explicit.append(argument.split("=", 1)[1]) + elif argument == "--" + flag and index + 1 < len(command): + explicit.append(command[index + 1]) + if len(explicit) > 1 or (explicit and not explicit[0].isdecimal()): + raise ContractError(f"실행 중인 etcd 시간 설정을 판별할 수 없습니다: {flag}") + timing[output] = {"value": int(explicit[0] if explicit else default.group(1)), + "source": "process override" if explicit else "installed binary default"} + return timing + + +def etcd_settings(root: Path): + """Keep only HA timing overrides and normalized live member names.""" + environment = root / "etc/etcd/etcd.env" + configured = {} + for line in environment.read_text(encoding="utf-8").splitlines(): + if "=" not in line or line.lstrip().startswith("#"): + continue + key, raw = line.split("=", 1) + if key not in {"ETCD_INITIAL_CLUSTER", "ETCD_INITIAL_CLUSTER_STATE", + "ETCD_HEARTBEAT_INTERVAL", "ETCD_ELECTION_TIMEOUT"}: + continue + if key in configured: + raise ContractError(f"etcd 설정 중복: {key}") + configured[key] = raw.strip().strip('"\'') + names = [] + for member in configured.get("ETCD_INITIAL_CLUSTER", "").split(","): + name = member.split("=", 1)[0] + if not re.fullmatch(r"etcd[123]", name): + raise ContractError("etcd 초기 멤버 이름을 안전하게 정규화할 수 없습니다") + names.append(name.replace("etcd", "node")) + if sorted(names) != ["node1", "node2", "node3"]: + raise ContractError("etcd 초기 멤버 세 개를 확인하지 못했습니다") + result = subprocess.run([str(root / "bin/etcdctl"), + "--endpoints=http://127.0.0.1:2379", "member", "list", + "--write-out=json"], capture_output=True, text=True, + timeout=10, check=True) + live = json.loads(result.stdout).get("members", []) + live_names = [] + for member in live: + name = member.get("name", "") + if not re.fullmatch(r"etcd[123]", name) or member.get("isLearner", False): + raise ContractError("etcd 실행 멤버 구성을 판별할 수 없습니다") + live_names.append(name.replace("etcd", "node")) + if sorted(live_names) != sorted(names): + raise ContractError("etcd 초기 설정과 실행 멤버가 일치하지 않습니다") + timing = {} + for key, output in (("ETCD_HEARTBEAT_INTERVAL", "heartbeat_interval_ms"), + ("ETCD_ELECTION_TIMEOUT", "election_timeout_ms")): + raw = configured.get(key) + if raw is not None and (not raw.isdecimal() or int(raw) <= 0): + raise ContractError(f"etcd 시간 설정을 판별할 수 없습니다: {key}") + timing[output] = int(raw) if raw is not None else None + return {"initial_cluster_state": safe_value(configured.get("ETCD_INITIAL_CLUSTER_STATE")), + "configured_member_names": sorted(names), "live_member_names": sorted(live_names), + "quorum": len(live_names) // 2 + 1, "explicit_timing": timing, + "running_timing": etcd_timing_inputs(root)} + + def patroni_members(text: str): """Normalize member names and roles without exposing REST addresses.""" members = json.loads(text) @@ -180,13 +291,17 @@ def collect(args): "schema_version": 1, "node": args.node, "os": {**os_release(args.os_release), "architecture": platform.machine()}, + "container_time_zone": time_zone(), "versions": { + "opensql": opensql_version(root / "bin/opensql"), "openproxy": version(root / "bin/openproxy", "--version"), "openproxy_revision": version(root / "bin/openproxy", "--revision"), "patroni": version(root / "bin/patroni", "--version"), "etcd": version(root / "bin/etcd", "--version"), "psql_client": version(root / "bin/psql", "--version"), + "postgres_server_binary": version(root / "bin/postgres", "--version"), }, + "etcd": etcd_settings(root), "patroni_dynamic": patroni_settings(subprocess.run( [str(root / "bin/patronictl"), "-c", str(root / "etc/patroni/patroni.yml"), "show-config"], capture_output=True, text=True, timeout=10, @@ -227,6 +342,7 @@ def runtime(args): if inspect.stdout.strip() not in {"always", "unless-stopped", "no", "on-failure"}: raise ContractError("컨테이너 재시작 정책을 판별할 수 없습니다") return {"node": args.node, "container_restart_policy": inspect.stdout.strip(), + "host_time_zone": time_zone(), "host_bootstrap_unit": {key: safe_value(fields.get(key)) for key in ("ActiveState", "Restart", "RestartUSec")}, "openproxy_live_process_count": len(openproxy_parents), @@ -309,13 +425,26 @@ def admin_snapshot(args): "prepare_cache_miss": int(row["prepare_cache_miss"]), "prepare_cache_eviction": int(row["prepare_cache_eviction"]), "prepare_cache_size": int(row["prepare_cache_size"])}) - return {"node": args.node, "effective_config": config, + return {"node": args.node, "proxy": "proxy-a" if args.node == "node2" else "proxy-b", + "effective_config": config, "stats": sorted(stats, key=lambda row: row["role"]), "servers": sorted(servers, key=lambda row: row["role"])} -def assemble(paths): - """Compare contract-critical A/B values and hash a canonical, sanitized snapshot.""" +def merge_node(collect_path: Path, runtime_path: Path): + """Join independently collected container and VM observations without hand editing.""" + node = json.loads(collect_path.read_text(encoding="utf-8")) + runtime_data = json.loads(runtime_path.read_text(encoding="utf-8")) + if node.get("node") != runtime_data.get("node") or not NODE.fullmatch(node.get("node", "")): + raise ContractError("컨테이너와 VM 수집 결과의 노드가 다릅니다") + if "runtime" in node or PRIVATE_VALUE.search(json.dumps({"node": node, "runtime": runtime_data})): + raise ContractError("중복 또는 공개 불가능한 수집 결과입니다") + node["runtime"] = {key: value for key, value in runtime_data.items() if key != "node"} + return node + + +def assemble(paths, admin_paths=None): + """Compare A/B contracts and hash all supplied, sanitized node/admin observations.""" nodes = [json.loads(path.read_text(encoding="utf-8")) for path in paths] if {node.get("node") for node in nodes} != {"node1", "node2", "node3"}: raise ContractError("node1~node3 결과가 각각 하나씩 필요합니다") @@ -332,12 +461,56 @@ def assemble(paths): raise ContractError("두 OpenProxy의 계약 관련 설정이 다릅니다") if by_name["node2"].get("versions", {}).get("openproxy") != by_name["node3"].get("versions", {}).get("openproxy"): raise ContractError("두 OpenProxy의 설치 버전이 다릅니다") + product_versions = [node.get("versions", {}).get("opensql") for node in nodes] + if any(product_versions) and (None in product_versions or len(set(product_versions)) != 1): + raise ContractError("세 노드의 OpenSQL 제품 버전이 다릅니다") if len({json.dumps(node.get("patroni_dynamic"), sort_keys=True) for node in nodes}) != 1: raise ContractError("Patroni 동적 설정의 노드별 조회 결과가 다릅니다") snapshot = {"schema_version": 1, "nodes": [by_name[name] for name in sorted(by_name)]} + if admin_paths: + admins = [json.loads(path.read_text(encoding="utf-8")) for path in admin_paths] + if {admin.get("proxy") for admin in admins} != {"proxy-a", "proxy-b"} or any( + PRIVATE_VALUE.search(json.dumps(admin)) for admin in admins): + raise ContractError("관리 결과 별칭 또는 공개 가능성을 확인할 수 없습니다") + by_proxy = {admin["proxy"]: admin for admin in admins} + if by_proxy["proxy-a"].get("node") != "node2" or by_proxy["proxy-b"].get("node") != "node3": + raise ContractError("관리 결과와 프록시 노드가 일치하지 않습니다") + if by_proxy["proxy-a"].get("effective_config") != by_proxy["proxy-b"].get("effective_config"): + raise ContractError("두 OpenProxy의 관리 콘솔 설정이 다릅니다") + snapshot["admins"] = [by_proxy[name] for name in sorted(by_proxy)] canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") - return {"snapshot": snapshot, "config_sha256": hashlib.sha256(canonical).hexdigest()} + return {"snapshot": snapshot, "evidence_sha256": hashlib.sha256(canonical).hexdigest()} + + +def junit_summary(path: Path): + """Preserve test cases and allowlisted observations without machine names or secrets.""" + suite = ET.parse(path).getroot() + if suite.tag != "testsuite": + raise ContractError("JUnit 시험 결과 형식을 확인할 수 없습니다") + timestamp = suite.get("timestamp") + if timestamp is not None and not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z", timestamp): + raise ContractError("JUnit 실행 시각을 판별할 수 없습니다") + cases = [] + for case in suite.findall("testcase"): + cases.append({"name": safe_value(case.get("name", "")), + "status": "failed" if case.find("failure") is not None else + "error" if case.find("error") is not None else + "skipped" if case.find("skipped") is not None else "passed"}) + observations = [] + for line in (suite.findtext("system-out") or "").splitlines(): + if line.startswith("CONTRACT_"): + if PRIVATE_VALUE.search(line) or not re.fullmatch( + r"CONTRACT_[A-Z_]+ [A-Za-z0-9_=./:+ -]{1,300}", line): + raise ContractError("공개할 수 없는 시험 관측값이 포함되었습니다") + observations.append(line) + return {"tests": int(suite.get("tests", "0")), + "executed_at_utc": timestamp, + "duration_seconds": float(suite.get("time", "0")), + "failures": int(suite.get("failures", "0")), + "errors": int(suite.get("errors", "0")), + "skipped": int(suite.get("skipped", "0")), + "cases": cases, "observations": observations} def main(argv=None): @@ -353,19 +526,28 @@ def main(argv=None): admin = actions.add_parser("admin") admin.add_argument("--node", required=True) admin.add_argument("--install-root", type=Path, default=Path("/var/lib/docgrid/opensql")) + merge = actions.add_parser("merge") + merge.add_argument("collect_json", type=Path) + merge.add_argument("runtime_json", type=Path) many = actions.add_parser("assemble") many.add_argument("node_json", nargs=3, type=Path) + many.add_argument("--admin", nargs=2, type=Path) + junit = actions.add_parser("junit") + junit.add_argument("xml", type=Path) args = parser.parse_args(argv) try: data = (collect(args) if args.action == "collect" else runtime(args) if args.action == "runtime" else - admin_snapshot(args) if args.action == "admin" else assemble(args.node_json)) + admin_snapshot(args) if args.action == "admin" else + merge_node(args.collect_json, args.runtime_json) if args.action == "merge" else + junit_summary(args.xml) if args.action == "junit" else + assemble(args.node_json, args.admin)) print(json.dumps(data, ensure_ascii=False, sort_keys=True, indent=2)) return 0 except ContractError as error: print(f"HA 계약 수집 실패: {error}", file=sys.stderr) return 1 - except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + except (OSError, subprocess.SubprocessError, json.JSONDecodeError, ET.ParseError) as error: print(f"HA 계약 수집 실패: {type(error).__name__}", file=sys.stderr) return 1 diff --git a/scripts/opensql/capture_live_ha_contract.sh b/scripts/opensql/capture_live_ha_contract.sh new file mode 100755 index 0000000..edc4b9c --- /dev/null +++ b/scripts/opensql/capture_live_ha_contract.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Capture public-safe contract evidence from the three existing DocGrid VMs. +set -euo pipefail + +: "${OPENSQL_GCP_ZONE:?Set the zone containing the existing three VMs}" +: "${OPENSQL_SSH_KEY:?Set the SSH private-key path used for the existing VMs}" +: "${OPENSQL_EXPECTED_ACCOUNT:?Set the approved GCP account}" +: "${OPENSQL_EXPECTED_PROJECT:?Set the approved GCP project}" + +active_account="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" +active_project="$(gcloud config get-value project 2>/dev/null)" +if [[ "$active_account" != "$OPENSQL_EXPECTED_ACCOUNT" || + "$active_project" != "$OPENSQL_EXPECTED_PROJECT" ]]; then + echo 'The active GCP account or project differs from the approved target.' >&2 + exit 1 +fi + +repository_root="$(cd "$(dirname "$0")/../.." && pwd)" +collector="$repository_root/scripts/opensql/capture_ha_contract.py" +output="$repository_root/docs/test-results/opensql-contract-evidence" +temporary="$(mktemp -d)" +trap 'rm -r "$temporary"' EXIT +started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# 1. Read the container and VM separately; neither remote command prints raw config files. +for node in node1 node2 node3; do + vm="docgrid-$node" + gcloud compute ssh "$vm" --zone="$OPENSQL_GCP_ZONE" \ + --ssh-key-file="$OPENSQL_SSH_KEY" \ + --command="sudo docker exec --user opensql -i $vm python3 - collect --node $node" --quiet \ + < "$collector" > "$temporary/$node.collect.json" + gcloud compute ssh "$vm" --zone="$OPENSQL_GCP_ZONE" \ + --ssh-key-file="$OPENSQL_SSH_KEY" \ + --command="python3 - runtime --node $node" --quiet \ + < "$collector" > "$temporary/$node.runtime.json" + python3 "$collector" merge "$temporary/$node.collect.json" \ + "$temporary/$node.runtime.json" > "$temporary/$node.json" +done + +# 2. Read OpenProxy's effective settings without exporting its administrator credentials. +for pair in 'node2 proxy-a' 'node3 proxy-b'; do + read -r node proxy <<< "$pair" + vm="docgrid-$node" + gcloud compute ssh "$vm" --zone="$OPENSQL_GCP_ZONE" \ + --ssh-key-file="$OPENSQL_SSH_KEY" \ + --command="sudo docker exec -i $vm python3 - admin --node $node" --quiet \ + < "$collector" > "$temporary/$proxy-admin.json" +done + +# 3. Validate every sanitized input before replacing any published snapshot. +python3 "$collector" assemble "$temporary/node1.json" "$temporary/node2.json" \ + "$temporary/node3.json" --admin "$temporary/proxy-a-admin.json" \ + "$temporary/proxy-b-admin.json" > "$temporary/contract-manifest.json" +finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +jq --arg started "$started_at" --arg finished "$finished_at" \ + '. + {capture_started_at_utc: $started, capture_finished_at_utc: $finished}' \ + "$temporary/contract-manifest.json" > "$temporary/timed-manifest.json" +mv "$temporary/timed-manifest.json" "$temporary/contract-manifest.json" +for name in node1 node2 node3 proxy-a-admin proxy-b-admin contract-manifest; do + cp "$temporary/$name.json" "$output/$name.json" +done +echo 'Public-safe OpenSQL contract snapshots refreshed.' diff --git a/scripts/opensql/test_capture_ha_contract.py b/scripts/opensql/test_capture_ha_contract.py index 2084e32..a4150d6 100644 --- a/scripts/opensql/test_capture_ha_contract.py +++ b/scripts/opensql/test_capture_ha_contract.py @@ -89,6 +89,13 @@ def test_patroni_top_level_only_and_missing_keys_are_explicit(self): self.assertIsNone(dynamic["ttl"]) self.assertNotIn("do-not-publish", json.dumps(dynamic)) + def test_opensql_banner_version_is_extracted_without_copyright_text(self): + """The product version is not the first line of its --version banner.""" + banner = "###\n\nOpenSQL version v3.17.8.7\n\nCopyright notice\n" + with patch.object(CONTRACT.subprocess, "run", + return_value=SimpleNamespace(stdout=banner)): + self.assertEqual("v3.17.8.7", CONTRACT.opensql_version(self.root / "opensql")) + def test_assembly_is_order_independent_and_rejects_proxy_drift(self): """One canonical SHA-256 represents the same three sanitized nodes.""" paths = [] @@ -161,6 +168,100 @@ def test_admin_snapshot_whitelists_effective_config_and_counters(self): self.assertEqual(7, data["stats"][0]["queries"]) self.assertNotIn("do-not-publish", json.dumps(data)) + def test_etcd_snapshot_keeps_members_and_explicit_timing_without_urls(self): + """A live three-member result must not publish peer addresses or tokens.""" + environment = self.root / "etc/etcd/etcd.env" + environment.parent.mkdir(parents=True) + environment.write_text( + "ETCD_INITIAL_CLUSTER=etcd1=http://192.0.2.1:2380," + "etcd2=http://192.0.2.2:2380,etcd3=http://192.0.2.3:2380\n" + "ETCD_INITIAL_CLUSTER_STATE=existing\n" + "ETCD_INITIAL_CLUSTER_TOKEN=do-not-publish-token\n", + encoding="utf-8") + live = {"members": [{"name": f"etcd{number}", + "peerURLs": [f"http://192.0.2.{number}:2380"]} + for number in (1, 2, 3)]} + with patch.object(CONTRACT.subprocess, "run", + return_value=SimpleNamespace(stdout=json.dumps(live))): + with patch.object(CONTRACT, "etcd_timing_inputs", return_value={ + "heartbeat_interval_ms": {"value": 100, "source": "installed binary default"}, + "election_timeout_ms": {"value": 1000, "source": "installed binary default"}}): + settings = CONTRACT.etcd_settings(self.root) + self.assertEqual(2, settings["quorum"]) + self.assertIsNone(settings["explicit_timing"]["election_timeout_ms"]) + self.assertEqual(["node1", "node2", "node3"], settings["live_member_names"]) + self.assertNotIn("192.0.2", json.dumps(settings)) + self.assertNotIn("do-not-publish", json.dumps(settings)) + + def test_etcd_running_timing_uses_installed_defaults_or_process_override(self): + """Do not call an absent env override an effective default without checking /proc.""" + process = self.root / "proc/1234" + process.mkdir(parents=True) + process.joinpath("comm").write_text("etcd\n", encoding="utf-8") + process.joinpath("cmdline").write_bytes(b"/bin/etcd\0") + process.joinpath("environ").write_bytes(b"ETCD_NAME=etcd1\0") + binary_help = "--heartbeat-interval '100'\n--election-timeout '1000'\n" + with patch.object(CONTRACT.subprocess, "run", + return_value=SimpleNamespace(stdout=binary_help, stderr="")): + timing = CONTRACT.etcd_timing_inputs(self.root, self.root / "proc") + self.assertEqual(1000, timing["election_timeout_ms"]["value"]) + self.assertEqual("installed binary default", timing["election_timeout_ms"]["source"]) + process.joinpath("environ").write_bytes(b"ETCD_ELECTION_TIMEOUT=1500\0") + overridden = CONTRACT.etcd_timing_inputs(self.root, self.root / "proc") + self.assertEqual(1500, overridden["election_timeout_ms"]["value"]) + self.assertEqual("process override", overridden["election_timeout_ms"]["source"]) + + def test_merge_matches_node_and_assembly_hashes_admin_snapshots(self): + """Generated node/runtime joins and both admin snapshots enter one fingerprint.""" + paths = [] + for node in ("node1", "node2", "node3"): + collect = self.root / f"{node}-collect.json" + runtime = self.root / f"{node}-runtime.json" + collect.write_text(json.dumps({"schema_version": 1, "node": node, + "os": {"id": "rocky", "version_id": "9.7", "architecture": "x86_64"}, + "patroni_dynamic": {"ttl": 30}, + **({"openproxy": {"pool_mode": "transaction"}} if node != "node1" else {})}), + encoding="utf-8") + runtime.write_text(json.dumps({"node": node, "host_time_zone": "UTC+0000"}), + encoding="utf-8") + merged = CONTRACT.merge_node(collect, runtime) + path = self.root / f"{node}.json" + path.write_text(json.dumps(merged), encoding="utf-8") + paths.append(path) + admins = [] + for proxy, node in (("proxy-a", "node2"), ("proxy-b", "node3")): + path = self.root / f"{proxy}.json" + path.write_text(json.dumps({"proxy": proxy, "node": node, + "effective_config": {"pool_mode": "Transaction"}}), + encoding="utf-8") + admins.append(path) + before = CONTRACT.assemble(paths, admins)["evidence_sha256"] + changed = json.loads(admins[1].read_text()) + changed["effective_config"]["pool_mode"] = "Session" + admins[1].write_text(json.dumps(changed), encoding="utf-8") + with self.assertRaisesRegex(CONTRACT.ContractError, "관리 콘솔 설정"): + CONTRACT.assemble(paths, admins) + changed["effective_config"]["pool_mode"] = "Transaction" + changed["stats"] = [{"queries": 7}] + admins[1].write_text(json.dumps(changed), encoding="utf-8") + self.assertNotEqual(before, CONTRACT.assemble(paths, admins)["evidence_sha256"]) + + def test_junit_summary_drops_hostname_and_rejects_sensitive_output(self): + """Retain the case verdict while removing machine identity from Gradle XML.""" + path = self.root / "TEST-contract.xml" + path.write_text('' + 'CONTRACT_PREPARED proxy=proxy-a threshold=5' + '', encoding="utf-8") + summary = CONTRACT.junit_summary(path) + self.assertEqual(1, summary["tests"]) + self.assertEqual("passed", summary["cases"][0]["status"]) + self.assertNotIn("private-host", json.dumps(summary)) + path.write_text(path.read_text().replace("threshold=5", "password=do-not-publish"), + encoding="utf-8") + with self.assertRaisesRegex(CONTRACT.ContractError, "공개"): + CONTRACT.junit_summary(path) + if __name__ == "__main__": unittest.main() From cd345166c7efefcfc577314b114551694959b82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 22:01:18 +0900 Subject: [PATCH 5/9] docs: publish complete OpenSQL contract evidence --- ...proxy-routing-session-contract-20260926.md | 67 ++- .../contract-manifest.json | 391 ++++++++++++++++++ .../junit-summary.json | 56 +++ .../opensql-contract-evidence/node1.json | 48 ++- .../opensql-contract-evidence/node2.json | 48 ++- .../opensql-contract-evidence/node3.json | 48 ++- .../proxy-a-admin.json | 33 +- .../proxy-b-admin.json | 40 +- 8 files changed, 637 insertions(+), 94 deletions(-) create mode 100644 docs/test-results/opensql-contract-evidence/contract-manifest.json create mode 100644 docs/test-results/opensql-contract-evidence/junit-summary.json diff --git a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md index a5829e4..133130e 100644 --- a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md +++ b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md @@ -2,33 +2,35 @@ ## 목적과 판정 범위 -두 OpenProxy를 거친 실제 SQL의 primary/standby 역할, 트랜잭션 풀에서의 pgJDBC named prepared statement, 시간대 경계, HA 관련 설치·동적 설정을 **운영 데이터를 바꾸지 않고** 확인했다. 이 문서는 장애 전환, 물리 standby별 부하 분산, Hikari/JPA 전체 경로의 무중단성을 합격 처리하지 않는다. 기존 Hikari/JPA 쓰기 검증은 [별도 결과](gimin-opensql-openproxy-hikari-troubleshooting-20260924.md)를 참조한다. +두 OpenProxy를 거친 실제 SQL의 primary/standby 역할, SQL-level `PREPARE/EXECUTE`와 pgJDBC 프로토콜 준비문, 시간대 경계, HA 관련 설치·실행 설정을 **운영 데이터를 영구 변경하지 않고** 확인했다. 이 문서는 장애 전환, 물리 standby별 부하 분산, Hikari/JPA 전체 경로의 무중단성을 합격 처리하지 않는다. 기존 Hikari/JPA 쓰기 검증은 [별도 결과](gimin-opensql-openproxy-hikari-troubleshooting-20260924.md)를 참조한다. 공개 증거는 논리 별칭 `node1~3`, `proxy-a/b`만 사용한다. 프로젝트 ID, IP, 관리자·앱 암호, 라이선스 파일은 포함하지 않는다. 수집 당시 `node1`이 leader, `node2/3`이 streaming replica였고, 프록시는 각각 `node2/3`에서 실행됐다. 세 컨테이너 모두 Rocky Linux 9.7 `x86_64`였다. -실행 코드 기준은 `origin/develop@98324caeb176`에 이 PR의 테스트·수집기 변경을 더한 상태다. 최종 전체 계약 시험은 2026-09-26 07:37 UTC에 4개 테스트/실패 0건으로 완료됐다. 머신 고유명이 들어 있는 원본 XML은 공개하지 않았다. +실행 코드 기준은 `origin/develop@98324caeb176`에 이 PR의 테스트·수집기 커밋 `5ec0d53`을 더한 상태다. 최종 전체 계약 시험은 2026-09-26 12:46 UTC에 **5개 테스트/실패 0건**으로 완료됐다. 이후 Java import·주석만 정리하고 재컴파일했다. 머신 고유명이 들어 있는 Gradle XML은 커밋하지 않고, 테스트 이름·판정·허용된 관측값만 [JUnit 요약](opensql-contract-evidence/junit-summary.json)에 남겼다. ## 재현 절차 -1. 설치된 각 컨테이너에서 [`capture_ha_contract.py`](../../scripts/opensql/capture_ha_contract.py)의 `collect --node node1|node2|node3`를 실행한다. 이 동작은 설치 TOML의 허용된 키, `patronictl show-config/list`, 버전과 OS만 출력한다. 원본 TOML 전체를 복사하지 않는다. -2. 각 VM 호스트에서 같은 스크립트의 `runtime --node ...`를 실행해 Docker 재시작 정책, 호스트 bootstrap unit, 살아 있는 OpenProxy 프로세스의 부모 PID를 별도로 기록한다. 컨테이너 안의 systemd 템플릿을 실제 감독자로 해석하지 않는다. -3. `node2/3` 컨테이너에서 `admin --node ...`를 실행한다. 수집기는 관리자 암호를 컨테이너 안에서만 읽고 서비스 포트로 `SHOW CONFIG`, `SHOW STATS`, `SHOW SERVERS`를 읽는다. 허용된 설정값과 역할별 숫자만 출력한다. -4. 세 `collect` 결과를 `assemble node1.json node2.json node3.json`으로 대조한다. OS·Patroni 동적 설정·두 프록시의 설치 설정/버전 불일치와 민감 문자열은 실패시킨다. 공개 스냅샷의 canonical SHA-256은 `b9bb7dbd246c53265fe23cadd507e8224b92fc45bda16c836f340c92cac9841c`이다. -5. A/B JDBC URL과 최소 권한 앱 계정 환경 변수를 주입한 뒤 `./backend/gradlew -p backend openSqlOpenProxyContractTest`를 실행한다. 일반 `test`에서는 외부 클러스터 태그를 제외한다. URL의 `connectTimeout=5&socketTimeout=15`는 **이번 수동 시험에만** 적용했고, 앱 운영 URL 변경은 아니다. SSH 터널은 이 시험의 접근 경로일 뿐 장애·성능 측정 경로로 사용하지 않는다. +1. [`capture_live_ha_contract.sh`](../../scripts/opensql/capture_live_ha_contract.sh)에 승인된 GCP 계정·프로젝트, 기존 VM의 존과 SSH 키 경로를 환경 변수로 주고 실행한다. 스크립트는 실제 활성 계정·프로젝트가 지정한 대상과 다르면 시작 전에 종료한다. 각 Rocky 컨테이너에서는 [`capture_ha_contract.py`](../../scripts/opensql/capture_ha_contract.py)의 `collect`를 **etcd 실행 사용자**로 실행한다. OS·제품 버전, `patronictl show-config/list`, etcd 초기 멤버 파일·실행 멤버 목록·프로세스 입력·설치 바이너리 기본값, OpenProxy TOML의 허용된 키만 출력한다. 원본 설정 파일 전체는 복사하지 않는다. +2. 같은 자동 수집 과정에서 각 VM 호스트의 `runtime`을 별도로 실행해 Docker 재시작 정책, 호스트 bootstrap unit, 살아 있는 OpenProxy 프로세스의 부모 PID, VM 시간대를 기록한다. `merge`가 컨테이너·VM 결과의 노드 별칭을 검증하고 자동 병합한다. 기존 파일처럼 사람이 `runtime` 필드를 옮겨 적지 않는다. +3. `node2/3` 컨테이너의 `admin`은 관리자 암호를 **컨테이너 메모리에서만** 읽고 서비스 포트에서 `SHOW CONFIG`, `SHOW STATS`, `SHOW SERVERS`를 실행한다. 허용된 설정값과 역할별 누적 숫자만 출력한다. `proxy-a=node2`, `proxy-b=node3` 별칭도 수집기가 만든다. +4. `assemble`이 노드 3개와 관리자 결과 2개를 함께 대조하고 canonical JSON의 SHA-256을 만든다. OS·OpenSQL 제품 버전·Patroni 동적 설정·A/B 설치 및 관리 설정 불일치나 민감 문자열은 실패시킨다. 수집 시각은 **2026-09-26 12:57:23~12:58:14 UTC**, 공개 증거의 SHA-256은 `0a0b86eff71c4c24740a99ea0f334fca70e5c11e1a813c3a3bc646a825549794`이다. 해시는 공개 JSON의 무결성을 검사할 뿐 GCP가 발급한 원본 증명은 아니다. +5. A/B JDBC URL과 최소 권한 앱 계정 환경 변수를 주입해 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행한다. `junit` 수집 모드가 XML의 호스트명을 버리고 5개 시험의 이름·판정·허용된 `CONTRACT_*` 출력만 보존한다. `connectTimeout=5&socketTimeout=15`는 **이번 수동 시험 URL에만** 붙였고 앱 운영 URL 변경은 아니다. SSH 터널은 접근 경로일 뿐 장애·성능 측정 경로로 사용하지 않는다. -수집기의 비밀값 차단·중복 설정 감지·스냅샷 일치성은 `python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 6개 시험/실패 0건을 확인했다. +수집기의 비밀값 차단·중복 감지·제품 버전 추출·etcd 시간 설정 우선순위·자동 병합·관리값 포함 해시·JUnit 비식별화는 `python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 **11개 시험/실패 0건**을 확인했다. -공개 원본: [node1](opensql-contract-evidence/node1.json), [node2](opensql-contract-evidence/node2.json), [node3](opensql-contract-evidence/node3.json), [proxy-a 관리값](opensql-contract-evidence/proxy-a-admin.json), [proxy-b 관리값](opensql-contract-evidence/proxy-b-admin.json), [캐시 카운터 전후](opensql-contract-evidence/prepared-cache-delta.json). 원본 Gradle XML은 로컬 빌드 산출물이라 커밋하지 않았다. +공개한 **비식별 수집 결과**: [node1](opensql-contract-evidence/node1.json), [node2](opensql-contract-evidence/node2.json), [node3](opensql-contract-evidence/node3.json), [proxy-a 관리값](opensql-contract-evidence/proxy-a-admin.json), [proxy-b 관리값](opensql-contract-evidence/proxy-b-admin.json), [통합 manifest](opensql-contract-evidence/contract-manifest.json), [JUnit 요약](opensql-contract-evidence/junit-summary.json), [이전 캐시 카운터 전후](opensql-contract-evidence/prepared-cache-delta.json). 마지막 캐시 카운터 파일은 **이전 실행의 관측값**이며 이번 통합 스냅샷·JUnit 실행과 같은 시점의 수치가 아니다. 원본 설정과 Gradle XML은 민감정보·머신 고유명 때문에 공개하지 않는다. ## 설치·실행 계약 | 항목 | 실측 결과 | 해석 | | --- | --- | --- | -| 제품 | OpenProxy 1.1.3 revision 723, Patroni 4.0.5, etcd 3.6.5, PostgreSQL client 17.8 | 두 프록시의 설치 버전 동일 | +| 제품 | OpenSQL v3.17.8.7(세 노드), OpenProxy 1.1.3 revision 723(node2/3), Patroni 4.0.5, etcd 3.6.5, PostgreSQL 서버 실행 파일·클라이언트 17.8 | 세 노드의 설치 바이너리를 조회했다. 양쪽 프록시 JDBC 연결의 서버 메타데이터도 PostgreSQL 17.8이었다. | +| 앱 시험 의존성 | pgJDBC 42.7.11, HikariCP 6.3.3 | Gradle `dependencyInsight --configuration testRuntimeClasspath`로 각각의 해석된 버전을 조회했다. JDBC 실측의 드라이버 메타데이터도 42.7.11이다. 이 계약 시험은 `DriverManager`를 사용하므로 **Hikari를 실행해 시험한 것은 아니다.** | | Patroni 동적 설정 | `ttl=30`, `loop_wait=10`, `retry_timeout=10`, `maximum_lag_on_failover=1048576`, `failsafe_mode=true` | `primary_start_timeout`, 동기 복제 모드는 동적 설정에 명시되지 않음. 미설정을 `0`으로 해석하지 않는다. | +| etcd 멤버·시간 설정 | 초기 구성 이름과 실행 멤버 모두 `node1/2/3`, 정족수 `2`; heartbeat `100ms`, election timeout `1000ms` | 실행 프로세스의 관련 환경·인자에 재정의가 없고 설치 바이너리 `--help` 기본값이 위 수치였다. 파일의 `initial_cluster_state=new`는 **부트스트랩 입력값**이지 현재 클러스터가 새로 생성 중이라는 뜻은 아니다. | | DocGrid 풀 | `pool_mode=transaction`, `default_role=primary`, query parser와 read/write splitting 활성, `primary_reads_enabled=false`, standby 선택 모드 `Random` | A/B 설치 파일과 관리 콘솔의 공통 항목이 일치한다. 물리 standby별 요청 비율을 이번 SQL 시험으로 판정하지는 않는다. | | 캐시 설정 | 설치 TOML 일반 섹션 `prepared_statements_cache_size=1000`; `SHOW CONFIG`의 **풀 항목**은 `0` | 두 표기가 상충한다. `0`을 “실제 캐시 완전 비활성”이라고 단정하지 않는다. 아래 실측과 공급사 확인이 필요하다. | -| 실행 감독 | 컨테이너 정책 `unless-stopped`; 호스트 bootstrap unit `inactive`; 살아 있는 OpenProxy의 부모는 컨테이너 PID 1 | 패키지에 동봉된 `Restart=always`, `RestartSec=1` systemd **예시 파일**이 현재 프로세스를 감독한다는 증거가 없다. 프로세스 종료 시 자동 재기동을 가정하지 않는다. | +| 실행 감독·OS 시간대 | 컨테이너 정책 `unless-stopped`; 호스트 bootstrap unit `inactive`; 살아 있는 OpenProxy의 부모는 컨테이너 PID 1. 세 VM·컨테이너 시간대 표기는 `UTC+0000` | 동봉된 `Restart=always`, `RestartSec=1` systemd **예시 파일**이 현재 프로세스를 감독한다는 증거가 없다. OpenProxy 프로세스 종료 후 자동 재시작 방식·시간은 장애 시험에서 따로 잰다. | 관리 설정의 `admin_port=6433`은 컨테이너 localhost에서 열려 있지 않았고, 실제 관리 `SHOW` 명령은 서비스 포트 `6432`로 성공했다. 이 차이는 설치 빌드의 관리 포트 의미를 추가 확인할 항목이다. @@ -43,13 +45,48 @@ 따라서 권한 회수 후 최신 역할을 반드시 읽어야 하는 경로에 read-only 어노테이션만 붙이는 수정은 금지한다. 전용 primary 경로 또는 명시적인 라우팅 정책을 별도로 설계·검증해야 한다. OpenProxy가 잘못 동작했다는 결론이 아니라, 애플리케이션의 일관성 경계를 제품 라우팅과 맞춰야 한다는 결론이다. -## pgJDBC prepared statement와 시간대 +## SQL-level·pgJDBC prepared statement 구분 -각 프록시에서 `prepareThreshold=5`와 `1`로 같은 JDBC PreparedStatement를 15회, 트랜잭션을 매번 커밋하며 반복했다. 네 조합 모두 결과값이 정확했고, 드라이버의 server-prepare 플래그가 켜졌으며, 마지막 트랜잭션의 DB `pg_prepared_statements`도 0보다 컸다. `SHOW SERVERS`에는 캐시 hit/miss가 관측됐다. 별도 반복 시험 전후의 합산 카운터는 양 프록시 모두 **hit +224, miss +80, eviction 0**이었다. 이는 캐시 경로가 사용된다는 강한 정황이지만, 카운터는 프록시 전체의 누적값이며 다른 트래픽을 완전히 격리하지 않았다. +두 방식은 이름이 비슷하지만 다른 계약이다. SQL-level 방식은 애플리케이션이 서버 SQL 명령 `PREPARE 이름(integer) AS SELECT ...`, `EXECUTE 이름(41)`, `DEALLOCATE 이름`을 명시적으로 보낸다. 이번 시험은 A/B 모두 **같은 트랜잭션 안에서** 값 41을 확인하고 정리한 뒤 rollback했다. 이는 SQL-level 준비문이 여러 트랜잭션이나 서로 다른 DB backend에서도 유지된다는 증거는 아니다. -한계가 중요하다. 반복 중 각 JDBC 연결에서 관측한 DB backend는 **1개**였다. 따라서 **서로 다른 backend로 옮겨 간 named statement의 재준비/캐시 적중은 아직 검증하지 않았다.** `SHOW CONFIG` 풀 값 `0`과 일반 TOML `1000`의 의미도 이번 시험만으로 확정하지 않는다. 공급사에 우선 확인하고, 이후 backend 교체를 강제한 회귀 시험을 추가한다. +pgJDBC 프로토콜 방식은 JDBC `PreparedStatement`가 확장 쿼리 프로토콜을 사용한다. 각 프록시에서 `prepareThreshold=5`와 `1`로 같은 SQL을 15회, 트랜잭션을 매번 커밋하며 반복했다. 네 조합 모두 결과가 정확했고, 드라이버의 server-prepare 플래그와 마지막 backend의 `pg_prepared_statements > 0`을 **assert**했다. 최종 실측에서 네 조합의 서로 다른 backend 수는 각각 **1개**였다. 이전 [캐시 카운터 실험](opensql-contract-evidence/prepared-cache-delta.json)의 합산값은 각 프록시 hit +224, miss +80, eviction 0이었지만, 이번 최종 5개 JUnit 시험과 시점이 달라 수치를 합치지 않는다. -양 프록시에서 `SHOW TimeZone=Asia/Seoul`이었고, 한국 시간 자정 직후의 `timestamp` 벽시계값과 `timestamptz` 순간값이 왕복 보존됐다. 트랜잭션 안의 `SET LOCAL TIME ZONE 'Pacific/Honolulu'`는 rollback 뒤 다음 요청에 남지 않았다. 전체 애플리케이션의 날짜 직렬화나 모든 세션 설정을 보증하는 결과는 아니다. +따라서 **backend 교체 시 named statement 재준비·캐시 적중은 아직 검증하지 않았다.** `SHOW CONFIG` 풀 값 `0`과 일반 TOML `1000`의 우선순위도 이번 시험만으로 확정하지 않는다. 공급사에 확인하고, 이후 backend 교체를 강제한 별도 회귀 시험이 필요하다. + +## JVM·DB·OS 시간대와 날짜 경계 + +시험 JVM 기본 시간대는 `Asia/Seoul`, 양쪽 프록시를 거친 DB 세션의 `SHOW TimeZone`도 `Asia/Seoul`이었다. 반면 세 VM 호스트와 Rocky 컨테이너의 OS 시간대는 `UTC+0000`이었다. **시간대 설정이 전부 동일했던 것은 아니다.** 그 조건에서 한국 시간 `2026-09-27 00:00:01`의 `timestamp` 벽시계값과 `timestamptz`가 나타내는 순간이 A/B에서 각각 왕복 보존됐다. 트랜잭션 안의 `SET LOCAL TIME ZONE 'Pacific/Honolulu'`는 rollback 뒤 다음 요청에 남지 않았다. 전체 애플리케이션의 JSON 직렬화·모든 날짜 유형·다른 JVM 기본 시간대까지 보증하는 결과는 아니다. + +## 이번 계약 시험의 완료 판정 + +아래의 ‘완료’는 **처음 정한 라우팅·세션 계약 확인 항목**에 대한 판정이지, 장애·부하 시험까지 합격했다는 뜻이 아니다. + +| 성공 기준 | 판정과 증거 | 주장하지 않는 범위 | +| --- | --- | --- | +| 1. OpenSQL·OpenProxy·Patroni·etcd·PostgreSQL·pgJDBC·Hikari 버전 | 완료. 노드 JSON의 설치 바이너리·JDBC 서버/드라이버 메타데이터·Gradle 해석 결과를 기록했다. | Hikari를 이 시험에서 실행한 것은 아니다. | +| 2. Patroni·etcd·OpenProxy·systemd 관련 설정 | 완료. Patroni 동적 설정, etcd 멤버 및 실행 프로세스·바이너리 시간 입력, OpenProxy 설치·관리 설정, 실제 Docker/호스트 unit 상태를 구분해 기록했다. | `primary_start_timeout`처럼 명시되지 않은 Patroni 값과 이후 장애 동작은 추정하지 않는다. | +| 3. 프록시 A/B 계약 설정 일치 | 완료. `assemble`이 설치 TOML 허용 항목과 `SHOW CONFIG` 허용 항목을 각각 비교했다. | 모든 비밀값·주소를 포함한 원본 파일의 byte-for-byte 일치는 공개하지 않는다. | +| 4. A/B 명시적 쓰기 트랜잭션 → primary | 완료. 두 프록시에서 0행 UPDATE를 같은 트랜잭션에서 실행하고 rollback했다. | 실제 대량 쓰기 처리량은 측정하지 않았다. | +| 5. `prepareThreshold=5/1` 반복 | 완료. 양 프록시 × 두 임계값 × 15회, 결과·server prepare·마지막 backend 준비문 존재를 확인했다. | backend 교체 후의 재준비는 미검증이다. | +| 6. 자동 커밋·read-only·read-write 도착 역할 | 완료. JDBC 관측값은 각각 standby·standby·primary였다. | Spring `@Transactional` 전체 호출 경로와 물리 standby별 분포는 미검증이다. | +| 7. SQL-level과 프로토콜 준비문 구분 | 완료. `Statement`의 SQL-level `PREPARE/EXECUTE/DEALLOCATE`와 `PGStatement`의 프로토콜 server prepare를 별도 시험했다. | SQL-level 준비문의 트랜잭션 간·backend 간 유지 여부는 미검증이다. | +| 8. JVM·DB·OS 시간대와 경계 | 완료. JVM/DB `Asia/Seoul`, VM/컨테이너 `UTC+0000`을 기록하고 양 프록시의 자정 경계·`SET LOCAL` 복원을 확인했다. | 모든 날짜 직렬화 경로까지 검증하지 않았다. | +| 9. 비식별 canonical JSON·SHA-256 | 완료. 새 자동 수집·병합 경로가 노드 3개와 관리자 결과 2개를 검증·해시한다. | 해시는 수집 코드와 공개 파일의 일관성 지표이지 클라우드 제공자의 서명은 아니다. | +| 10. 후속 HA 판정 기준 | 완료. 바로 아래에 사전조건·통과·경고·즉시 중단 기준을 명시했다. | 장애 결과가 나왔다는 뜻은 아니다. | + +## 후속 HA 시험의 사전 판정 기준 + +아래 시간은 **DocGrid의 시험 목표값**이며 OpenSQL의 제품 보장 수치가 아니다. 부하 발생기·앱은 DB 노드 밖의 GCP 내부에서 실행한다. 모든 요청의 `request_id`, HTTP 결과와 최종 DB 반영을 대조한 뒤 판정한다. RTO는 장애 시각이 아니라 **마지막 정상 성공부터 30초 연속 안정 구간의 시작까지**로 정의한다. RPO는 성공 응답을 받은 `request_id` 중 최종 DB에 없는 개수로 보고한다. 재시도 주체(HTTP 부하 발생기·앱·Worker)를 각각 구분한다. + +| 시험 | 시작 전 필수 확인 | 통과 목표 | 경고·실패 또는 즉시 중단 | +| --- | --- | --- | --- | +| OpenProxy A/B 지속 중단 | A/B 설정 일치, 양쪽 경로에서 위 5개 계약 시험 통과, 정상 최대 안정 처리량 측정 후 60~70% 부하, 실제 앱 URL의 유한한 연결·소켓 제한시간 확인 | 한쪽 중단 후 새 연결이 다른 프록시로 도달, RTO 30초 이내, 성공 응답 누락·중복 0건, 오류·결과 불명 요청 수 공개 | RTO 30~60초 경고, 60초 초과 실패. 상대 프록시에도 접속하지 못하거나 성공 응답이 사라지면 즉시 중단한다. | +| PostgreSQL 프로세스 종료 | Patroni 역할·timeline·WAL LSN 기록, 복구 경로 확인 | 같은 노드 재시작인지 새 리더 선출인지 **실제 결과로** 분류, RTO 60초 이내, 성공 응답 누락·중복 0건 | 프로세스 종료를 곧바로 ‘새 리더 선출’로 세지 않는다. 이중 writable primary 또는 원장 대조 불가능 시 즉시 중단한다. | +| 리더 VM 상실 | 기존 리더 격리 방식·복구 절차 확정, Patroni/etcd 건강 상태 3/3, 요청 원장 DB 밖 보관 | 새 단일 리더 선출과 라우팅 회복 RTO 120초 이내, RPO와 모든 실패·재시도 건수 공개 | RTO 초과 실패. 비동기 복제에서 RPO>0이면 **DocGrid의 무손실 목표 실패**로 보고하고 제품이 무조건 0손실을 보장한다고 주장하지 않는다. 이중 리더면 즉시 중단한다. | +| Worker 인덱싱 중 리더 상실 | 시험 전용 pause 지점과 lease 만료·재처리 관측, 최종 DB 중복 키 점검 | 최종 완료 문서·청크·임베딩·Outbox 누락 및 중복 0건 | 최종 상태 불일치나 재처리 불능이면 실패. 미완료 작업을 지우고 재시도 성공으로 꾸미지 않는다. | +| etcd 멤버 장애 | 스냅샷·복구 절차 검증 후 별도 실행, `failsafe_mode=true`와 정족수 2/3 확인 | 1대 상실과 2대 상실을 구분하고 단일 writable primary·복구 후 정상 복제를 증명 | 2대 상실 시 무조건 쓰기 정지를 기대하지 않는다. 이중 리더, DCS 복구 불능, 스냅샷 부재 시 즉시 중단한다. | + +공통으로 요청 실패율·p95/p99는 **요청 표본**에서 계산하고, 5회 장애 반복의 RTO는 개별 값과 범위를 제시한다. 반복 5개의 p95를 성능 지표처럼 사용하지 않는다. 장애 주입 전 Patroni 멤버가 3/3 정상·etcd 멤버가 3/3 정상·두 프록시 관리 상태가 정상이라는 사전조건을 충족하지 못하면 주입하지 않는다. 실패 후에는 원래 토폴로지·설정·복제 상태로 돌아왔는지도 별도로 판정한다. ## 다음 작업의 전제와 미검증 사항 diff --git a/docs/test-results/opensql-contract-evidence/contract-manifest.json b/docs/test-results/opensql-contract-evidence/contract-manifest.json new file mode 100644 index 0000000..d227d3b --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/contract-manifest.json @@ -0,0 +1,391 @@ +{ + "evidence_sha256": "0a0b86eff71c4c24740a99ea0f334fca70e5c11e1a813c3a3bc646a825549794", + "snapshot": { + "admins": [ + { + "effective_config": { + "connect_timeout": 10000, + "pools.docgrid.default_role": "primary", + "pools.docgrid.load_balancing_mode": "Random", + "pools.docgrid.pool_mode": "Transaction", + "pools.docgrid.prepared_statements_cache_size": 0, + "pools.docgrid.primary_reads_enabled": false, + "pools.docgrid.query_parser_enabled": true, + "pools.docgrid.query_parser_read_write_splitting": true, + "shutdown_timeout": 60000 + }, + "node": "node2", + "proxy": "proxy-a", + "servers": [], + "stats": [ + { + "errors": 0, + "queries": 1068, + "role": "primary", + "transactions": 333 + }, + { + "errors": 0, + "queries": 44, + "role": "replica_0", + "transactions": 32 + }, + { + "errors": 0, + "queries": 60, + "role": "replica_1", + "transactions": 40 + } + ] + }, + { + "effective_config": { + "connect_timeout": 10000, + "pools.docgrid.default_role": "primary", + "pools.docgrid.load_balancing_mode": "Random", + "pools.docgrid.pool_mode": "Transaction", + "pools.docgrid.prepared_statements_cache_size": 0, + "pools.docgrid.primary_reads_enabled": false, + "pools.docgrid.query_parser_enabled": true, + "pools.docgrid.query_parser_read_write_splitting": true, + "shutdown_timeout": 60000 + }, + "node": "node3", + "proxy": "proxy-b", + "servers": [], + "stats": [ + { + "errors": 0, + "queries": 1387, + "role": "primary", + "transactions": 469 + }, + { + "errors": 0, + "queries": 127, + "role": "replica_0", + "transactions": 87 + }, + { + "errors": 0, + "queries": 112, + "role": "replica_1", + "transactions": 94 + } + ] + } + ], + "nodes": [ + { + "container_time_zone": "UTC+0000", + "etcd": { + "configured_member_names": [ + "node1", + "node2", + "node3" + ], + "explicit_timing": { + "election_timeout_ms": null, + "heartbeat_interval_ms": null + }, + "initial_cluster_state": "new", + "live_member_names": [ + "node1", + "node2", + "node3" + ], + "quorum": 2, + "running_timing": { + "election_timeout_ms": { + "source": "installed binary default", + "value": 1000 + }, + "heartbeat_interval_ms": { + "source": "installed binary default", + "value": 100 + } + } + }, + "node": "node1", + "os": { + "architecture": "x86_64", + "id": "rocky", + "version_id": "9.7" + }, + "patroni_dynamic": { + "check_timeline": null, + "failsafe_mode": true, + "loop_wait": 10, + "maximum_lag_on_failover": 1048576, + "primary_start_timeout": null, + "primary_stop_timeout": null, + "retry_timeout": 10, + "synchronous_mode": null, + "synchronous_mode_strict": null, + "ttl": 30 + }, + "patroni_members": [ + { + "node": "node1", + "role": "Leader", + "state": "running" + }, + { + "node": "node2", + "role": "Replica", + "state": "streaming" + }, + { + "node": "node3", + "role": "Replica", + "state": "streaming" + } + ], + "runtime": { + "container_restart_policy": "unless-stopped", + "host_bootstrap_unit": { + "ActiveState": "inactive", + "Restart": "on-failure", + "RestartUSec": "15s" + }, + "host_time_zone": "UTC+0000", + "openproxy_live_process_count": 0, + "openproxy_parent_is_container_pid1": null + }, + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": null, + "openproxy_revision": null, + "opensql": "v3.17.8.7", + "patroni": "patroni 4.0.5", + "postgres_server_binary": "postgres (PostgreSQL) 17.8", + "psql_client": "psql (PostgreSQL) 17.8" + } + }, + { + "container_time_zone": "UTC+0000", + "etcd": { + "configured_member_names": [ + "node1", + "node2", + "node3" + ], + "explicit_timing": { + "election_timeout_ms": null, + "heartbeat_interval_ms": null + }, + "initial_cluster_state": "new", + "live_member_names": [ + "node1", + "node2", + "node3" + ], + "quorum": 2, + "running_timing": { + "election_timeout_ms": { + "source": "installed binary default", + "value": 1000 + }, + "heartbeat_interval_ms": { + "source": "installed binary default", + "value": 100 + } + } + }, + "node": "node2", + "openproxy": { + "general": { + "connect_timeout": 10000, + "prepared_statements_cache_size": 1000 + }, + "pools.docgrid": { + "default_role": "primary", + "pool_mode": "transaction", + "query_parser_enabled": true, + "query_parser_read_write_splitting": true + }, + "pools.docgrid.shards.0": { + "patroni_port": "8008", + "use_patroni": true + }, + "pools.docgrid.users.0": { + "pool_size": 5 + } + }, + "openproxy_service_template": { + "Restart": "always", + "RestartSec": "1" + }, + "os": { + "architecture": "x86_64", + "id": "rocky", + "version_id": "9.7" + }, + "patroni_dynamic": { + "check_timeline": null, + "failsafe_mode": true, + "loop_wait": 10, + "maximum_lag_on_failover": 1048576, + "primary_start_timeout": null, + "primary_stop_timeout": null, + "retry_timeout": 10, + "synchronous_mode": null, + "synchronous_mode_strict": null, + "ttl": 30 + }, + "patroni_members": [ + { + "node": "node1", + "role": "Leader", + "state": "running" + }, + { + "node": "node2", + "role": "Replica", + "state": "streaming" + }, + { + "node": "node3", + "role": "Replica", + "state": "streaming" + } + ], + "runtime": { + "container_restart_policy": "unless-stopped", + "host_bootstrap_unit": { + "ActiveState": "inactive", + "Restart": "on-failure", + "RestartUSec": "15s" + }, + "host_time_zone": "UTC+0000", + "openproxy_live_process_count": 1, + "openproxy_parent_is_container_pid1": true + }, + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": "openproxy 1.1.3", + "openproxy_revision": "revision number: 723", + "opensql": "v3.17.8.7", + "patroni": "patroni 4.0.5", + "postgres_server_binary": "postgres (PostgreSQL) 17.8", + "psql_client": "psql (PostgreSQL) 17.8" + } + }, + { + "container_time_zone": "UTC+0000", + "etcd": { + "configured_member_names": [ + "node1", + "node2", + "node3" + ], + "explicit_timing": { + "election_timeout_ms": null, + "heartbeat_interval_ms": null + }, + "initial_cluster_state": "new", + "live_member_names": [ + "node1", + "node2", + "node3" + ], + "quorum": 2, + "running_timing": { + "election_timeout_ms": { + "source": "installed binary default", + "value": 1000 + }, + "heartbeat_interval_ms": { + "source": "installed binary default", + "value": 100 + } + } + }, + "node": "node3", + "openproxy": { + "general": { + "connect_timeout": 10000, + "prepared_statements_cache_size": 1000 + }, + "pools.docgrid": { + "default_role": "primary", + "pool_mode": "transaction", + "query_parser_enabled": true, + "query_parser_read_write_splitting": true + }, + "pools.docgrid.shards.0": { + "patroni_port": "8008", + "use_patroni": true + }, + "pools.docgrid.users.0": { + "pool_size": 5 + } + }, + "openproxy_service_template": { + "Restart": "always", + "RestartSec": "1" + }, + "os": { + "architecture": "x86_64", + "id": "rocky", + "version_id": "9.7" + }, + "patroni_dynamic": { + "check_timeline": null, + "failsafe_mode": true, + "loop_wait": 10, + "maximum_lag_on_failover": 1048576, + "primary_start_timeout": null, + "primary_stop_timeout": null, + "retry_timeout": 10, + "synchronous_mode": null, + "synchronous_mode_strict": null, + "ttl": 30 + }, + "patroni_members": [ + { + "node": "node1", + "role": "Leader", + "state": "running" + }, + { + "node": "node2", + "role": "Replica", + "state": "streaming" + }, + { + "node": "node3", + "role": "Replica", + "state": "streaming" + } + ], + "runtime": { + "container_restart_policy": "unless-stopped", + "host_bootstrap_unit": { + "ActiveState": "inactive", + "Restart": "on-failure", + "RestartUSec": "15s" + }, + "host_time_zone": "UTC+0000", + "openproxy_live_process_count": 1, + "openproxy_parent_is_container_pid1": true + }, + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": "openproxy 1.1.3", + "openproxy_revision": "revision number: 723", + "opensql": "v3.17.8.7", + "patroni": "patroni 4.0.5", + "postgres_server_binary": "postgres (PostgreSQL) 17.8", + "psql_client": "psql (PostgreSQL) 17.8" + } + } + ], + "schema_version": 1 + }, + "capture_started_at_utc": "2026-09-26T12:57:23Z", + "capture_finished_at_utc": "2026-09-26T12:58:14Z" +} diff --git a/docs/test-results/opensql-contract-evidence/junit-summary.json b/docs/test-results/opensql-contract-evidence/junit-summary.json new file mode 100644 index 0000000..d18babb --- /dev/null +++ b/docs/test-results/opensql-contract-evidence/junit-summary.json @@ -0,0 +1,56 @@ +{ + "cases": [ + { + "name": "timestamp와 timestamptz의 한국 시간 자정 경계를 보존한다", + "status": "passed" + }, + { + "name": "SQL-level PREPARE/EXECUTE를 pgJDBC 프로토콜 준비문과 구별한다", + "status": "passed" + }, + { + "name": "기본·강제 server-side prepared statement를 여러 트랜잭션에서 반복한다", + "status": "passed" + }, + { + "name": "자동 커밋 조회와 명시적 읽기·쓰기 트랜잭션의 DB 역할을 구분한다", + "status": "passed" + }, + { + "name": "Worker의 FOR UPDATE SKIP LOCKED 조회는 standby에서 거부되지 않는다", + "status": "passed" + } + ], + "duration_seconds": 74.608, + "errors": 0, + "executed_at_utc": "2026-09-26T12:46:25.416Z", + "failures": 0, + "observations": [ + "CONTRACT_TIMEZONE proxy=proxy-a jvm=Asia/Seoul db=Asia/Seoul boundary=pass", + "CONTRACT_SERVER proxy=proxy-a postgresql=17.8", + "CONTRACT_DRIVER proxy=proxy-a pgjdbc=42.7.11", + "CONTRACT_TIMEZONE proxy=proxy-b jvm=Asia/Seoul db=Asia/Seoul boundary=pass", + "CONTRACT_SERVER proxy=proxy-b postgresql=17.8", + "CONTRACT_DRIVER proxy=proxy-b pgjdbc=42.7.11", + "CONTRACT_SQL_PREPARE proxy=proxy-a result=same_transaction_pass", + "CONTRACT_SQL_PREPARE proxy=proxy-b result=same_transaction_pass", + "CONTRACT_PREPARED proxy=proxy-a threshold=5 executions=15 distinct_backends=1 prepared_on_last_backend=22", + "CONTRACT_PREPARED proxy=proxy-a threshold=1 executions=15 distinct_backends=1 prepared_on_last_backend=40", + "CONTRACT_PREPARED proxy=proxy-b threshold=5 executions=15 distinct_backends=1 prepared_on_last_backend=22", + "CONTRACT_PREPARED proxy=proxy-b threshold=1 executions=15 distinct_backends=1 prepared_on_last_backend=40", + "CONTRACT_ROUTE proxy=proxy-a operation=autocommit-select role=standby", + "CONTRACT_ROUTE proxy=proxy-a operation=read-write-transaction role=primary", + "CONTRACT_ROUTE proxy=proxy-a operation=read-only-transaction role=standby", + "CONTRACT_ROUTE proxy=proxy-b operation=autocommit-select role=standby", + "CONTRACT_ROUTE proxy=proxy-b operation=read-write-transaction role=primary", + "CONTRACT_ROUTE proxy=proxy-b operation=read-only-transaction role=standby", + "CONTRACT_LOCK proxy=proxy-a table=embedding_jobs route=primary", + "CONTRACT_LOCK proxy=proxy-a table=sync_outbox_events route=primary", + "CONTRACT_LOCK proxy=proxy-a table=rag_responses route=primary", + "CONTRACT_LOCK proxy=proxy-b table=embedding_jobs route=primary", + "CONTRACT_LOCK proxy=proxy-b table=sync_outbox_events route=primary", + "CONTRACT_LOCK proxy=proxy-b table=rag_responses route=primary" + ], + "skipped": 0, + "tests": 5 +} diff --git a/docs/test-results/opensql-contract-evidence/node1.json b/docs/test-results/opensql-contract-evidence/node1.json index e8c53f2..5f8beca 100644 --- a/docs/test-results/opensql-contract-evidence/node1.json +++ b/docs/test-results/opensql-contract-evidence/node1.json @@ -1,4 +1,33 @@ { + "container_time_zone": "UTC+0000", + "etcd": { + "configured_member_names": [ + "node1", + "node2", + "node3" + ], + "explicit_timing": { + "election_timeout_ms": null, + "heartbeat_interval_ms": null + }, + "initial_cluster_state": "new", + "live_member_names": [ + "node1", + "node2", + "node3" + ], + "quorum": 2, + "running_timing": { + "election_timeout_ms": { + "source": "installed binary default", + "value": 1000 + }, + "heartbeat_interval_ms": { + "source": "installed binary default", + "value": 100 + } + } + }, "node": "node1", "os": { "architecture": "x86_64", @@ -34,14 +63,6 @@ "state": "streaming" } ], - "schema_version": 1, - "versions": { - "etcd": "etcd Version: 3.6.5", - "openproxy": null, - "openproxy_revision": null, - "patroni": "patroni 4.0.5", - "psql_client": "psql (PostgreSQL) 17.8" - }, "runtime": { "container_restart_policy": "unless-stopped", "host_bootstrap_unit": { @@ -49,7 +70,18 @@ "Restart": "on-failure", "RestartUSec": "15s" }, + "host_time_zone": "UTC+0000", "openproxy_live_process_count": 0, "openproxy_parent_is_container_pid1": null + }, + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": null, + "openproxy_revision": null, + "opensql": "v3.17.8.7", + "patroni": "patroni 4.0.5", + "postgres_server_binary": "postgres (PostgreSQL) 17.8", + "psql_client": "psql (PostgreSQL) 17.8" } } diff --git a/docs/test-results/opensql-contract-evidence/node2.json b/docs/test-results/opensql-contract-evidence/node2.json index d56190e..508a956 100644 --- a/docs/test-results/opensql-contract-evidence/node2.json +++ b/docs/test-results/opensql-contract-evidence/node2.json @@ -1,4 +1,33 @@ { + "container_time_zone": "UTC+0000", + "etcd": { + "configured_member_names": [ + "node1", + "node2", + "node3" + ], + "explicit_timing": { + "election_timeout_ms": null, + "heartbeat_interval_ms": null + }, + "initial_cluster_state": "new", + "live_member_names": [ + "node1", + "node2", + "node3" + ], + "quorum": 2, + "running_timing": { + "election_timeout_ms": { + "source": "installed binary default", + "value": 1000 + }, + "heartbeat_interval_ms": { + "source": "installed binary default", + "value": 100 + } + } + }, "node": "node2", "openproxy": { "general": { @@ -57,14 +86,6 @@ "state": "streaming" } ], - "schema_version": 1, - "versions": { - "etcd": "etcd Version: 3.6.5", - "openproxy": "openproxy 1.1.3", - "openproxy_revision": "revision number: 723", - "patroni": "patroni 4.0.5", - "psql_client": "psql (PostgreSQL) 17.8" - }, "runtime": { "container_restart_policy": "unless-stopped", "host_bootstrap_unit": { @@ -72,7 +93,18 @@ "Restart": "on-failure", "RestartUSec": "15s" }, + "host_time_zone": "UTC+0000", "openproxy_live_process_count": 1, "openproxy_parent_is_container_pid1": true + }, + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": "openproxy 1.1.3", + "openproxy_revision": "revision number: 723", + "opensql": "v3.17.8.7", + "patroni": "patroni 4.0.5", + "postgres_server_binary": "postgres (PostgreSQL) 17.8", + "psql_client": "psql (PostgreSQL) 17.8" } } diff --git a/docs/test-results/opensql-contract-evidence/node3.json b/docs/test-results/opensql-contract-evidence/node3.json index da62263..44699f8 100644 --- a/docs/test-results/opensql-contract-evidence/node3.json +++ b/docs/test-results/opensql-contract-evidence/node3.json @@ -1,4 +1,33 @@ { + "container_time_zone": "UTC+0000", + "etcd": { + "configured_member_names": [ + "node1", + "node2", + "node3" + ], + "explicit_timing": { + "election_timeout_ms": null, + "heartbeat_interval_ms": null + }, + "initial_cluster_state": "new", + "live_member_names": [ + "node1", + "node2", + "node3" + ], + "quorum": 2, + "running_timing": { + "election_timeout_ms": { + "source": "installed binary default", + "value": 1000 + }, + "heartbeat_interval_ms": { + "source": "installed binary default", + "value": 100 + } + } + }, "node": "node3", "openproxy": { "general": { @@ -57,14 +86,6 @@ "state": "streaming" } ], - "schema_version": 1, - "versions": { - "etcd": "etcd Version: 3.6.5", - "openproxy": "openproxy 1.1.3", - "openproxy_revision": "revision number: 723", - "patroni": "patroni 4.0.5", - "psql_client": "psql (PostgreSQL) 17.8" - }, "runtime": { "container_restart_policy": "unless-stopped", "host_bootstrap_unit": { @@ -72,7 +93,18 @@ "Restart": "on-failure", "RestartUSec": "15s" }, + "host_time_zone": "UTC+0000", "openproxy_live_process_count": 1, "openproxy_parent_is_container_pid1": true + }, + "schema_version": 1, + "versions": { + "etcd": "etcd Version: 3.6.5", + "openproxy": "openproxy 1.1.3", + "openproxy_revision": "revision number: 723", + "opensql": "v3.17.8.7", + "patroni": "patroni 4.0.5", + "postgres_server_binary": "postgres (PostgreSQL) 17.8", + "psql_client": "psql (PostgreSQL) 17.8" } } diff --git a/docs/test-results/opensql-contract-evidence/proxy-a-admin.json b/docs/test-results/opensql-contract-evidence/proxy-a-admin.json index 1feb2d1..6b9b985 100644 --- a/docs/test-results/opensql-contract-evidence/proxy-a-admin.json +++ b/docs/test-results/opensql-contract-evidence/proxy-a-admin.json @@ -11,41 +11,26 @@ "shutdown_timeout": 60000 }, "node": "node2", - "servers": [ - { - "prepare_cache_eviction": 0, - "prepare_cache_hit": 499, - "prepare_cache_miss": 194, - "prepare_cache_size": 97, - "role": "primary" - }, - { - "prepare_cache_eviction": 0, - "prepare_cache_hit": 27, - "prepare_cache_miss": 18, - "prepare_cache_size": 9, - "role": "replica_1" - } - ], + "proxy": "proxy-a", + "servers": [], "stats": [ { "errors": 0, - "queries": 378, + "queries": 1068, "role": "primary", - "transactions": 129 + "transactions": 333 }, { "errors": 0, - "queries": 42, + "queries": 44, "role": "replica_0", - "transactions": 30 + "transactions": 32 }, { "errors": 0, - "queries": 47, + "queries": 60, "role": "replica_1", - "transactions": 33 + "transactions": 40 } - ], - "proxy": "proxy-a" + ] } diff --git a/docs/test-results/opensql-contract-evidence/proxy-b-admin.json b/docs/test-results/opensql-contract-evidence/proxy-b-admin.json index f803685..6f1f4bb 100644 --- a/docs/test-results/opensql-contract-evidence/proxy-b-admin.json +++ b/docs/test-results/opensql-contract-evidence/proxy-b-admin.json @@ -11,48 +11,26 @@ "shutdown_timeout": 60000 }, "node": "node3", - "servers": [ - { - "prepare_cache_eviction": 0, - "prepare_cache_hit": 490, - "prepare_cache_miss": 188, - "prepare_cache_size": 94, - "role": "primary" - }, - { - "prepare_cache_eviction": 0, - "prepare_cache_hit": 15, - "prepare_cache_miss": 10, - "prepare_cache_size": 5, - "role": "replica_0" - }, - { - "prepare_cache_eviction": 0, - "prepare_cache_hit": 9, - "prepare_cache_miss": 6, - "prepare_cache_size": 3, - "role": "replica_1" - } - ], + "proxy": "proxy-b", + "servers": [], "stats": [ { "errors": 0, - "queries": 697, + "queries": 1387, "role": "primary", - "transactions": 265 + "transactions": 469 }, { "errors": 0, - "queries": 119, + "queries": 127, "role": "replica_0", - "transactions": 83 + "transactions": 87 }, { "errors": 0, - "queries": 105, + "queries": 112, "role": "replica_1", - "transactions": 89 + "transactions": 94 } - ], - "proxy": "proxy-b" + ] } From 7c02a8c0ff81e12acabbf41cf5d335dc80da14dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 22:05:42 +0900 Subject: [PATCH 6/9] docs: distinguish contract results from local suite failure --- ...gimin-opensql-openproxy-routing-session-contract-20260926.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md index 133130e..05cd139 100644 --- a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md +++ b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md @@ -18,6 +18,8 @@ 수집기의 비밀값 차단·중복 감지·제품 버전 추출·etcd 시간 설정 우선순위·자동 병합·관리값 포함 해시·JUnit 비식별화는 `python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 **11개 시험/실패 0건**을 확인했다. +별도로 `./backend/gradlew -p backend test --offline`도 실행했으나 **1,124개 중 104개가 실패하여 전체 테스트는 통과하지 않았다.** 다수의 PostgreSQL 통합 테스트에서 `Connection refused`가 관측됐고 이 실행 환경에서는 로컬 Docker 데몬도 연결되지 않았다. 모든 실패가 동일 원인이라고 단정하지 않으며, 로컬 인프라를 복구한 뒤 전체 테스트를 다시 실행해야 한다. 이 결과를 위의 외부 클러스터 전용 5개 계약 시험 성공과 섞어 ‘전체 빌드 성공’이라고 주장하지 않는다. + 공개한 **비식별 수집 결과**: [node1](opensql-contract-evidence/node1.json), [node2](opensql-contract-evidence/node2.json), [node3](opensql-contract-evidence/node3.json), [proxy-a 관리값](opensql-contract-evidence/proxy-a-admin.json), [proxy-b 관리값](opensql-contract-evidence/proxy-b-admin.json), [통합 manifest](opensql-contract-evidence/contract-manifest.json), [JUnit 요약](opensql-contract-evidence/junit-summary.json), [이전 캐시 카운터 전후](opensql-contract-evidence/prepared-cache-delta.json). 마지막 캐시 카운터 파일은 **이전 실행의 관측값**이며 이번 통합 스냅샷·JUnit 실행과 같은 시점의 수치가 아니다. 원본 설정과 Gradle XML은 민감정보·머신 고유명 때문에 공개하지 않는다. ## 설치·실행 계약 From 6407aaa97efdfff59844fbfe761e72ab978cd2df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 22:21:40 +0900 Subject: [PATCH 7/9] docs: explain ten OpenSQL contract checks with commands and evidence --- ...proxy-routing-session-contract-20260926.md | 2 + .../autocommit-readonly-readwrite-routing.md | 35 ++++++++++++ .../explicit-write-transaction-primary.md | 52 ++++++++++++++++++ .../ha-fault-test-acceptance-gates.md | 40 ++++++++++++++ .../ha-settings-and-runtime-supervision.md | 53 +++++++++++++++++++ .../openproxy-a-b-config-parity.md | 42 +++++++++++++++ .../pgjdbc-prepared-threshold-repeat.md | 45 ++++++++++++++++ .../product-and-driver-versions.md | 48 +++++++++++++++++ .../sanitized-snapshot-and-sha256.md | 45 ++++++++++++++++ .../sql-prepare-vs-protocol-prepare.md | 30 +++++++++++ .../timezone-and-midnight-boundary.md | 36 +++++++++++++ 11 files changed, 428 insertions(+) create mode 100644 docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md create mode 100644 docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md create mode 100644 docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md create mode 100644 docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md create mode 100644 docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md create mode 100644 docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md create mode 100644 docs/test-results/opensql-contract-verification/product-and-driver-versions.md create mode 100644 docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md create mode 100644 docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md create mode 100644 docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md diff --git a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md index 05cd139..2afe10d 100644 --- a/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md +++ b/docs/test-results/gimin-opensql-openproxy-routing-session-contract-20260926.md @@ -76,6 +76,8 @@ pgJDBC 프로토콜 방식은 JDBC `PreparedStatement`가 확장 쿼리 프로 | 9. 비식별 canonical JSON·SHA-256 | 완료. 새 자동 수집·병합 경로가 노드 3개와 관리자 결과 2개를 검증·해시한다. | 해시는 수집 코드와 공개 파일의 일관성 지표이지 클라우드 제공자의 서명은 아니다. | | 10. 후속 HA 판정 기준 | 완료. 바로 아래에 사전조건·통과·경고·즉시 중단 기준을 명시했다. | 장애 결과가 나왔다는 뜻은 아니다. | +각 항목의 **실행 위치·명령/코드·목적·관측값·해석**은 별도의 상세 기록으로 나눴다: [제품·드라이버 버전](opensql-contract-verification/product-and-driver-versions.md), [HA 설정·실행 감독](opensql-contract-verification/ha-settings-and-runtime-supervision.md), [A/B 설정 일치](opensql-contract-verification/openproxy-a-b-config-parity.md), [쓰기 트랜잭션](opensql-contract-verification/explicit-write-transaction-primary.md), [pgJDBC 반복 준비문](opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md), [자동 커밋·read-only·read-write 라우팅](opensql-contract-verification/autocommit-readonly-readwrite-routing.md), [SQL-level·프로토콜 준비문 구분](opensql-contract-verification/sql-prepare-vs-protocol-prepare.md), [시간대·자정 경계](opensql-contract-verification/timezone-and-midnight-boundary.md), [비식별 JSON·해시](opensql-contract-verification/sanitized-snapshot-and-sha256.md), [후속 HA 판정 기준](opensql-contract-verification/ha-fault-test-acceptance-gates.md). 마지막 문서는 **실행한 장애 결과가 아니라 앞으로 적용할 판정 규칙**이다. + ## 후속 HA 시험의 사전 판정 기준 아래 시간은 **DocGrid의 시험 목표값**이며 OpenSQL의 제품 보장 수치가 아니다. 부하 발생기·앱은 DB 노드 밖의 GCP 내부에서 실행한다. 모든 요청의 `request_id`, HTTP 결과와 최종 DB 반영을 대조한 뒤 판정한다. RTO는 장애 시각이 아니라 **마지막 정상 성공부터 30초 연속 안정 구간의 시작까지**로 정의한다. RPO는 성공 응답을 받은 `request_id` 중 최종 DB에 없는 개수로 보고한다. 재시도 주체(HTTP 부하 발생기·앱·Worker)를 각각 구분한다. diff --git a/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md b/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md new file mode 100644 index 0000000..988e963 --- /dev/null +++ b/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md @@ -0,0 +1,35 @@ +# 자동 커밋·read-only·read-write JDBC 라우팅 확인 + +## 검증 질문 + +OpenProxy가 모든 SELECT를 primary로 보내는지, 자동 커밋 SELECT와 명시적 read-only/read-write 트랜잭션을 다르게 처리하는지 **실제 SQL 실행 DB의 역할**로 확인했다. 이 구분은 권한 회수 뒤 최신 역할을 읽어야 하는 경로와, 조금 늦어도 되는 조회 경로를 설계할 때 중요하다. + +## 실행 명령·위치 + +개발자 컴퓨터의 저장소 루트에서 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행했다. A/B JDBC URL은 임시 SSH 터널을 통해 GCP node2/node3 OpenProxy로 갔다. Java 코드는 **개발자 컴퓨터 JVM**에서 실행됐지만 `SELECT pg_is_in_recovery()`는 **프록시를 지난 DB 세션**에서 실행됐다. 이 구별 때문에 “로컬에서 SQL을 실행했다”라고만 표현하면 오해가 생긴다. + +[`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)의 실제 세 경로는 아래와 같다. + +| 경로 | JDBC 코드/원격 SQL | 왜 이렇게 했나 | +| --- | --- | --- | +| 자동 커밋 SELECT | 기본 auto-commit 연결에서 `SELECT pg_is_in_recovery()` | 단일 조회가 실제 어떤 역할의 DB에 도착하는지 본다. | +| 명시적 읽기·쓰기 | `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `UPDATE documents SET id=id WHERE id=-1` → `rollback()` | 쓰기 가능 트랜잭션이 primary에 선제적으로 고정되는지 확인하고 데이터 변경은 남기지 않는다. | +| JDBC read-only | `setReadOnly(true)` → `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `rollback()` | read-only 힌트가 primary 일관성을 보장하는지 **가정하지 않고** 측정한다. | + +SQL 함수 `pg_is_in_recovery()`가 `true`면 그 SQL이 standby에서 실행된 것이고, `false`면 primary에서 실행된 것이다. 이 결과는 관리 콘솔의 누적 통계보다 해당 요청의 역할을 직접 보여준다. + +## 실제 결과 + +| JDBC 경로 | proxy-a | proxy-b | 해석 | +| --- | --- | --- | --- | +| 자동 커밋 SELECT | standby | standby | 단순 SELECT가 두 프록시 모두에서 읽기 복제본으로 갔다. | +| 명시적 읽기·쓰기 | primary | primary | SELECT 단계부터 primary였고 뒤의 0행 UPDATE가 성공했다. | +| JDBC read-only 트랜잭션 | standby | standby | read-only라는 이유만으로 primary 최신성을 얻지 못했다. | + +위 6개 `CONTRACT_ROUTE` 출력은 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)에 그대로 남아 있다. 같은 실행의 `FOR UPDATE SKIP LOCKED` 세 테이블 × 두 프록시도 성공했지만, 그 시험은 잠금 SQL이 허용됐다는 **간접 primary 증거**이고 이 표의 `pg_is_in_recovery()` 직접 측정과는 증거 성격이 다르다. + +## 어떤 결론을 내릴 수 있나 + +이번 설치·설정에서는 자동 커밋 조회와 JDBC read-only 트랜잭션이 standby로 갈 수 있다. 따라서 권한 변경 직후 최신 역할을 반드시 확인해야 하는 로직에 단순히 `@Transactional(readOnly=true)`를 붙이면 안전해진다는 주장은 성립하지 않는다. 다만 **Spring 어노테이션을 붙인 실제 서비스 메서드**를 이 시험에서 호출한 것은 아니다. Spring/Hikari가 트랜잭션 시작 시 언제 연결을 빌리고 어떤 SQL을 앞세우는지는 후속 앱 통합 시험에서 확인해야 한다. + +또한 `standby`는 DB 역할만 뜻한다. 각 SELECT가 node2와 node3 중 어느 물리 standby로 갔는지, 두 standby로 균등 분산됐는지, 복제 지연이 얼마였는지는 이 JUnit으로 판정하지 않았다. `SHOW STATS`의 역할별 카운터는 누적값이고 각 테스트 요청과 1:1로 연결되지 않는다. 따라서 이 기준의 **라우팅 역할 확인은 완료**지만 **물리 노드별 로드밸런싱 효과는 미검증**이다. diff --git a/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md b/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md new file mode 100644 index 0000000..9f1ebc4 --- /dev/null +++ b/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md @@ -0,0 +1,52 @@ +# 명시적 쓰기 트랜잭션의 primary 도착 확인 + +## 검증하려는 계약 + +DocGrid가 두 OpenProxy 중 어느 쪽에 접속해도 **명시적 트랜잭션의 `SELECT → UPDATE`가 쓰기 가능한 primary에서 실행되는지** 확인했다. 이 테스트는 라우팅의 기능 기준선이며, 실제 문서를 수정하거나 쓰기 처리량을 측정하지 않는다. + +## 실행 위치와 명령 + +JUnit은 개발자 컴퓨터의 저장소 루트에서 실행했다. 두 개의 SSH 터널은 각각 기존 GCP node2의 OpenProxy A, node3의 OpenProxy B 서비스 포트로 연결했다. 터널은 원격 접근 수단일 뿐 HA·성능 측정 경로가 아니다. 실제 실행 명령의 테스트 부분은 다음과 같다. + +```bash +./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline +``` + +실행 시 `OPENSQL_PROXY_A_JDBC_URL`, `OPENSQL_PROXY_B_JDBC_URL`, `OPENSQL_APP_USER`, `OPENSQL_APP_PASSWORD`를 **셸 환경 변수**로 제공했다. A/B URL에는 이 수동 시험을 위한 `connectTimeout=5&socketTimeout=15`를 붙였다. 이 값으로 운영 `.env`를 변경했다는 뜻은 아니다. 암호는 공개 로그·Git에 넣지 않았다. 같은 한 번의 JUnit 실행에서 아래 SQL 테스트와 준비문·시간대 테스트 등 총 5개가 함께 돌았다. + +| 장소 | 수행 주체 | 수행 내용 | 이유 | +| --- | --- | --- | --- | +| 개발자 컴퓨터 JVM | [`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | A/B마다 JDBC 연결을 만들고 `setAutoCommit(false)` | 명시적 트랜잭션의 라우팅을 분리 측정한다. | +| A/B OpenProxy를 지난 DB 세션 | `SELECT pg_is_in_recovery()` | `false`면 현재 SQL이 primary에서 실행됐음을 직접 확인한다. | +| 같은 트랜잭션의 DB 세션 | `UPDATE documents SET id = id WHERE id = ?`에 `-1` 바인딩 | 쓰기 SQL도 같은 트랜잭션에서 허용되는지 확인한다. 존재하지 않는 ID라 0행이어야 한다. | +| 개발자 컴퓨터 JVM | `connection.rollback()` | 테스트가 운영 데이터를 남기지 않도록 한다. | + +핵심 코드의 실제 순서는 아래와 같다. 의도를 보여주기 위해 핵심 호출만 발췌했으며 전체 구현은 위 파일을 참조한다. + +```java +connection.setAutoCommit(false); +boolean standby = isStandby(connection); // SELECT pg_is_in_recovery() +assertThat(standby).isFalse(); +update.setLong(1, -1L); +assertThat(update.executeUpdate()).isZero(); +connection.rollback(); +``` + +추가로 같은 JUnit의 [`workerLockingSelectUsesPrimary()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)는 `embedding_jobs`, `sync_outbox_events`, `rag_responses`에서 `WHERE 1=0 ... FOR UPDATE SKIP LOCKED`를 A/B로 조회했다. 빈 결과이므로 행을 잠그거나 바꾸지는 않는다. standby에서는 `FOR UPDATE`가 허용되지 않으므로 **성공은 primary 라우팅의 간접 증거**다. 이 보조 시험에서는 `pg_is_in_recovery()`를 각 잠금 SQL 안에 넣어 직접 물리 역할을 출력한 것은 아니다. + +## 관측값과 해석 + +[JUnit 요약](../opensql-contract-evidence/junit-summary.json)에 다음 결과가 남았다. + +```text +CONTRACT_ROUTE proxy=proxy-a operation=read-write-transaction role=primary +CONTRACT_ROUTE proxy=proxy-b operation=read-write-transaction role=primary +CONTRACT_LOCK proxy=proxy-a table=embedding_jobs route=primary +CONTRACT_LOCK proxy=proxy-b table=embedding_jobs route=primary +``` + +잠금 조회는 세 테이블 × 두 프록시 모두 성공했다. 명시적 쓰기 트랜잭션 테스트는 두 프록시 모두 `pg_is_in_recovery()=false`, UPDATE 영향 행 `0`, rollback으로 통과했다. JUnit은 2026-09-26 12:46 UTC에 총 5개/실패 0건이었다. `SELECT`를 먼저 보냈을 때부터 트랜잭션이 primary에 도착했으므로, 뒤의 UPDATE가 standby에서 실패할 위험을 이 단순 경로에서는 관찰하지 않았다. + +## 판정 경계 + +**완료:** 양쪽 프록시에서 명시적 JDBC 쓰기 트랜잭션이 primary로 도착하고 무해한 UPDATE가 성공했다. **미검증:** DocGrid의 실제 Hikari·JPA 서비스 메서드 전체, 장애 직후 기존 커넥션 재연결, 대량 쓰기 처리량, 실제 Worker claim부터 commit까지의 흐름. UPDATE가 0행이라 실제 행 변경의 내구성을 증명하지 않는다. 장애나 재시도 없이 성공한 것을 HA 합격으로 부르지 않는다. diff --git a/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md b/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md new file mode 100644 index 0000000..8f2d7b2 --- /dev/null +++ b/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md @@ -0,0 +1,40 @@ +# 후속 HA 장애 시험의 사전 판정 기준 + +## 이 문서의 성격 + +이 항목의 “완료”는 **장애 주입 전에 통과·경고·실패·중단 기준을 정의했다**는 뜻이다. 이번 계약 확인에서는 OpenProxy 프로세스 종료, DB 프로세스 종료, 리더 VM 상실, etcd 정족수 상실을 **실행하지 않았다**. 따라서 여기에 RTO/RPO 실측값은 없다. 아래 시간은 DocGrid의 시험 목표이며 OpenSQL 제품 보장 수치가 아니다. + +## 실제로 실행한 확인 명령·위치 + +기준을 정하기 전에 개발자 컴퓨터의 저장소 루트에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행해 Patroni/etcd·OpenProxy 설정과 실행 감독을 수집했다. 이 문서를 작성할 때는 같은 worktree에서 다음 읽기 전용 명령으로 공개 [manifest](../opensql-contract-evidence/contract-manifest.json)의 판정 입력을 다시 확인했다. + +```bash +jq '{patroni: [.snapshot.nodes[] | {node, failsafe_mode: .patroni_dynamic.failsafe_mode, ttl: .patroni_dynamic.ttl, primary_start_timeout: .patroni_dynamic.primary_start_timeout}], proxies: [.snapshot.admins[] | {proxy, pool_mode: .effective_config["pools.docgrid.pool_mode"], connect_timeout: .effective_config.connect_timeout, shutdown_timeout: .effective_config.shutdown_timeout}]}' docs/test-results/opensql-contract-evidence/contract-manifest.json +``` + +결과는 세 노드 모두 `failsafe_mode=true`, `ttl=30`, `primary_start_timeout=null`; 프록시 A/B 모두 `pool_mode=Transaction`, `connect_timeout=10000`, `shutdown_timeout=60000`이었다. `primary_start_timeout=null`은 동적 설정에 명시되지 않았다는 뜻이지 값 `0`이 아니다. 설치 예시 systemd unit의 재시작 옵션과 실제 실행 감독은 [별도 설정 결과](ha-settings-and-runtime-supervision.md)에 구분했다. + +이 단계에서 **실행하지 않은 명령**도 명확히 적는다: `kill -9`, `systemctl stop`, 방화벽 `DROP`, VM stop/delete, `etcdctl member remove`, `pg_wal_replay_pause()` 등. 아래 표는 그 명령들을 실행한 결과가 아니라 안전하게 시험하기 위한 사전 약속이다. + +## 측정 규칙 + +- 부하 발생기와 앱은 DB 3노드 밖의 **GCP 내부**에서 돌린다. SSH 터널을 통해 장애 시간이나 p95/p99를 재지 않는다. +- 부하 강도는 정상 상태에서 찾은 최대 **안정** 처리량의 60~70%로 고정하고, 장애 전후에 같은 요청 유형·데이터셋을 쓴다. 아직 그 최대 안정 처리량은 측정하지 않았다. +- 요청마다 `request_id`를 DB 외부 원장과 최종 DB 양쪽에 남긴다. 성공 HTTP 응답을 받은 ID 중 최종 DB에 없는 수를 관측 RPO로 보고한다. 앱 HTTP→Hikari→OpenProxy를 거치게 할 probe 경로·테이블은 **후속 구현 대상**이고, 이번 PR에 없다. +- RTO는 장애 주입 시각부터가 아니라 **마지막 정상 성공 이후 30초 연속 안정 회복 구간의 시작까지**로 정의한다. 실패·결과 불명·재시도·중복 건수를 별도 계수한다. +- 요청 표본에서 p95/p99를 계산한다. 장애 반복 5개의 RTO에서 p95를 만들어 성능 지표처럼 쓰지 않고 개별 값·범위를 공개한다. +- 장애 직전 Patroni/etcd 각 3/3 정상, A/B 프록시 정상, 원복 절차·원장 보존을 확인하지 못하면 주입하지 않는다. 종료 후 원래 토폴로지·복제 건강·설정으로 돌아왔는지도 판정한다. + +## 장애별 통과·중단 기준 + +| 후속 시험 | 장애 유형을 구분할 이유 | DocGrid 통과 목표 | 경고·실패·즉시 중단 | +| --- | --- | --- | --- | +| OpenProxy A/B | 프로세스 kill은 재시작할 수 있고, 지속 stop은 다른 시험이며, 패킷 DROP은 응답 없이 매달릴 수 있다. | A 중단과 B 중단을 각각 반복. 새 연결이 살아 있는 프록시로 도달하고 RTO ≤30초, 성공 응답 누락·중복 0건. | 30~60초 경고, >60초 실패. 상대 프록시에도 못 붙거나 성공 응답이 사라지면 즉시 중단. | +| PostgreSQL 프로세스 종료 | Patroni가 **같은 노드 재시작**을 선택할 수 있으므로 “새 리더 선출”과 구분한다. | 실제 복구 유형을 기록하고 RTO ≤60초, 성공 응답 누락·중복 0건. | 이중 writable primary 또는 원장 대조 불가 시 즉시 중단. | +| 리더 VM 상실 | PostgreSQL 프로세스 종료와 달리 노드 자체가 빠져 TTL 이후 failover가 필요할 수 있다. | 새 단일 리더와 라우팅 회복 RTO ≤120초, 성공 응답 후 누락 ID 수 공개. | RTO 초과 실패. 비동기 복제로 RPO>0이면 DocGrid의 무손실 목표 실패; 이중 리더 즉시 중단. | +| Worker 인덱싱 중 리더 상실 | lease·Outbox·임베딩 저장의 중간 상태를 최종 결과와 구분해야 한다. | 최종 완료 문서·청크·임베딩·Outbox 누락/중복 0건. | 최종 상태 불일치·재처리 불가 실패. 시험 전용 pause 지점 구현 전에는 ‘임베딩 저장 중’ 주입을 주장하지 않는다. | +| etcd 멤버 장애 | 1대 상실(2/3)과 2대 상실(1/3)은 다르며 현재 `failsafe_mode=true`다. | 단일 writable primary 유지 여부와 복구 후 정상 복제를 기록. | 2대 상실 시 무조건 쓰기 정지를 기대하지 않는다. 이중 리더·복구 불능·사전 스냅샷 부재는 즉시 중단. | + +## 해석과 남은 일 + +이 기준은 [설치 설정](../opensql-contract-evidence/contract-manifest.json)과 [라우팅 JUnit](../opensql-contract-evidence/junit-summary.json)을 바탕으로 썼다. 하지만 “30초 안에 전환된다”, “성공 응답 손실이 0이다”는 아직 **가설이자 목표**다. HA 실험을 시작하려면 GCP 내부 부하 발생기, HTTP probe API/DB 원장, 장애 주입·복구 자동화, 사전 스냅샷을 구현하고, 각 장애 유형별 원본 요청/DB 대조값을 남겨야 한다. 특히 비동기 복제에서는 성공 응답한 쓰기가 리더 상실 후 사라질 가능성을 시험 전부터 인정한다. diff --git a/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md b/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md new file mode 100644 index 0000000..f91d4dc --- /dev/null +++ b/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md @@ -0,0 +1,53 @@ +# Patroni·etcd·OpenProxy·실행 감독 설정 확인 + +## 검증 질문 + +리더 장애·프록시 장애 시험의 결과를 해석하기 전에, 실제 설치 설정과 실행 감독자가 무엇인지 알아야 한다. 특히 `PostgreSQL 프로세스 종료 = 반드시 새 리더 선출`, `OpenProxy kill = 계속 꺼짐`, `etcd 2대 장애 = 무조건 쓰기 중단`처럼 단정하면 안 된다. 이 문서는 **장애 주입 전 설정 기준선**을 기록한다. 장애를 직접 발생시킨 결과는 아니다. + +## 실행 위치·명령·이유 + +개발자 컴퓨터의 저장소 루트에서 실제 수집 진입점 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했다. 승인 계정·프로젝트·존·SSH 키를 환경 변수로 주었고, 값은 공개 JSON이나 문서에 넣지 않았다. 이 스크립트가 `node1/2/3`마다 다음 두 호출을 분리해서 수행했다. + +```bash +# Rocky 컨테이너: 설치 설정, Patroni/etcd 상태, 바이너리 기본값 +sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN + +# 동일 VM 호스트: Docker 정책, host systemd unit, 실행 중 프로세스 +python3 - runtime --node nodeN +``` + +위 두 줄은 개발자 컴퓨터에서 SSH를 통해 **원격 실행된 명령의 본문**이다. `python3 -`의 표준 입력으로 [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)를 보냈다. 원격 호스트에 수집 스크립트를 영구 설치하지 않았다. 수집기 내부의 읽기 전용 호출은 아래와 같다. + +| 실행 장소 | 수집기 내부 호출·조회 | 왜 필요한가 | +| --- | --- | --- | +| 각 Rocky 컨테이너 | `patronictl -c <설치된 patroni.yml> show-config` 및 `list --format json` | 동적 HA 정책과 당시 3멤버 역할을 분리해서 확인한다. 전체 설정 파일은 공개하지 않는다. | +| 각 Rocky 컨테이너 | `etcdctl --endpoints=http://127.0.0.1:2379 member list --write-out=json` | 부트스트랩 파일에 적힌 3멤버가 아니라 **실행 중인** 멤버가 3개인지 확인한다. 주소는 결과에서 제거한다. | +| 각 Rocky 컨테이너 | 설치된 `etcd --help` + 실행 etcd 프로세스의 시간 관련 인자·환경 변수 검사 | `heartbeat-interval`, `election-timeout`이 명시 값인지 설치 바이너리 기본값인지 판별한다. 프로세스 인자 전체는 공개하지 않는다. | +| OpenProxy 설치 컨테이너 | `openproxy.toml`의 허용 키와 동봉 `openproxy.service`의 `Restart/RestartSec`만 읽기 | 풀·라우팅·캐시 값과 **예시 systemd 파일**의 내용을 기록한다. 암호·주소는 읽기 출력에 포함하지 않는다. | +| 각 GCP VM 호스트 | `sudo docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' docgrid-nodeN` | 컨테이너 재시작 정책을 확인한다. | +| 각 GCP VM 호스트 | `systemctl show docgrid-opensql@docgrid-nodeN.service -p ActiveState -p Restart -p RestartUSec` | 호스트의 실제 bootstrap unit 상태를 확인한다. 설치 예시 파일과 혼동하지 않는다. | +| 각 GCP VM 호스트에서 컨테이너 내부 조회 | `sudo docker exec docgrid-nodeN ps -eo ppid=,stat=,comm=` | 살아 있는 OpenProxy 개수와 부모가 컨테이너 PID 1인지 확인한다. | + +## 코드의 판별 방식 + +[`etcd_timing_inputs()`](../../../scripts/opensql/capture_ha_contract.py)은 실행 etcd 프로세스가 정확히 하나인지 확인하고, 시간 관련 CLI 인자와 환경 변수를 먼저 검사한다. 별도 `--config-file`이 있으면 우선순위가 불명확해 수집을 실패시킨다. 이번에는 override가 없어 해당 설치 바이너리 `--help` 기본값을 `source=installed binary default`로 저장했다. 이는 **실행 입력의 추론**이며 선출 시간을 재서 검증한 값은 아니다. + +[`runtime()`](../../../scripts/opensql/capture_ha_contract.py)은 Docker 정책과 host unit, 살아 있는 프록시 프로세스를 별도로 수집한다. 따라서 패키지에 동봉된 `Restart=always`를 실제 supervisor 정책으로 잘못 보고하지 않는다. + +## 관측 결과 + +| 항목 | 세 노드 또는 A/B의 관측값 | 결과 해석 | +| --- | --- | --- | +| Patroni | `ttl=30`, `loop_wait=10`, `retry_timeout=10`, `maximum_lag_on_failover=1048576`, `failsafe_mode=true` | 실제 동적 설정 허용 항목이 세 노드에서 같았다. `primary_start_timeout`, `primary_stop_timeout`, `synchronous_mode`, `check_timeline`은 동적 설정에 **명시되지 않아 `null`**이며 `0`으로 해석하지 않는다. | +| etcd 멤버 | 구성 이름·실행 멤버 모두 `node1/2/3`, 정족수 `2` | `initial_cluster_state=new`는 최초 부트스트랩 입력값이지 현재 새 클러스터를 생성한다는 뜻이 아니다. | +| etcd 시간 입력 | heartbeat `100ms`, election timeout `1000ms`; 둘 다 `installed binary default` | 명시적 override가 없음을 확인했다. 실제 장애 때 선출 지연은 별도 측정해야 한다. | +| OpenProxy 풀 | `pool_mode=transaction`, `default_role=primary`, parser·읽기/쓰기 분리 활성, `primary_reads_enabled=false`, `Random` | 연결이 프록시를 통과할 때의 기대 라우팅 조건이다. `Random`만으로 물리 standby 분산 비율을 입증하지 않는다. | +| 캐시 표기 | 일반 TOML `prepared_statements_cache_size=1000`, 관리자 풀 조회값 `0` | 두 범위의 값이 달라 **유효 우선순위 미확정**. `0=완전 비활성`이라고 단정하지 않는다. | +| Docker / host unit | Docker `unless-stopped`; host bootstrap unit `inactive`, `Restart=on-failure`, `RestartUSec=15s` | 동봉 OpenProxy service 예시의 `Restart=always`, `RestartSec=1`이 현재 프록시를 감독한다는 증거가 없다. | +| 살아 있는 OpenProxy | node1 `0`, node2 `1`, node3 `1`; node2/3의 부모는 컨테이너 PID 1 | 두 프록시가 실행 중이라는 증거다. 프로세스 kill 뒤 누가 얼마 만에 재시작할지는 아직 모른다. | + +원본: [node1](../opensql-contract-evidence/node1.json), [node2](../opensql-contract-evidence/node2.json), [node3](../opensql-contract-evidence/node3.json), [A 관리자 값](../opensql-contract-evidence/proxy-a-admin.json), [B 관리자 값](../opensql-contract-evidence/proxy-b-admin.json). 관리자 조회는 `SHOW CONFIG/STATS/SERVERS`를 서비스 포트에서 읽었으며 설정 변경 명령은 실행하지 않았다. `SHOW STATS`는 누적값이라 이번 수집만의 요청량으로 해석하지 않는다. + +## 판정·미검증 + +설정 **기록** 기준은 완료됐다. 그러나 OpenProxy 프로세스 `kill`, `systemctl stop`, 네트워크 DROP, PostgreSQL 프로세스 종료, VM 상실, etcd 정족수 상실은 이 단계에서 실행하지 않았다. 각 장애의 재시작/선출/쓰기 가능 시간은 후속 실험으로만 판정한다. `failsafe_mode=true`에서 etcd 정족수 상실을 단순히 “즉시 쓰기 정지”로 예측하지 않는다. diff --git a/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md b/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md new file mode 100644 index 0000000..16bcdd3 --- /dev/null +++ b/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md @@ -0,0 +1,42 @@ +# OpenProxy A/B 설정 일치 검증 + +## 목적과 범위 + +이후 한쪽 OpenProxy를 중단하고 다른 쪽으로 전환할 때, 실패 원인이 장애 자체인지 A/B 설정 차이인지 구분하려고 **설치 파일의 공개 가능 항목**과 **관리 콘솔의 유효 항목**을 각각 비교했다. `proxy-a=node2`, `proxy-b=node3`이다. 암호·서버 주소·원본 TOML 전체의 byte-for-byte 동일성은 공개하거나 비교 결과로 주장하지 않는다. + +## 실제 명령은 어디에서 실행됐나 + +개발자 컴퓨터의 저장소 루트에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했다. 스크립트는 승인된 활성 GCP 계정·프로젝트를 확인한 후, node2/3의 Rocky 컨테이너에서 아래 두 유형을 호출했다. + +```bash +# node2/3 컨테이너에서 수집기 실행: 설치 TOML의 허용 키만 JSON화 +sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN + +# node2/3 컨테이너에서 수집기 실행: 관리자 계정은 컨테이너 메모리에서만 사용 +sudo docker exec -i docgrid-nodeN python3 - admin --node nodeN +``` + +`admin` 모드의 [`admin_rows()`](../../../scripts/opensql/capture_ha_contract.py)는 컨테이너 localhost의 OpenProxy 서비스 포트에 `psql -X -w --csv -P footer=off -h 127.0.0.1 -d openproxy -c 'SHOW CONFIG'` 형태로 접속한다. 같은 방식으로 `SHOW STATS`, `SHOW SERVERS`도 호출했다. 실제 port·관리 사용자·암호는 설치 파일에서 메모리로 읽어 프로세스 환경에만 넣었으며, 공개 명령 예시에는 적지 않는다. `SHOW`는 조회 명령이고 설정 변경은 아니다. 설치 파일의 `admin_port=6433` 경로는 컨테이너 localhost에서 열려 있지 않았고, 서비스 포트 `6432` 경로가 성공했다. 관리 포트의 제품상 의미는 별도 확인 사항이다. + +로컬에서 [`assemble`](../../../scripts/opensql/capture_ha_contract.py)을 실행해 다섯 JSON 입력의 허용 필드를 검증·비교했다. 재현 시에는 위 스크립트가 이 단계를 자동으로 실행한다. `assemble`은 `node2.openproxy`와 `node3.openproxy`, `proxy-a.effective_config`와 `proxy-b.effective_config`를 별도 비교하고 다르면 실패한다. + +## 비교한 코드·설정과 결과 + +| 비교 층위 | 실제 확인한 항목 | A/B 결과 | 해석 | +| --- | --- | --- | --- | +| 설치 TOML 일반 섹션 | `connect_timeout=10000`, `prepared_statements_cache_size=1000` | 동일 | 같은 설치 설정의 허용 항목이다. 두 번째 값의 관리자 풀 값과의 우선순위는 별개다. | +| 설치 TOML `pools.docgrid` | `pool_mode=transaction`, `default_role=primary`, `query_parser_enabled=true`, `query_parser_read_write_splitting=true` | 동일 | 라우팅 규칙의 기본 조건이 두 프록시에서 같았다. | +| 설치 TOML shard/user | `use_patroni=true`, `patroni_port=8008`, `pool_size=5` | 동일 | 주소와 인증 정보는 제외한 공통 계약이다. | +| `SHOW CONFIG` 유효 풀 값 | `pool_mode=Transaction`, `default_role=primary`, 읽기/쓰기 분리·parser 활성, `primary_reads_enabled=false`, `load_balancing_mode=Random`, 풀의 `prepared_statements_cache_size=0` | 동일 | 런타임 관리 뷰에서도 허용 항목이 일치했다. 대소문자 차이는 출력 표기다. | +| `SHOW CONFIG` 일반 값 | `connect_timeout=10000`, `shutdown_timeout=60000` | 동일 | 연결·종료 관련 설정을 후속 장애 시험 기준선으로 쓸 수 있다. | +| `SHOW STATS` | 양쪽에 primary·replica 누적 카운터가 존재 | 각각 숫자는 다름 | **설정 비교 대상이 아니다.** 누적 통계가 다르다고 설정 불일치로 판정하면 안 된다. | + +증거: [node2 설치 스냅샷](../opensql-contract-evidence/node2.json), [node3 설치 스냅샷](../opensql-contract-evidence/node3.json), [A 관리 스냅샷](../opensql-contract-evidence/proxy-a-admin.json), [B 관리 스냅샷](../opensql-contract-evidence/proxy-b-admin.json), [통합 해시](../opensql-contract-evidence/contract-manifest.json). + +## 왜 이것이 필요한가 + +두 프록시의 공개 가능한 설치·유효 설정이 같아야 A→B 또는 B→A 접속 전환 시험 결과를 해석할 기준선이 생긴다. 예를 들어 한쪽만 read/write splitting이 꺼져 있으면 장애 전후 SQL 역할 차이가 장애의 영향인지 설정의 영향인지 구분되지 않는다. 이번 비교는 그 혼동을 줄인다. + +## 결론과 한계 + +A/B **허용 항목의 설정 일치**는 완료됐다. 원본 TOML 전체·비밀값·서버 주소가 동일하다는 주장은 하지 않는다. `SHOW SERVERS`의 공개 스냅샷은 `servers=[]`여서 물리 DB별 라우팅 증거가 아니다. 풀 캐시 값 `0`과 일반 TOML `1000`의 실제 우선순위도 확정하지 못했다. 그리고 양쪽의 설정이 같아도 한쪽 장애 뒤 실제 새 연결이 다른 쪽으로 넘어가는지는 아직 시험하지 않았다. diff --git a/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md b/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md new file mode 100644 index 0000000..b18ba05 --- /dev/null +++ b/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md @@ -0,0 +1,45 @@ +# pgJDBC server-side prepared statement 반복 실행 + +## 왜 검사했나 + +pgJDBC의 `PreparedStatement`는 같은 SQL을 반복하면 `prepareThreshold`에 따라 server-side prepare 단계로 전환될 수 있다. OpenProxy의 transaction pooling에서 이것이 실패하면 뒤의 장애·부하 시험에 **HA와 무관한 SQL 오류**가 섞인다. 기본에 해당하는 임계값 `5`와 강제 전환용 `1`을 각각 두 프록시에서 검사했다. + +## 어디에서 어떤 명령·코드를 실행했나 + +개발자 컴퓨터의 저장소 루트에서 A/B JDBC URL 및 앱 계정을 셸 환경으로 주고 아래 명령을 실행했다. A/B URL은 각각 기존 GCP node2/node3의 OpenProxy로 향하는 임시 SSH 터널이었다. 비밀번호 원문은 명령 기록·문서에 쓰지 않는다. + +```bash +./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline +``` + +실제 반복은 [`preparedStatementsSurviveTransactionPooling()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) 코드 안에서 일어났다. `connect()`가 각 JDBC URL 뒤에 `prepareThreshold=5` 또는 `prepareThreshold=1`을 추가했다. **셸에서 15번 명령을 수동으로 친 것이 아니라**, 같은 물리 JDBC 연결·같은 `PreparedStatement` 객체로 루프를 15번 돌렸다. + +```java +connection.setAutoCommit(false); +PreparedStatement statement = connection.prepareStatement("SELECT ?::integer"); +for (int value = 1; value <= 15; value++) { + statement.setInt(1, value); + ResultSet result = statement.executeQuery(); + // 반환값이 입력값과 같아야 한다. + connection.commit(); +} +``` + +실제 코드에서는 매번 `SELECT pg_postmaster_start_time()::text || ':' || pg_backend_pid()`로 backend 식별자를 기록하고, 마지막 반복과 같은 트랜잭션 안에서 `SELECT count(*) FROM pg_prepared_statements`를 읽었다. `PGStatement.getPrepareThreshold()`로 요청한 임계값을 확인하고, 루프가 끝난 뒤 `PGStatement.isUseServerPrepare()`가 `true`인지 assert했다. 이 때문에 단순히 SQL 15회 성공했다는 것보다 **드라이버가 준비문 단계에 들어갔다는 증거**가 하나 더 있다. + +## 관측 결과 + +| 프록시 | `prepareThreshold` | 반복·정확성 | 관측 backend 수 | 마지막 backend의 전체 준비문 수 | +| --- | ---: | --- | ---: | ---: | +| A | 5 | 15회, 매번 입력 정수 그대로 반환 | 1 | 22 | +| A | 1 | 15회, 매번 입력 정수 그대로 반환 | 1 | 40 | +| B | 5 | 15회, 매번 입력 정수 그대로 반환 | 1 | 22 | +| B | 1 | 15회, 매번 입력 정수 그대로 반환 | 1 | 40 | + +네 조합 모두 `isUseServerPrepare()=true`, `prepared_on_last_backend > 0`을 통과했다. 결과는 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)의 `CONTRACT_PREPARED` 네 줄에 있다. 마지막 준비문 수는 **해당 backend에 보이는 전체 개수**이며 이 테스트 SQL만의 독립 개수가 아니다. + +[이전 캐시 카운터 실험](../opensql-contract-evidence/prepared-cache-delta.json)은 A/B 각각 hit `+224`, miss `+80`, eviction `0`을 남겼다. 다만 **이번 최종 JUnit 실행이나 12:57 UTC 설정 스냅샷과 같은 시점의 실행이 아니므로** 위 네 행에 합산하거나 15회 루프의 캐시 적중률로 환산하지 않는다. 설치 TOML의 일반 설정 `prepared_statements_cache_size=1000`과 `SHOW CONFIG`의 `pools.docgrid` 값 `0`도 서로 달라 우선순위를 확정하지 못했다. + +## 결론과 제한 + +**완료:** 두 프록시 × 두 임계값에서 여러 트랜잭션에 걸친 반복 실행·결과 정확성·드라이버 server prepare 진입을 확인했다. **미검증:** transaction pooling이 실제로 *다른 backend*로 전환될 때 named statement를 재준비하는지, OpenProxy 캐시 설정의 적용 우선순위와 캐시 효율, 고동시성에서의 오류율. 이번에는 네 조합 모두 `distinct_backends=1`이므로 “backend 교체를 견뎠다”라고 말할 수 없다. 별도 backend 교체 실험 전까지 이 부분은 후속 위험으로 남긴다. diff --git a/docs/test-results/opensql-contract-verification/product-and-driver-versions.md b/docs/test-results/opensql-contract-verification/product-and-driver-versions.md new file mode 100644 index 0000000..ab6bf2e --- /dev/null +++ b/docs/test-results/opensql-contract-verification/product-and-driver-versions.md @@ -0,0 +1,48 @@ +# OpenSQL 3노드 제품·드라이버 버전 확인 + +## 무엇을 확인했나 + +Rocky Linux 9.7 `x86_64` 컨테이너 세 개에 설치된 OpenSQL·PostgreSQL·Patroni·etcd 버전, OpenProxy 두 개의 버전, 테스트 애플리케이션이 해석한 pgJDBC·HikariCP 버전을 기록했다. 버전이 같다는 사실은 이후 실험의 재현 조건이지, 제품 간 모든 호환성을 입증하는 결과는 아니다. + +## 어디에서 어떤 명령을 실행했나 + +| 실행 위치 | 명령 또는 호출 | 목적 | +| --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인된 GCP 계정·프로젝트인지 먼저 검사하고 기존 세 VM의 컨테이너에서 버전만 수집한다. 실제 실행에는 `OPENSQL_GCP_ZONE`, `OPENSQL_SSH_KEY`, `OPENSQL_EXPECTED_ACCOUNT`, `OPENSQL_EXPECTED_PROJECT`를 환경 변수로 제공했다. 값 자체는 공개하지 않는다. | +| 스크립트가 SSH로 접근한 `node1/2/3`의 Rocky 컨테이너 | `python3 - collect --node nodeN` | [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)의 `collect`가 설치된 실행 파일의 버전 출력을 허용 목록으로 가공한다. `node1`에는 OpenProxy가 없으므로 그 값은 `null`이다. | +| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend dependencyInsight --dependency org.postgresql:postgresql --configuration testRuntimeClasspath --offline` | Gradle 테스트 런타임이 실제로 선택한 pgJDBC 버전을 확인한다. 선언 버전만 읽는 것과 다르다. | +| 같은 위치 | `./backend/gradlew -p backend dependencyInsight --dependency com.zaxxer:HikariCP --configuration testRuntimeClasspath --offline` | HikariCP의 실제 선택 버전을 확인한다. | +| 개발자 컴퓨터에서 A/B OpenProxy로 연결한 JUnit | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | JDBC 메타데이터의 PostgreSQL 서버·드라이버 버전도 원격 연결을 통해 교차 확인한다. 연결 변수와 비밀번호는 셸 환경에만 주입했다. | + +수집 스크립트 안의 SSH 명령 형태는 다음과 같다. `nodeN`은 `node1`부터 `node3`까지 반복되고 SSH 키 경로는 공개하지 않는다. + +```bash +gcloud compute ssh "docgrid-nodeN" --zone="$OPENSQL_GCP_ZONE" \ + --ssh-key-file="$OPENSQL_SSH_KEY" \ + --command="sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN" --quiet \ + < scripts/opensql/capture_ha_contract.py +``` + +이는 수집 코드의 실행 구조를 보여주는 **비식별화한 표기**다. 실제 스크립트는 셸 변수로 노드명을 조합하고 결과를 임시 JSON에 받는다. 원본 라이선스나 설정 파일 전체를 출력하지 않는다. + +## 어떤 코드가 결과를 만들었나 + +수집기는 설치된 바이너리의 버전 출력 중 제품명·버전만 파싱해 [node1](../opensql-contract-evidence/node1.json), [node2](../opensql-contract-evidence/node2.json), [node3](../opensql-contract-evidence/node3.json)의 `versions`에 넣는다. JDBC 테스트의 [`timeZoneBoundaryIsStableAcrossProxies()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)는 `connection.getMetaData().getDatabaseProductVersion()`과 `getDriverVersion()`을 각각 A/B에서 읽어 `CONTRACT_SERVER`, `CONTRACT_DRIVER` 관측값을 남긴다. 이 테스트는 `DriverManager`로 연결하므로 HikariCP를 실제로 구동하는 시험은 아니다. + +## 실제 결과와 해석 + +| 대상 | 관측값 | 의미 | +| --- | --- | --- | +| 세 컨테이너 | Rocky Linux 9.7, `x86_64` | 세 노드의 OS 식별자·아키텍처가 동일했다. | +| OpenSQL | 세 노드 모두 `v3.17.8.7` | 제품 버전 차이로 인한 노드별 동작 차이를 이번 기준선에서는 배제할 수 있다. | +| OpenProxy | `node2/3` 모두 `1.1.3`, revision `723`; `node1`은 미설치 | 프록시 두 대의 설치 빌드가 같았다. | +| Patroni / etcd | 각각 세 노드 모두 `4.0.5` / `3.6.5` | 이후 리더 선출 시험에서 같은 버전의 3멤버를 대상으로 한다. | +| PostgreSQL 실행 파일 / psql | 세 노드 모두 `17.8` / `17.8` | 설치 바이너리와 클라이언트 버전이다. | +| JDBC 서버 메타데이터 | A/B 모두 PostgreSQL `17.8` | 실제 프록시 경유 연결이 반환한 서버 버전이다. | +| pgJDBC / HikariCP | `42.7.11` / `6.3.3` | Gradle `testRuntimeClasspath`의 선택 버전이다. pgJDBC `42.7.11`은 JDBC 메타데이터와도 일치했다. | + +원본은 [통합 스냅샷](../opensql-contract-evidence/contract-manifest.json)과 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)이다. JUnit 전체 5개 시험은 2026-09-26 12:46 UTC에 실패 0건이었고, 이 문서의 버전 항목은 그 시험과 별도 수집 스냅샷을 함께 해석한 것이다. 수집은 12:57:23~12:58:14 UTC에 순차 실행됐으므로 두 파일을 **동일 시점의 원자적 상태**라고 표현하지 않는다. + +## 결론과 한계 + +버전 기록 기준은 완료됐다. 하지만 HikariCP `6.3.3`은 의존성 확인 결과이지 이 계약 JUnit에서 Hikari 풀을 실행했다는 뜻은 아니다. 또한 `node1`의 OpenProxy 값이 `null`인 것은 수집 누락이 아니라 해당 노드에 프록시를 설치하지 않은 토폴로지다. 전체 Gradle 테스트 `1,124개 중 104개 실패`라는 별도 실행 결과도 있으므로 “저장소 전체 테스트 성공”으로 확대하지 않는다. diff --git a/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md b/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md new file mode 100644 index 0000000..8dd656b --- /dev/null +++ b/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md @@ -0,0 +1,45 @@ +# 비식별 수집 JSON과 SHA-256 재계산 + +## 검증 대상 + +노드 3개와 OpenProxy 관리자 스냅샷 2개를 **사람이 수작업으로 옮겨 적지 않고**, 공개 가능한 항목만 모아 하나의 canonical JSON 스냅샷으로 만들었는지 확인했다. SHA-256은 그 공개 스냅샷의 무결성 확인값이다. GCP·티맥스티베로의 서명이나 원본 서버 전체 상태에 대한 증명서는 아니다. + +## 실제 실행 명령과 장소 + +개발자 컴퓨터의 저장소 루트에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했다. 승인 계정/프로젝트를 확인한 뒤, 스크립트가 각 GCP VM에 SSH로 들어가 Rocky 컨테이너의 `collect`, VM 호스트의 `runtime`, node2/3 컨테이너의 `admin`을 호출했다. 로컬의 `merge`가 같은 노드 별칭인 두 결과만 합치고, `assemble`이 최종 5개 입력을 검증·해시했다. 명령 구조는 다음과 같다. + +```bash +python3 scripts/opensql/capture_ha_contract.py merge \ + +python3 scripts/opensql/capture_ha_contract.py assemble \ + \ + --admin +``` + +꺾쇠 안은 스크립트가 만든 **임시 파일 경로의 설명용 자리표시자**이며, 실제 스크립트는 `mktemp -d`로 임시 디렉터리를 만들고 종료 시 삭제한다. 공개 증거는 [`opensql-contract-evidence`](../opensql-contract-evidence/contract-manifest.json)에 복사한다. 원격 관리 암호는 컨테이너 메모리에서만 읽고, 공개 JSON에는 설정 허용 키·역할별 누적 숫자만 들어간다. + +이번 문서를 작성하면서 개발자 컴퓨터의 같은 worktree에서 실제로 아래 독립 재계산 명령도 실행했다. 이는 **수집 당시 명령이 아니라 공개 결과를 나중에 검산한 명령**이다. + +```bash +python3 -c 'import json,hashlib,pathlib; p=pathlib.Path("docs/test-results/opensql-contract-evidence/contract-manifest.json"); d=json.loads(p.read_text()); b=json.dumps(d["snapshot"],ensure_ascii=False,sort_keys=True,separators=(",",":")).encode(); print(hashlib.sha256(b).hexdigest()); print(d["evidence_sha256"]); print(hashlib.sha256(b).hexdigest()==d["evidence_sha256"])' +``` + +## 코드가 하는 검증 + +[`merge_node()`](../../../scripts/opensql/capture_ha_contract.py)은 컨테이너 결과와 호스트 결과의 노드 별칭이 다르면 실패한다. [`assemble()`](../../../scripts/opensql/capture_ha_contract.py)은 node1/2/3이 정확히 하나씩 있는지, OS가 모두 Rocky 9.7 `x86_64`인지, A/B 설치·관리 계약 값이 같은지, OpenSQL 버전과 Patroni 동적 설정이 같은지 검사한다. 민감 문자열 탐지도 통과해야 한다. 그 뒤 `snapshot`을 `ensure_ascii=False`, `sort_keys=True`, `separators=(",",":")`로 직렬화한 **UTF-8 바이트**에 SHA-256을 적용한다. 수집 시작/종료 시각은 해시 대상 `snapshot`의 바깥 필드다. + +`python3 -m unittest scripts.opensql.test_capture_ha_contract -v`로 수집기 단위시험 11개가 실패 없이 통과했다. 여기에는 비밀값 차단, 중복 감지, 버전 추출, etcd 시간 입력 우선순위, 자동 병합, 관리 설정 포함 해시, JUnit 비식별화 검증이 있다. 이 단위시험은 원격 상태의 진실성을 보장하는 시험이 아니라 **수집 코드의 방어 동작**을 확인한 것이다. + +## 관측 결과와 해석 + +| 항목 | 결과 | 해석 | +| --- | --- | --- | +| 순차 수집 구간 | 2026-09-26 `12:57:23Z`~`12:58:14Z` | 원자적 단일 시점 스냅샷이 아니다. | +| 입력 | 노드 3개, 관리자 결과 2개 | 모두 [manifest](../opensql-contract-evidence/contract-manifest.json)의 `snapshot`에 포함됐다. | +| 공개 SHA-256 | `0a0b86eff71c4c24740a99ea0f334fca70e5c11e1a813c3a3bc646a825549794` | 공개 snapshot 바이트의 동일성 검사용이다. | +| 독립 재계산 | 출력 해시 두 줄 동일, 비교 `True` | 게시된 manifest의 `evidence_sha256`이 현재 `snapshot`에서 재현됐다. | +| 민감 값 | 계정·프로젝트 ID·내부 IP·암호·라이선스 파일 미포함 | 공개 문서로 올릴 수 있는 범위를 유지했다. 자동 필터를 통과해도 게시 전 리뷰가 필요하다. | + +## 한계 + +원본 설정 전체가 아닌 **허용 목록**만 해시한다. 다른 시점에 다시 수집하면 누적 `SHOW STATS` 숫자가 변해 해시도 달라질 수 있다. 해시가 같아도 당시 서버가 정직했음을 증명하는 것은 아니며, 해시가 달라도 곧바로 설정 오류라는 뜻은 아니다. JUnit 결과와 이전 prepared-cache delta는 이 해시 입력에 포함되지 않고 실행 시점도 다르다. 따라서 세 증거를 한 번의 원자적 실험으로 묶지 않는다. diff --git a/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md b/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md new file mode 100644 index 0000000..b322239 --- /dev/null +++ b/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md @@ -0,0 +1,30 @@ +# SQL-level PREPARE와 pgJDBC 프로토콜 준비문 구분 + +## 왜 별도로 검증했나 + +둘 다 “prepared statement”라고 부르지만, 애플리케이션이 `PREPARE/EXECUTE` SQL을 직접 보내는 방식과 pgJDBC `PreparedStatement`가 확장 쿼리 프로토콜로 server-side prepare 하는 방식은 같은 시험이 아니다. OpenProxy transaction pooling에서는 수명·backend 전환 문제를 다르게 해석해야 하므로 별도로 검사했다. + +## 명령과 실행 위치 + +개발자 컴퓨터의 저장소 루트에서 A/B OpenProxy 터널과 앱 계정 환경 변수로 다음 **한 번의 JUnit 명령**을 실행했다. SQL은 Java 코드 안에서 프록시를 지나 GCP의 DB 세션에서 실행됐다. + +```bash +./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline +``` + +| 방식 | Java 실행 위치·API | DB에 보낸 핵심 명령 | 목적 | +| --- | --- | --- | --- | +| SQL-level | 개발자 컴퓨터 JVM의 `Statement`, [`sqlLevelPrepareIsObservedSeparately()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `PREPARE contract_<무작위식별자>(integer) AS SELECT $1::integer` → `EXECUTE contract_<식별자>(41)` → `DEALLOCATE contract_<식별자>` | 앱이 명시적으로 보내는 서버 SQL 명령의 **같은 트랜잭션 내** 동작을 확인한다. 이름은 충돌 방지를 위해 실행 때마다 생성했다. | +| pgJDBC 프로토콜 | 같은 JVM의 `PreparedStatement`와 `PGStatement`, [`preparedStatementsSurviveTransactionPooling()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `SELECT ?::integer` 15회; URL에 `prepareThreshold=5` 또는 `1` | 드라이버가 server-side prepare 단계로 들어가고 반복 결과가 맞는지 확인한다. SQL-level `PREPARE` 문자열을 직접 보내는 시험이 아니다. | + +SQL-level 테스트는 `setAutoCommit(false)` 이후 `PREPARE`, `EXECUTE`, `DEALLOCATE`를 **같은 트랜잭션 안에서** 실행해 `41`을 확인하고 `rollback()`했다. 프록시 A 결과와 B 결과가 같은지도 assert했다. 프로토콜 테스트는 같은 `PreparedStatement`를 유지한 채 **매 반복 후 commit**해 15개 트랜잭션을 통과했다. + +## 결과·해석 + +[JUnit 요약](../opensql-contract-evidence/junit-summary.json)에 `CONTRACT_SQL_PREPARE proxy=proxy-a result=same_transaction_pass`와 B의 동일한 결과가 있다. SQL-level 방식은 양쪽 모두 **동일 트랜잭션 안에서** 41을 반환하고 명시적으로 정리됐다. 프로토콜 방식은 A/B × threshold `5/1` 네 조합에서 모두 15회 정확한 정수를 반환하고 `PGStatement.isUseServerPrepare()`가 `true`였다. + +따라서 “SQL-level 명령도 동작했다”와 “pgJDBC의 server-side prepare도 해당 반복 조건에서 동작했다”는 두 개의 별도 결론을 낼 수 있다. 전자를 근거로 후자의 캐시 정책을 입증하거나, 후자를 근거로 SQL-level 준비문의 세션 간 보존을 주장하면 안 된다. + +## 남은 검증 + +SQL-level 시험은 **트랜잭션 간 재사용**이나 **다른 backend로 이동한 뒤 재사용**을 해보지 않았다. 프로토콜 반복은 15번의 commit을 거쳤지만 각 조합에서 관측한 backend가 **1개**여서 backend 전환 시 재준비를 검증하지 못했다. `prepared_statements_cache_size`의 일반 TOML `1000`과 관리 풀 값 `0`의 우선순위도 확정되지 않았다. 이 문서의 완료 판정은 두 방식을 구분해 **실제로 수행한 범위의 동작을 각각 기록했다**는 뜻이다. diff --git a/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md b/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md new file mode 100644 index 0000000..876978d --- /dev/null +++ b/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md @@ -0,0 +1,36 @@ +# JVM·DB·OS 시간대와 자정 경계 확인 + +## 검증 목표 + +개발자 컴퓨터의 JVM, GCP VM/컨테이너의 OS, 프록시를 거친 DB 세션의 시간대가 **같은지 다른지** 먼저 기록했다. 그런 다음 한국 시간 자정 직후의 `timestamp`와 `timestamptz`를 A/B OpenProxy에서 왕복시키고, 한 트랜잭션에서 바꾼 `SET LOCAL TIME ZONE`이 다음 요청에 남지 않는지 확인했다. + +## 실제 명령·실행 위치 + +개발자 컴퓨터의 저장소 루트에서 A/B JDBC URL을 임시 SSH 터널로 주고 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행했다. 이때 자정 경계 테스트는 같은 JUnit 실행의 5개 테스트 중 하나였다. 별도로 개발자 컴퓨터에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했고, 그 스크립트의 `collect`·`runtime` 모드가 각각 Rocky 컨테이너와 GCP VM 호스트에서 `date +%Z%z`를 호출해 OS 시간대를 비식별화된 문자열로 기록했다. + +| 위치 | 실행 코드·SQL | 목적 | +| --- | --- | --- | +| 개발자 컴퓨터 JVM | `ZoneId.systemDefault()` | 테스트 실행 JVM의 기본 시간대를 확인한다. | +| A/B를 통한 원격 DB 세션 | `SHOW TimeZone` | 실제 세션의 날짜 변환 기준을 확인한다. | +| 같은 DB 세션 | `SELECT ?::timestamp, ?::timestamptz` | 벽시계 날짜와 시간대가 포함된 순간이 각각 왕복 보존되는지 비교한다. | +| 같은 DB 세션의 명시적 트랜잭션 | `SET LOCAL TIME ZONE 'Pacific/Honolulu'` → `SHOW TimeZone` → `rollback()` → 다시 `SHOW TimeZone` | 트랜잭션 국소 설정이 풀의 다음 사용으로 새지 않는지 확인한다. | +| 세 GCP VM 호스트 및 세 Rocky 컨테이너 | `date +%Z%z` | OS 시간대 표기를 수집한다. DB 세션 시간대와 혼동하지 않는다. | + +핵심 Java 코드는 [`timeZoneBoundaryIsStableAcrossProxies()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)에 있다. 입력은 `LocalDateTime.of(2026, 9, 27, 0, 0, 1)`과 같은 벽시계값의 `OffsetDateTime` `+09:00`이다. `timestamp`는 `LocalDateTime`으로 같은 값인지 비교하고, `timestamptz`는 조회된 `OffsetDateTime`의 **`Instant`가 원본과 같은지** 비교했다. 표시 오프셋 문자열의 동일성이 아니라 순간의 동일성을 검사한 것이다. A/B의 `SHOW TimeZone` 및 서버 버전도 서로 같아야 테스트가 통과한다. + +## 관측 결과 + +| 대상 | 관측 시간대 | 해석 | +| --- | --- | --- | +| JUnit을 실행한 JVM | `Asia/Seoul` | Java 기본 시간대다. GCP VM OS의 시간대와 다르다. | +| proxy-a를 통한 DB 세션 | `Asia/Seoul` | 쿼리를 실행한 DB 세션 시간대다. | +| proxy-b를 통한 DB 세션 | `Asia/Seoul` | A와 같다. | +| GCP VM 3대와 Rocky 컨테이너 3개 | `UTC+0000` | OS 시간대이며 JDBC 세션의 시간대를 강제로 UTC로 만든다는 의미는 아니다. | +| 자정 경계 왕복 | A/B 모두 `boundary=pass` | 지정한 `timestamp` 벽시계값과 `timestamptz` 순간이 보존됐다. | +| `SET LOCAL` 후 rollback | A/B 모두 원래 DB 세션 시간대 복귀 | 이번 트랜잭션 국소 설정은 다음 조회에 남지 않았다. | + +출처는 [JUnit 요약](../opensql-contract-evidence/junit-summary.json)의 `CONTRACT_TIMEZONE` 출력, [node1](../opensql-contract-evidence/node1.json)·[node2](../opensql-contract-evidence/node2.json)·[node3](../opensql-contract-evidence/node3.json)의 `container_time_zone` 및 `runtime.host_time_zone`이다. JUnit의 출력은 A/B 각각 `jvm=Asia/Seoul db=Asia/Seoul boundary=pass`였다. + +## 결론과 범위 + +**완료:** 서로 다른 JVM/DB/OS 시간대 조건에서 지정한 자정 경계의 두 PostgreSQL 날짜 유형이 A/B 모두에서 일관되게 왕복했고, `SET LOCAL`이 rollback 뒤 누수되지 않았다. **미검증:** 모든 날짜 값·DST 경계·JSON 직렬화·Spring/JPA 변환·다른 JVM 기본 시간대·네트워크 장애 뒤 재연결 시 세션 시간대. 이번 한 날짜의 통과를 “시간대 문제 전부 해결”로 확대하지 않는다. From be5a759469ff334cb6548456993e187cc3698b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 26 Sep 2026 22:30:59 +0900 Subject: [PATCH 8/9] docs: add per-command result summaries to OpenSQL checks --- .../autocommit-readonly-readwrite-routing.md | 11 +++++----- .../explicit-write-transaction-primary.md | 13 ++++++------ .../ha-fault-test-acceptance-gates.md | 20 ++++++++++++------- .../ha-settings-and-runtime-supervision.md | 19 +++++++++--------- .../openproxy-a-b-config-parity.md | 9 +++++++++ .../pgjdbc-prepared-threshold-repeat.md | 8 ++++++++ .../product-and-driver-versions.md | 14 ++++++------- .../sanitized-snapshot-and-sha256.md | 9 +++++++++ .../sql-prepare-vs-protocol-prepare.md | 9 +++++---- .../timezone-and-midnight-boundary.md | 16 ++++++++------- 10 files changed, 83 insertions(+), 45 deletions(-) diff --git a/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md b/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md index 988e963..34fa126 100644 --- a/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md +++ b/docs/test-results/opensql-contract-verification/autocommit-readonly-readwrite-routing.md @@ -10,11 +10,12 @@ OpenProxy가 모든 SELECT를 primary로 보내는지, 자동 커밋 SELECT와 [`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)의 실제 세 경로는 아래와 같다. -| 경로 | JDBC 코드/원격 SQL | 왜 이렇게 했나 | -| --- | --- | --- | -| 자동 커밋 SELECT | 기본 auto-commit 연결에서 `SELECT pg_is_in_recovery()` | 단일 조회가 실제 어떤 역할의 DB에 도착하는지 본다. | -| 명시적 읽기·쓰기 | `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `UPDATE documents SET id=id WHERE id=-1` → `rollback()` | 쓰기 가능 트랜잭션이 primary에 선제적으로 고정되는지 확인하고 데이터 변경은 남기지 않는다. | -| JDBC read-only | `setReadOnly(true)` → `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `rollback()` | read-only 힌트가 primary 일관성을 보장하는지 **가정하지 않고** 측정한다. | +| 경로 | JDBC 코드/원격 SQL | 왜 이렇게 했나 | 결과 요약 | +| --- | --- | --- | --- | +| 계약 JUnit 실행 | 개발자 컴퓨터에서 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B의 세 JDBC 경로를 같은 시험 실행에서 비교한다. | 전체 계약 JUnit 5개 통과·실패 0건; 아래 여섯 역할 출력이 기록됐다. | +| 자동 커밋 SELECT | 기본 auto-commit 연결에서 `SELECT pg_is_in_recovery()` | 단일 조회가 실제 어떤 역할의 DB에 도착하는지 본다. | A/B 모두 `true`, 즉 standby 도착. 물리 standby 번호는 미확인. | +| 명시적 읽기·쓰기 | `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `UPDATE documents SET id=id WHERE id=-1` → `rollback()` | 쓰기 가능 트랜잭션이 primary에 선제적으로 고정되는지 확인하고 데이터 변경은 남기지 않는다. | A/B 모두 `false`, 즉 primary 도착; UPDATE 영향 행 `0`, 오류 없음. | +| JDBC read-only | `setReadOnly(true)` → `setAutoCommit(false)` → `SELECT pg_is_in_recovery()` → `rollback()` | read-only 힌트가 primary 일관성을 보장하는지 **가정하지 않고** 측정한다. | A/B 모두 `true`, 즉 standby 도착. Spring `@Transactional` 경로는 별도 미검증. | SQL 함수 `pg_is_in_recovery()`가 `true`면 그 SQL이 standby에서 실행된 것이고, `false`면 primary에서 실행된 것이다. 이 결과는 관리 콘솔의 누적 통계보다 해당 요청의 역할을 직접 보여준다. diff --git a/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md b/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md index 9f1ebc4..8de8081 100644 --- a/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md +++ b/docs/test-results/opensql-contract-verification/explicit-write-transaction-primary.md @@ -14,12 +14,13 @@ JUnit은 개발자 컴퓨터의 저장소 루트에서 실행했다. 두 개의 실행 시 `OPENSQL_PROXY_A_JDBC_URL`, `OPENSQL_PROXY_B_JDBC_URL`, `OPENSQL_APP_USER`, `OPENSQL_APP_PASSWORD`를 **셸 환경 변수**로 제공했다. A/B URL에는 이 수동 시험을 위한 `connectTimeout=5&socketTimeout=15`를 붙였다. 이 값으로 운영 `.env`를 변경했다는 뜻은 아니다. 암호는 공개 로그·Git에 넣지 않았다. 같은 한 번의 JUnit 실행에서 아래 SQL 테스트와 준비문·시간대 테스트 등 총 5개가 함께 돌았다. -| 장소 | 수행 주체 | 수행 내용 | 이유 | -| --- | --- | --- | --- | -| 개발자 컴퓨터 JVM | [`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | A/B마다 JDBC 연결을 만들고 `setAutoCommit(false)` | 명시적 트랜잭션의 라우팅을 분리 측정한다. | -| A/B OpenProxy를 지난 DB 세션 | `SELECT pg_is_in_recovery()` | `false`면 현재 SQL이 primary에서 실행됐음을 직접 확인한다. | -| 같은 트랜잭션의 DB 세션 | `UPDATE documents SET id = id WHERE id = ?`에 `-1` 바인딩 | 쓰기 SQL도 같은 트랜잭션에서 허용되는지 확인한다. 존재하지 않는 ID라 0행이어야 한다. | -| 개발자 컴퓨터 JVM | `connection.rollback()` | 테스트가 운영 데이터를 남기지 않도록 한다. | +| 장소 | 수행 주체 | 수행 내용 | 이유 | 결과 요약 | +| --- | --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | Gradle | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B에 대한 계약 JUnit 5개를 실행한다. | 2026-09-26 12:46 UTC 실행에서 5개 통과·실패 0건. 이 문서의 쓰기 시험은 그중 하나다. | +| 개발자 컴퓨터 JVM | [`routesQueriesByTransactionContract()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | A/B마다 JDBC 연결을 만들고 `setAutoCommit(false)` | 명시적 트랜잭션의 라우팅을 분리 측정한다. | 두 프록시의 명시적 읽기·쓰기 경로가 모두 통과했다. | +| A/B OpenProxy를 지난 DB 세션 | JDBC `Statement` | `SELECT pg_is_in_recovery()` | `false`면 현재 SQL이 primary에서 실행됐음을 직접 확인한다. | A/B 모두 `false`를 반환해 `role=primary`로 기록됐다. | +| 같은 트랜잭션의 DB 세션 | JDBC `PreparedStatement` | `UPDATE documents SET id = id WHERE id = ?`에 `-1` 바인딩 | 쓰기 SQL도 같은 트랜잭션에서 허용되는지 확인한다. 존재하지 않는 ID라 0행이어야 한다. | A/B 모두 오류 없이 실행됐고 영향 행은 `0`이었다. 실제 행 변경의 내구성은 검증하지 않는다. | +| 개발자 컴퓨터 JVM | JDBC `Connection` | `connection.rollback()` | 테스트가 운영 데이터를 남기지 않도록 한다. | 두 경로 모두 rollback 호출이 오류 없이 끝났다. 변경 행이 0개였으므로 rollback의 데이터 복구 효과를 별도로 측정한 것은 아니다. | 핵심 코드의 실제 순서는 아래와 같다. 의도를 보여주기 위해 핵심 호출만 발췌했으며 전체 구현은 위 파일을 참조한다. diff --git a/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md b/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md index 8f2d7b2..5bc8525 100644 --- a/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md +++ b/docs/test-results/opensql-contract-verification/ha-fault-test-acceptance-gates.md @@ -12,6 +12,12 @@ jq '{patroni: [.snapshot.nodes[] | {node, failsafe_mode: .patroni_dynamic.failsafe_mode, ttl: .patroni_dynamic.ttl, primary_start_timeout: .patroni_dynamic.primary_start_timeout}], proxies: [.snapshot.admins[] | {proxy, pool_mode: .effective_config["pools.docgrid.pool_mode"], connect_timeout: .effective_config.connect_timeout, shutdown_timeout: .effective_config.shutdown_timeout}]}' docs/test-results/opensql-contract-evidence/contract-manifest.json ``` +| 실행 위치 | 명령 | 목적 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 장애 기준을 정하기 전에 현재 설정·실행 감독을 수집한다. | node1/2/3과 proxy-a/b의 비식별 스냅샷이 생성됐다. 장애 주입은 포함하지 않았다. | +| 같은 worktree | 위의 `jq '{patroni: ..., proxies: ...}'` | 후속 시험 판정에 영향을 주는 설정값만 다시 확인한다. | 세 노드 `failsafe_mode=true`, `ttl=30`, `primary_start_timeout=null`; A/B `pool_mode=Transaction`, `connect_timeout=10000`, `shutdown_timeout=60000`. | +| 실행하지 않음 | `kill -9`, `systemctl stop`, 방화벽 `DROP`, VM stop/delete, `etcdctl member remove`, `pg_wal_replay_pause()` | 이번 문서가 장애 결과가 아닌 사전 기준임을 분명히 한다. | **미실행**. RTO·RPO·장애 전환 결과는 없다. | + 결과는 세 노드 모두 `failsafe_mode=true`, `ttl=30`, `primary_start_timeout=null`; 프록시 A/B 모두 `pool_mode=Transaction`, `connect_timeout=10000`, `shutdown_timeout=60000`이었다. `primary_start_timeout=null`은 동적 설정에 명시되지 않았다는 뜻이지 값 `0`이 아니다. 설치 예시 systemd unit의 재시작 옵션과 실제 실행 감독은 [별도 설정 결과](ha-settings-and-runtime-supervision.md)에 구분했다. 이 단계에서 **실행하지 않은 명령**도 명확히 적는다: `kill -9`, `systemctl stop`, 방화벽 `DROP`, VM stop/delete, `etcdctl member remove`, `pg_wal_replay_pause()` 등. 아래 표는 그 명령들을 실행한 결과가 아니라 안전하게 시험하기 위한 사전 약속이다. @@ -27,13 +33,13 @@ jq '{patroni: [.snapshot.nodes[] | {node, failsafe_mode: .patroni_dynamic.failsa ## 장애별 통과·중단 기준 -| 후속 시험 | 장애 유형을 구분할 이유 | DocGrid 통과 목표 | 경고·실패·즉시 중단 | -| --- | --- | --- | --- | -| OpenProxy A/B | 프로세스 kill은 재시작할 수 있고, 지속 stop은 다른 시험이며, 패킷 DROP은 응답 없이 매달릴 수 있다. | A 중단과 B 중단을 각각 반복. 새 연결이 살아 있는 프록시로 도달하고 RTO ≤30초, 성공 응답 누락·중복 0건. | 30~60초 경고, >60초 실패. 상대 프록시에도 못 붙거나 성공 응답이 사라지면 즉시 중단. | -| PostgreSQL 프로세스 종료 | Patroni가 **같은 노드 재시작**을 선택할 수 있으므로 “새 리더 선출”과 구분한다. | 실제 복구 유형을 기록하고 RTO ≤60초, 성공 응답 누락·중복 0건. | 이중 writable primary 또는 원장 대조 불가 시 즉시 중단. | -| 리더 VM 상실 | PostgreSQL 프로세스 종료와 달리 노드 자체가 빠져 TTL 이후 failover가 필요할 수 있다. | 새 단일 리더와 라우팅 회복 RTO ≤120초, 성공 응답 후 누락 ID 수 공개. | RTO 초과 실패. 비동기 복제로 RPO>0이면 DocGrid의 무손실 목표 실패; 이중 리더 즉시 중단. | -| Worker 인덱싱 중 리더 상실 | lease·Outbox·임베딩 저장의 중간 상태를 최종 결과와 구분해야 한다. | 최종 완료 문서·청크·임베딩·Outbox 누락/중복 0건. | 최종 상태 불일치·재처리 불가 실패. 시험 전용 pause 지점 구현 전에는 ‘임베딩 저장 중’ 주입을 주장하지 않는다. | -| etcd 멤버 장애 | 1대 상실(2/3)과 2대 상실(1/3)은 다르며 현재 `failsafe_mode=true`다. | 단일 writable primary 유지 여부와 복구 후 정상 복제를 기록. | 2대 상실 시 무조건 쓰기 정지를 기대하지 않는다. 이중 리더·복구 불능·사전 스냅샷 부재는 즉시 중단. | +| 후속 시험 | 장애 유형을 구분할 이유 | DocGrid 통과 목표 | 경고·실패·즉시 중단 | 현재 결과 요약 | +| --- | --- | --- | --- | --- | +| OpenProxy A/B | 프로세스 kill은 재시작할 수 있고, 지속 stop은 다른 시험이며, 패킷 DROP은 응답 없이 매달릴 수 있다. | A 중단과 B 중단을 각각 반복. 새 연결이 살아 있는 프록시로 도달하고 RTO ≤30초, 성공 응답 누락·중복 0건. | 30~60초 경고, >60초 실패. 상대 프록시에도 못 붙거나 성공 응답이 사라지면 즉시 중단. | 미실행·RTO 미측정. | +| PostgreSQL 프로세스 종료 | Patroni가 **같은 노드 재시작**을 선택할 수 있으므로 “새 리더 선출”과 구분한다. | 실제 복구 유형을 기록하고 RTO ≤60초, 성공 응답 누락·중복 0건. | 이중 writable primary 또는 원장 대조 불가 시 즉시 중단. | 미실행·재시작/선출 여부 미확인. | +| 리더 VM 상실 | PostgreSQL 프로세스 종료와 달리 노드 자체가 빠져 TTL 이후 failover가 필요할 수 있다. | 새 단일 리더와 라우팅 회복 RTO ≤120초, 성공 응답 후 누락 ID 수 공개. | RTO 초과 실패. 비동기 복제로 RPO>0이면 DocGrid의 무손실 목표 실패; 이중 리더 즉시 중단. | 미실행·RTO/RPO 미측정. | +| Worker 인덱싱 중 리더 상실 | lease·Outbox·임베딩 저장의 중간 상태를 최종 결과와 구분해야 한다. | 최종 완료 문서·청크·임베딩·Outbox 누락/중복 0건. | 최종 상태 불일치·재처리 불가 실패. 시험 전용 pause 지점 구현 전에는 ‘임베딩 저장 중’ 주입을 주장하지 않는다. | 미실행·최종 중복/누락 미측정. | +| etcd 멤버 장애 | 1대 상실(2/3)과 2대 상실(1/3)은 다르며 현재 `failsafe_mode=true`다. | 단일 writable primary 유지 여부와 복구 후 정상 복제를 기록. | 2대 상실 시 무조건 쓰기 정지를 기대하지 않는다. 이중 리더·복구 불능·사전 스냅샷 부재는 즉시 중단. | 미실행·정족수 상실 때의 실제 동작 미확인. | ## 해석과 남은 일 diff --git a/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md b/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md index f91d4dc..8565455 100644 --- a/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md +++ b/docs/test-results/opensql-contract-verification/ha-settings-and-runtime-supervision.md @@ -18,15 +18,16 @@ python3 - runtime --node nodeN 위 두 줄은 개발자 컴퓨터에서 SSH를 통해 **원격 실행된 명령의 본문**이다. `python3 -`의 표준 입력으로 [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)를 보냈다. 원격 호스트에 수집 스크립트를 영구 설치하지 않았다. 수집기 내부의 읽기 전용 호출은 아래와 같다. -| 실행 장소 | 수집기 내부 호출·조회 | 왜 필요한가 | -| --- | --- | --- | -| 각 Rocky 컨테이너 | `patronictl -c <설치된 patroni.yml> show-config` 및 `list --format json` | 동적 HA 정책과 당시 3멤버 역할을 분리해서 확인한다. 전체 설정 파일은 공개하지 않는다. | -| 각 Rocky 컨테이너 | `etcdctl --endpoints=http://127.0.0.1:2379 member list --write-out=json` | 부트스트랩 파일에 적힌 3멤버가 아니라 **실행 중인** 멤버가 3개인지 확인한다. 주소는 결과에서 제거한다. | -| 각 Rocky 컨테이너 | 설치된 `etcd --help` + 실행 etcd 프로세스의 시간 관련 인자·환경 변수 검사 | `heartbeat-interval`, `election-timeout`이 명시 값인지 설치 바이너리 기본값인지 판별한다. 프로세스 인자 전체는 공개하지 않는다. | -| OpenProxy 설치 컨테이너 | `openproxy.toml`의 허용 키와 동봉 `openproxy.service`의 `Restart/RestartSec`만 읽기 | 풀·라우팅·캐시 값과 **예시 systemd 파일**의 내용을 기록한다. 암호·주소는 읽기 출력에 포함하지 않는다. | -| 각 GCP VM 호스트 | `sudo docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' docgrid-nodeN` | 컨테이너 재시작 정책을 확인한다. | -| 각 GCP VM 호스트 | `systemctl show docgrid-opensql@docgrid-nodeN.service -p ActiveState -p Restart -p RestartUSec` | 호스트의 실제 bootstrap unit 상태를 확인한다. 설치 예시 파일과 혼동하지 않는다. | -| 각 GCP VM 호스트에서 컨테이너 내부 조회 | `sudo docker exec docgrid-nodeN ps -eo ppid=,stat=,comm=` | 살아 있는 OpenProxy 개수와 부모가 컨테이너 PID 1인지 확인한다. | +| 실행 장소 | 수집기 내부 호출·조회 | 왜 필요한가 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인 계정·프로젝트를 검사하고 컨테이너/호스트의 읽기 전용 수집을 시작한다. | 노드 3개·프록시 관리자 2개의 비식별 스냅샷을 생성했고 통합 검증을 통과했다. | +| 각 Rocky 컨테이너 | `patronictl -c <설치된 patroni.yml> show-config` 및 `list --format json` | 동적 HA 정책과 당시 3멤버 역할을 분리해서 확인한다. 전체 설정 파일은 공개하지 않는다. | 세 노드의 동적 설정이 일치했다. node1 `Leader/running`, node2/3 `Replica/streaming`; `ttl=30`, `failsafe_mode=true`였다. | +| 각 Rocky 컨테이너 | `etcdctl --endpoints=http://127.0.0.1:2379 member list --write-out=json` | 부트스트랩 파일에 적힌 3멤버가 아니라 **실행 중인** 멤버가 3개인지 확인한다. 주소는 결과에서 제거한다. | 실행 멤버 `node1/2/3`이 확인됐고 계산된 정족수는 `2`다. | +| 각 Rocky 컨테이너 | 설치된 `etcd --help` + 실행 etcd 프로세스의 시간 관련 인자·환경 변수 검사 | `heartbeat-interval`, `election-timeout`이 명시 값인지 설치 바이너리 기본값인지 판별한다. 프로세스 인자 전체는 공개하지 않는다. | override가 없어 설치 바이너리 기본값 heartbeat `100ms`, election timeout `1000ms`를 기록했다. 실제 장애 선출 시간은 측정하지 않았다. | +| OpenProxy 설치 컨테이너 | `openproxy.toml`의 허용 키와 동봉 `openproxy.service`의 `Restart/RestartSec`만 읽기 | 풀·라우팅·캐시 값과 **예시 systemd 파일**의 내용을 기록한다. 암호·주소는 읽기 출력에 포함하지 않는다. | A/B의 허용 설정이 같았다. 동봉 service 예시는 `Restart=always`, `RestartSec=1`이지만 실제 실행 supervisor로 확인된 것은 아니다. | +| 각 GCP VM 호스트 | `sudo docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' docgrid-nodeN` | 컨테이너 재시작 정책을 확인한다. | 세 컨테이너 모두 `unless-stopped`였다. | +| 각 GCP VM 호스트 | `systemctl show docgrid-opensql@docgrid-nodeN.service -p ActiveState -p Restart -p RestartUSec` | 호스트의 실제 bootstrap unit 상태를 확인한다. 설치 예시 파일과 혼동하지 않는다. | 세 호스트 모두 `ActiveState=inactive`, `Restart=on-failure`, `RestartUSec=15s`였다. | +| 각 GCP VM 호스트에서 컨테이너 내부 조회 | `sudo docker exec docgrid-nodeN ps -eo ppid=,stat=,comm=` | 살아 있는 OpenProxy 개수와 부모가 컨테이너 PID 1인지 확인한다. | node1 `0개`, node2/3 각 `1개`; node2/3 프록시의 부모는 컨테이너 PID 1이었다. | ## 코드의 판별 방식 diff --git a/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md b/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md index 16bcdd3..85e7c34 100644 --- a/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md +++ b/docs/test-results/opensql-contract-verification/openproxy-a-b-config-parity.md @@ -16,6 +16,15 @@ sudo docker exec --user opensql -i docgrid-nodeN python3 - collect --node nodeN sudo docker exec -i docgrid-nodeN python3 - admin --node nodeN ``` +| 실행 위치 | 명령 또는 코드 내부 호출 | 목적 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | A/B 설치·관리 설정을 같은 수집 절차로 확보한다. | node2/3 설치 스냅샷과 proxy-a/b 관리자 스냅샷이 생성되고 `assemble` 검증을 통과했다. | +| node2/3 Rocky 컨테이너 | `python3 - collect --node nodeN` | TOML의 공개 허용 키만 수집한다. | 두 노드에서 `pool_mode=transaction`, parser·읽기/쓰기 분리 활성 등 허용 설치값이 일치했다. | +| node2/3 Rocky 컨테이너 | `python3 - admin --node nodeN` 안의 `SHOW CONFIG` | 런타임 관리 뷰의 유효 설정을 설치값과 별개로 읽는다. | A/B의 허용 관리 설정이 같았다. 풀 캐시 값은 `0`으로, TOML 일반 섹션 `1000`과 달랐다. | +| 같은 관리 세션 | `SHOW STATS` | primary·replica 역할별 통계가 있는지 확인한다. | A/B 모두 primary·replica 누적 카운터가 있었다. 숫자는 서로 달랐으며 이번 시험만의 요청량은 아니다. | +| 같은 관리 세션 | `SHOW SERVERS` | 서버별 상태·캐시 통계의 공개 가능한 결과를 확인한다. | 공개 스냅샷의 `servers=[]`; 이 결과로 물리 DB별 분산을 판정하지 못했다. | +| 개발자 컴퓨터의 저장소 루트 | 수집 스크립트가 호출한 `capture_ha_contract.py assemble` | 두 설치값·두 관리값의 동등성을 검사한다. | 공개 허용 항목 비교를 통과했고, 다섯 스냅샷의 SHA-256은 `0a0b86ef…549794`였다. | + `admin` 모드의 [`admin_rows()`](../../../scripts/opensql/capture_ha_contract.py)는 컨테이너 localhost의 OpenProxy 서비스 포트에 `psql -X -w --csv -P footer=off -h 127.0.0.1 -d openproxy -c 'SHOW CONFIG'` 형태로 접속한다. 같은 방식으로 `SHOW STATS`, `SHOW SERVERS`도 호출했다. 실제 port·관리 사용자·암호는 설치 파일에서 메모리로 읽어 프로세스 환경에만 넣었으며, 공개 명령 예시에는 적지 않는다. `SHOW`는 조회 명령이고 설정 변경은 아니다. 설치 파일의 `admin_port=6433` 경로는 컨테이너 localhost에서 열려 있지 않았고, 서비스 포트 `6432` 경로가 성공했다. 관리 포트의 제품상 의미는 별도 확인 사항이다. 로컬에서 [`assemble`](../../../scripts/opensql/capture_ha_contract.py)을 실행해 다섯 JSON 입력의 허용 필드를 검증·비교했다. 재현 시에는 위 스크립트가 이 단계를 자동으로 실행한다. `assemble`은 `node2.openproxy`와 `node3.openproxy`, `proxy-a.effective_config`와 `proxy-b.effective_config`를 별도 비교하고 다르면 실패한다. diff --git a/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md b/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md index b18ba05..f38b673 100644 --- a/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md +++ b/docs/test-results/opensql-contract-verification/pgjdbc-prepared-threshold-repeat.md @@ -27,6 +27,14 @@ for (int value = 1; value <= 15; value++) { 실제 코드에서는 매번 `SELECT pg_postmaster_start_time()::text || ':' || pg_backend_pid()`로 backend 식별자를 기록하고, 마지막 반복과 같은 트랜잭션 안에서 `SELECT count(*) FROM pg_prepared_statements`를 읽었다. `PGStatement.getPrepareThreshold()`로 요청한 임계값을 확인하고, 루프가 끝난 뒤 `PGStatement.isUseServerPrepare()`가 `true`인지 assert했다. 이 때문에 단순히 SQL 15회 성공했다는 것보다 **드라이버가 준비문 단계에 들어갔다는 증거**가 하나 더 있다. +| 실행 위치 | 명령·코드 내부 SQL | 목적 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B와 두 임계값 조합의 계약 JUnit을 실행한다. | 전체 JUnit 5개 통과·실패 0건; 준비문 테스트도 통과했다. | +| A/B OpenProxy를 통한 DB 세션 | `SELECT ?::integer`를 각 조합에서 15회 실행 | 입력값과 반환값의 정확성 및 여러 commit 사이의 반복 동작을 확인한다. | A/B × `prepareThreshold=5/1` 네 조합 모두 15회 정확한 정수를 반환했다. | +| 같은 DB 세션 | `SELECT pg_postmaster_start_time()::text \|\| ':' \|\| pg_backend_pid()` | 반복 중 사용한 backend가 바뀌었는지 센다. | 네 조합 모두 서로 다른 backend가 `1개`여서 backend 교체 시험은 되지 않았다. | +| 마지막 반복과 같은 DB 트랜잭션 | `SELECT count(*) FROM pg_prepared_statements` | 마지막 backend에 보이는 준비문을 확인한다. | threshold `5`는 A/B 각 `22`, threshold `1`은 각 `40`이었다. 해당 SQL만의 독립 개수는 아니다. | +| 개발자 컴퓨터 JVM | `PGStatement.getPrepareThreshold()` 및 `isUseServerPrepare()` | 요청한 임계값과 드라이버의 server-prepare 상태를 확인한다. | 네 조합 모두 요청값 `5` 또는 `1`과 일치했고 `isUseServerPrepare()=true`였다. | + ## 관측 결과 | 프록시 | `prepareThreshold` | 반복·정확성 | 관측 backend 수 | 마지막 backend의 전체 준비문 수 | diff --git a/docs/test-results/opensql-contract-verification/product-and-driver-versions.md b/docs/test-results/opensql-contract-verification/product-and-driver-versions.md index ab6bf2e..ad3d348 100644 --- a/docs/test-results/opensql-contract-verification/product-and-driver-versions.md +++ b/docs/test-results/opensql-contract-verification/product-and-driver-versions.md @@ -6,13 +6,13 @@ Rocky Linux 9.7 `x86_64` 컨테이너 세 개에 설치된 OpenSQL·PostgreSQL· ## 어디에서 어떤 명령을 실행했나 -| 실행 위치 | 명령 또는 호출 | 목적 | -| --- | --- | --- | -| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인된 GCP 계정·프로젝트인지 먼저 검사하고 기존 세 VM의 컨테이너에서 버전만 수집한다. 실제 실행에는 `OPENSQL_GCP_ZONE`, `OPENSQL_SSH_KEY`, `OPENSQL_EXPECTED_ACCOUNT`, `OPENSQL_EXPECTED_PROJECT`를 환경 변수로 제공했다. 값 자체는 공개하지 않는다. | -| 스크립트가 SSH로 접근한 `node1/2/3`의 Rocky 컨테이너 | `python3 - collect --node nodeN` | [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)의 `collect`가 설치된 실행 파일의 버전 출력을 허용 목록으로 가공한다. `node1`에는 OpenProxy가 없으므로 그 값은 `null`이다. | -| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend dependencyInsight --dependency org.postgresql:postgresql --configuration testRuntimeClasspath --offline` | Gradle 테스트 런타임이 실제로 선택한 pgJDBC 버전을 확인한다. 선언 버전만 읽는 것과 다르다. | -| 같은 위치 | `./backend/gradlew -p backend dependencyInsight --dependency com.zaxxer:HikariCP --configuration testRuntimeClasspath --offline` | HikariCP의 실제 선택 버전을 확인한다. | -| 개발자 컴퓨터에서 A/B OpenProxy로 연결한 JUnit | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | JDBC 메타데이터의 PostgreSQL 서버·드라이버 버전도 원격 연결을 통해 교차 확인한다. 연결 변수와 비밀번호는 셸 환경에만 주입했다. | +| 실행 위치 | 명령 또는 호출 | 목적 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인된 GCP 계정·프로젝트인지 먼저 검사하고 기존 세 VM의 컨테이너에서 버전만 수집한다. 실제 실행에는 `OPENSQL_GCP_ZONE`, `OPENSQL_SSH_KEY`, `OPENSQL_EXPECTED_ACCOUNT`, `OPENSQL_EXPECTED_PROJECT`를 환경 변수로 제공했다. 값 자체는 공개하지 않는다. | 3개 노드·2개 관리자 스냅샷을 만들었다. 수집 구간은 2026-09-26 12:57:23~12:58:14 UTC다. | +| 스크립트가 SSH로 접근한 `node1/2/3`의 Rocky 컨테이너 | `python3 - collect --node nodeN` | [`capture_ha_contract.py`](../../../scripts/opensql/capture_ha_contract.py)의 `collect`가 설치된 실행 파일의 버전 출력을 허용 목록으로 가공한다. `node1`에는 OpenProxy가 없으므로 그 값은 `null`이다. | 모두 Rocky Linux 9.7 `x86_64`, OpenSQL `v3.17.8.7`, PostgreSQL `17.8`, Patroni `4.0.5`, etcd `3.6.5`; OpenProxy는 node2/3에서 `1.1.3` revision `723`이었다. | +| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend dependencyInsight --dependency org.postgresql:postgresql --configuration testRuntimeClasspath --offline` | Gradle 테스트 런타임이 실제로 선택한 pgJDBC 버전을 확인한다. 선언 버전만 읽는 것과 다르다. | `testRuntimeClasspath`에서 pgJDBC `42.7.11`이 선택됐다. | +| 같은 위치 | `./backend/gradlew -p backend dependencyInsight --dependency com.zaxxer:HikariCP --configuration testRuntimeClasspath --offline` | HikariCP의 실제 선택 버전을 확인한다. | HikariCP `6.3.3`이 선택됐다. **버전 조회이지 Hikari 실행 시험은 아니다.** | +| 개발자 컴퓨터에서 A/B OpenProxy로 연결한 JUnit | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | JDBC 메타데이터의 PostgreSQL 서버·드라이버 버전도 원격 연결을 통해 교차 확인한다. 연결 변수와 비밀번호는 셸 환경에만 주입했다. | 5개 테스트/실패 0건. A/B 모두 JDBC 서버 메타데이터 PostgreSQL `17.8`, 드라이버 `42.7.11`을 반환했다. | 수집 스크립트 안의 SSH 명령 형태는 다음과 같다. `nodeN`은 `node1`부터 `node3`까지 반복되고 SSH 키 경로는 공개하지 않는다. diff --git a/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md b/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md index 8dd656b..f4aeb17 100644 --- a/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md +++ b/docs/test-results/opensql-contract-verification/sanitized-snapshot-and-sha256.md @@ -24,6 +24,15 @@ python3 scripts/opensql/capture_ha_contract.py assemble \ python3 -c 'import json,hashlib,pathlib; p=pathlib.Path("docs/test-results/opensql-contract-evidence/contract-manifest.json"); d=json.loads(p.read_text()); b=json.dumps(d["snapshot"],ensure_ascii=False,sort_keys=True,separators=(",",":")).encode(); print(hashlib.sha256(b).hexdigest()); print(d["evidence_sha256"]); print(hashlib.sha256(b).hexdigest()==d["evidence_sha256"])' ``` +| 실행 위치 | 명령·수집기 모드 | 목적 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 승인 GCP 환경에서 다섯 스냅샷을 자동 수집한다. | 2026-09-26 12:57:23~12:58:14 UTC에 노드 3개·관리자 2개를 수집했다. | +| 각 GCP VM/컨테이너 | 수집기의 `collect`, `runtime`, `admin` | 설치값·호스트 실행 상태·프록시 관리값을 분리해서 읽는다. | 노드 3개, proxy-a/b 관리자 2개의 비식별 JSON이 생성됐다. | +| 개발자 컴퓨터 | `capture_ha_contract.py merge` | 같은 별칭의 컨테이너/호스트 결과만 결합한다. | node1/2/3 각각의 병합이 성공했고 중복·별칭 불일치가 관측되지 않았다. | +| 개발자 컴퓨터 | `capture_ha_contract.py assemble` | 다섯 결과의 허용 항목·A/B 일치 여부를 검사하고 canonical JSON을 해시한다. | 검증을 통과했고 SHA-256 `0a0b86ef…549794`를 manifest에 기록했다. | +| 개발자 컴퓨터, 문서 작성 시 재검산 | 위의 `python3 -c 'import json,hashlib,pathlib; ...'` | 공개 manifest의 `snapshot`에서 해시를 독립 재계산한다. | 재계산값과 기록값이 같았고 비교 결과가 `True`였다. 원격 상태를 새로 수집한 것은 아니다. | +| 개발자 컴퓨터 | `python3 -m unittest scripts.opensql.test_capture_ha_contract -v` | 비식별화·병합·해시의 코드 방어 동작을 확인한다. | 단위시험 `11개`, 실패 `0건`. 원격 서버 상태의 진실성 증명은 아니다. | + ## 코드가 하는 검증 [`merge_node()`](../../../scripts/opensql/capture_ha_contract.py)은 컨테이너 결과와 호스트 결과의 노드 별칭이 다르면 실패한다. [`assemble()`](../../../scripts/opensql/capture_ha_contract.py)은 node1/2/3이 정확히 하나씩 있는지, OS가 모두 Rocky 9.7 `x86_64`인지, A/B 설치·관리 계약 값이 같은지, OpenSQL 버전과 Patroni 동적 설정이 같은지 검사한다. 민감 문자열 탐지도 통과해야 한다. 그 뒤 `snapshot`을 `ensure_ascii=False`, `sort_keys=True`, `separators=(",",":")`로 직렬화한 **UTF-8 바이트**에 SHA-256을 적용한다. 수집 시작/종료 시각은 해시 대상 `snapshot`의 바깥 필드다. diff --git a/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md b/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md index b322239..d8813d7 100644 --- a/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md +++ b/docs/test-results/opensql-contract-verification/sql-prepare-vs-protocol-prepare.md @@ -12,10 +12,11 @@ ./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline ``` -| 방식 | Java 실행 위치·API | DB에 보낸 핵심 명령 | 목적 | -| --- | --- | --- | --- | -| SQL-level | 개발자 컴퓨터 JVM의 `Statement`, [`sqlLevelPrepareIsObservedSeparately()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `PREPARE contract_<무작위식별자>(integer) AS SELECT $1::integer` → `EXECUTE contract_<식별자>(41)` → `DEALLOCATE contract_<식별자>` | 앱이 명시적으로 보내는 서버 SQL 명령의 **같은 트랜잭션 내** 동작을 확인한다. 이름은 충돌 방지를 위해 실행 때마다 생성했다. | -| pgJDBC 프로토콜 | 같은 JVM의 `PreparedStatement`와 `PGStatement`, [`preparedStatementsSurviveTransactionPooling()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `SELECT ?::integer` 15회; URL에 `prepareThreshold=5` 또는 `1` | 드라이버가 server-side prepare 단계로 들어가고 반복 결과가 맞는지 확인한다. SQL-level `PREPARE` 문자열을 직접 보내는 시험이 아니다. | +| 방식 | Java 실행 위치·API | DB에 보낸 핵심 명령 | 목적 | 결과 요약 | +| --- | --- | --- | --- | --- | +| 계약 JUnit 실행 | 개발자 컴퓨터의 저장소 루트, Gradle | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | 아래 두 방식의 테스트를 포함한 전체 계약 시험을 실행한다. | JUnit 5개 통과·실패 0건. 두 준비문 테스트도 모두 통과했다. | +| SQL-level | 개발자 컴퓨터 JVM의 `Statement`, [`sqlLevelPrepareIsObservedSeparately()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `PREPARE contract_<무작위식별자>(integer) AS SELECT $1::integer` → `EXECUTE contract_<식별자>(41)` → `DEALLOCATE contract_<식별자>` | 앱이 명시적으로 보내는 서버 SQL 명령의 **같은 트랜잭션 내** 동작을 확인한다. 이름은 충돌 방지를 위해 실행 때마다 생성했다. | A/B 모두 `41` 반환, `same_transaction_pass`. 다른 트랜잭션·backend로의 재사용은 미검증. | +| pgJDBC 프로토콜 | 같은 JVM의 `PreparedStatement`와 `PGStatement`, [`preparedStatementsSurviveTransactionPooling()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java) | `SELECT ?::integer` 15회; URL에 `prepareThreshold=5` 또는 `1` | 드라이버가 server-side prepare 단계로 들어가고 반복 결과가 맞는지 확인한다. SQL-level `PREPARE` 문자열을 직접 보내는 시험이 아니다. | A/B × 임계값 2개, 네 조합 모두 15회 정확한 결과와 `isUseServerPrepare()=true`; 각 조합의 관측 backend는 `1개`. | SQL-level 테스트는 `setAutoCommit(false)` 이후 `PREPARE`, `EXECUTE`, `DEALLOCATE`를 **같은 트랜잭션 안에서** 실행해 `41`을 확인하고 `rollback()`했다. 프록시 A 결과와 B 결과가 같은지도 assert했다. 프로토콜 테스트는 같은 `PreparedStatement`를 유지한 채 **매 반복 후 commit**해 15개 트랜잭션을 통과했다. diff --git a/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md b/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md index 876978d..f352a16 100644 --- a/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md +++ b/docs/test-results/opensql-contract-verification/timezone-and-midnight-boundary.md @@ -8,13 +8,15 @@ 개발자 컴퓨터의 저장소 루트에서 A/B JDBC URL을 임시 SSH 터널로 주고 `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline`를 실행했다. 이때 자정 경계 테스트는 같은 JUnit 실행의 5개 테스트 중 하나였다. 별도로 개발자 컴퓨터에서 `bash scripts/opensql/capture_live_ha_contract.sh`를 실행했고, 그 스크립트의 `collect`·`runtime` 모드가 각각 Rocky 컨테이너와 GCP VM 호스트에서 `date +%Z%z`를 호출해 OS 시간대를 비식별화된 문자열로 기록했다. -| 위치 | 실행 코드·SQL | 목적 | -| --- | --- | --- | -| 개발자 컴퓨터 JVM | `ZoneId.systemDefault()` | 테스트 실행 JVM의 기본 시간대를 확인한다. | -| A/B를 통한 원격 DB 세션 | `SHOW TimeZone` | 실제 세션의 날짜 변환 기준을 확인한다. | -| 같은 DB 세션 | `SELECT ?::timestamp, ?::timestamptz` | 벽시계 날짜와 시간대가 포함된 순간이 각각 왕복 보존되는지 비교한다. | -| 같은 DB 세션의 명시적 트랜잭션 | `SET LOCAL TIME ZONE 'Pacific/Honolulu'` → `SHOW TimeZone` → `rollback()` → 다시 `SHOW TimeZone` | 트랜잭션 국소 설정이 풀의 다음 사용으로 새지 않는지 확인한다. | -| 세 GCP VM 호스트 및 세 Rocky 컨테이너 | `date +%Z%z` | OS 시간대 표기를 수집한다. DB 세션 시간대와 혼동하지 않는다. | +| 위치 | 실행 코드·SQL | 목적 | 결과 요약 | +| --- | --- | --- | --- | +| 개발자 컴퓨터의 저장소 루트 | `./backend/gradlew -p backend openSqlOpenProxyContractTest --rerun-tasks --offline` | A/B 자정 경계·세션 시험을 포함한 계약 JUnit을 실행한다. | 전체 5개 통과·실패 0건; A/B 모두 `boundary=pass`를 기록했다. | +| 개발자 컴퓨터의 저장소 루트 | `bash scripts/opensql/capture_live_ha_contract.sh` | 세 GCP VM 호스트와 Rocky 컨테이너의 시간대를 수집한다. | 노드 3개에서 호스트·컨테이너 모두 `UTC+0000`으로 기록됐다. | +| 개발자 컴퓨터 JVM | `ZoneId.systemDefault()` | 테스트 실행 JVM의 기본 시간대를 확인한다. | `Asia/Seoul`이었다. | +| A/B를 통한 원격 DB 세션 | `SHOW TimeZone` | 실제 세션의 날짜 변환 기준을 확인한다. | A/B 모두 `Asia/Seoul`이었다. | +| 같은 DB 세션 | `SELECT ?::timestamp, ?::timestamptz` | 벽시계 날짜와 시간대가 포함된 순간이 각각 왕복 보존되는지 비교한다. | A/B 모두 지정한 `2026-09-27 00:00:01` 벽시계값과 `+09:00` 순간을 보존해 `boundary=pass`. | +| 같은 DB 세션의 명시적 트랜잭션 | `SET LOCAL TIME ZONE 'Pacific/Honolulu'` → `SHOW TimeZone` → `rollback()` → 다시 `SHOW TimeZone` | 트랜잭션 국소 설정이 풀의 다음 사용으로 새지 않는지 확인한다. | 트랜잭션 안에서는 `Pacific/Honolulu`, rollback 후에는 A/B 모두 원래의 `Asia/Seoul`로 복귀했다. | +| 세 GCP VM 호스트 및 세 Rocky 컨테이너 | `date +%Z%z` | OS 시간대 표기를 수집한다. DB 세션 시간대와 혼동하지 않는다. | 호스트 3대와 컨테이너 3개 모두 `UTC+0000`이었다. | 핵심 Java 코드는 [`timeZoneBoundaryIsStableAcrossProxies()`](../../../backend/src/test/java/com/opensource/docgrid/opensql/OpenSqlOpenProxyContractTest.java)에 있다. 입력은 `LocalDateTime.of(2026, 9, 27, 0, 0, 1)`과 같은 벽시계값의 `OffsetDateTime` `+09:00`이다. `timestamp`는 `LocalDateTime`으로 같은 값인지 비교하고, `timestamptz`는 조회된 `OffsetDateTime`의 **`Instant`가 원본과 같은지** 비교했다. 표시 오프셋 문자열의 동일성이 아니라 순간의 동일성을 검사한 것이다. A/B의 `SHOW TimeZone` 및 서버 버전도 서로 같아야 테스트가 통과한다. From b3f9ebc31bc76e87135d0f92ba6de36bd035414f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sun, 27 Sep 2026 02:59:12 +0900 Subject: [PATCH 9/9] docs: remove technical blog draft label --- .../gimin-opensql-openproxy-hikari-troubleshooting-20260924.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md b/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md index f300776..ed9fbc9 100644 --- a/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md +++ b/docs/test-results/gimin-opensql-openproxy-hikari-troubleshooting-20260924.md @@ -1,6 +1,6 @@ # OpenProxy × HikariCP: 연결은 성공했는데 쓰기는 standby로 간 이유 -> 2026-09-24 실행 기록 및 기술 블로그 초안. 공개 가능한 내용만 담았다. VM·프로젝트 식별자, 사설 IP, 비밀번호, 라이선스 파일 및 서버 경로는 제외했다. +> 2026-09-24 실행 기록. 공개 가능한 내용만 담았다. VM·프로젝트 식별자, 사설 IP, 비밀번호, 라이선스 파일 및 서버 경로는 제외했다. ## 결과부터