4825 lines
218 KiB
Markdown
4825 lines
218 KiB
Markdown
# JPA/PostgreSQL Production Capability Deep Design
|
||
|
||
- 작성일: 2026-07-28
|
||
- 상태: 상세 설계 완료, 현행 기능별 R1 이하, JPA/PostgreSQL capability 전체 R2 미달
|
||
- 기준: Java 21, Spring Boot 4.0.0, Hibernate ORM 7.1.8, PostgreSQL 16,
|
||
Gradle 멀티모듈 Clean Architecture
|
||
- 대상 leaf: `adapter-outbound-persistence-jpa`
|
||
- Gradle path: `:adapter:outbound:persistence-jpa`
|
||
- 상위 문서:
|
||
[Production Capability Platform Design](2026-07-26-production-capability-platform-design.md)
|
||
- 관련 심화 문서:
|
||
[Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md),
|
||
[FileServer Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md),
|
||
[Messaging Production Capability Deep Design](2026-07-28-messaging-production-capability-design.md)
|
||
|
||
## 0. 구현 상태
|
||
|
||
2026-07-28 현재 구현된 범위:
|
||
|
||
- `application-core`가 Spring annotation 없이 transaction intent를 선언하는
|
||
`TransactionPort`;
|
||
- `REQUIRED` write/read-only와 제한적인 `REQUIRES_NEW` transaction template;
|
||
- 모든 현재 transaction mode의 명시적 `READ_COMMITTED` isolation;
|
||
- adapter-owned JPA auditing base class와 명시적 audit stamp;
|
||
- SQLState 기반 표준/PostgreSQL failure 분류 SPI와 web error carrier;
|
||
- unique scope, request fingerprint, TTL, reaper를 가진 JPA idempotency V1;
|
||
- inline response와 optional object-storage response seam;
|
||
- PostgreSQL `FOR UPDATE SKIP LOCKED`를 사용하는 polling outbox V1;
|
||
- local/JDBC 선택이 가능한 Spring Integration JDBC efficiency lock;
|
||
- Flyway V1, V3, V4, V5 production migration;
|
||
- OSIV disable 계약, datasource/Hikari/Flyway 설정 및 일부 startup validator;
|
||
- sample의 aggregate entity, optimistic version, JPQL constructor projection,
|
||
PostgreSQL integration test seam.
|
||
|
||
그러나 다음 핵심 범위는 구현되지 않았거나 실제 production 경로에 연결되지 않았다.
|
||
|
||
- 모든 repository/query operation을 통과하는 persistence failure translation boundary;
|
||
- constraint name 기반의 허용 목록 conflict mapping;
|
||
- commit 응답 유실과 일반 connection failure를 분리하는 commit outcome;
|
||
- application deadline과 transaction/statement/lock/pool acquisition timeout의 계층;
|
||
- transaction policy별 isolation, retry eligibility, query budget;
|
||
- primary/replica datasource, 명시적 read consistency와 lag gate;
|
||
- owner token/CAS를 가진 idempotency V2;
|
||
- immutable event와 delivery state를 분리한 owner-safe outbox V2;
|
||
- same-store inbox;
|
||
- query ID, N+1 budget, representative `EXPLAIN` plan qualification;
|
||
- PostgreSQL을 필수로 기동하는 non-skippable CI task;
|
||
- rolling schema compatibility, restore/failover rehearsal evidence;
|
||
- tenant discriminator/RLS profile;
|
||
- typed primary/replica pool capacity와 shutdown/quiesce 계약.
|
||
|
||
따라서 이 문서에서 “설계 완료”는 구현 계약, 보장 경계, 검증 순서가 결정되었다는 뜻이다.
|
||
현재 JPA leaf나 이를 사용하는 애플리케이션이 production-ready라는 뜻이 아니다.
|
||
|
||
### 0.1 현재 capability별 준비도
|
||
|
||
| Capability card | 현재 | 이 문서의 R2 목표 | 현재 판정 이유 |
|
||
| --- | --- | --- | --- |
|
||
| JPA aggregate store | production R0 / sample reference R1 | R2 | production leaf에는 목표 aggregate 구현이 없고 sample CRUD와 optimistic version만 참고 증거로 존재한다. |
|
||
| Application transaction | R1 | R2 | 세 mode와 `READ_COMMITTED`만 있으며 deadline, outcome, policy가 없다. |
|
||
| Query model | production R0 / sample reference R1 | R2 | production query card는 없고 sample projection에 bound, N+1, plan gate가 없다. |
|
||
| Flyway migration | R1 | R2 | migration 실행은 있으나 rolling/large-table/rollback evidence가 없다. |
|
||
| Polling outbox | R1 | R2 | claim은 있으나 owner-safe completion, aggregate sequence, delivery 분리가 없다. |
|
||
| JPA idempotency | R1 | R2 | scope V1이며 stale owner가 새 claim을 변경할 수 있다. |
|
||
| JDBC coordination | R1 efficiency | R2 efficiency | fencing/renewal/owner-safe release가 없으므로 correctness lock이 아니다. |
|
||
| Primary/replica routing | R0 | R2 optional | 구현과 consistency/lag evidence가 없다. |
|
||
| Same-store inbox | R0 | R2 optional | schema, port, consumer transaction choreography가 없다. |
|
||
| Tenant isolation | R0 | R2 optional | tenant key, query enforcement, RLS가 없다. |
|
||
|
||
`R1`은 local/testable implementation evidence, `R2`는 production profile evidence,
|
||
`R3`는 실제 운영 및 복구 rehearsal evidence를 의미한다. 한 card가 R2라고 해서 leaf 전체나
|
||
다른 card의 R2를 대신하지 않는다.
|
||
|
||
## 1. 설계 판정
|
||
|
||
이 저장소에서 JPA는 범용 ORM 편의 계층이 아니라 다음 capability다.
|
||
|
||
> application이 정의한 transaction과 repository/query port 뒤에서 aggregate state,
|
||
> same-store reliability record, schema evolution을 PostgreSQL에 안전하게 영속화하고,
|
||
> concurrency, timeout, failure, consistency의 의미를 framework-neutral 결과로 보존하는 기능
|
||
|
||
선택한 핵심 구조는 다음과 같다.
|
||
|
||
1. 정확히 19개인 현재 leaf registry를 유지하고 JPA 공통 코드와 PostgreSQL 전용 코드는
|
||
같은 leaf의 `.postgresql` package로 격리한다.
|
||
2. `domain-core` entity와 persistence entity를 분리하며 JPA annotation을 core로 유출하지
|
||
않는다.
|
||
3. application에는 aggregate별 repository port, 목적별 query port, transaction policy만
|
||
노출한다. `JpaRepository`, `EntityManager`, `Pageable`, `Sort`, `Specification`,
|
||
Hibernate type은 노출하지 않는다.
|
||
4. aggregate command 경로는 JPA를 기본으로 하고, read model은 JPQL projection 또는
|
||
PostgreSQL native/JDBC query를 목적별 adapter 내부 구현으로 선택한다.
|
||
5. transaction boundary는 application use case가 `TransactionPort`로 소유한다.
|
||
controller, repository adapter, mapper, scheduler가 business transaction policy를
|
||
새로 만들지 않는다.
|
||
6. 기존 `inWrite`, `inRead`, `inNew`는 source-compatible facade로 유지하되,
|
||
named transaction policy와 absolute `CallBudget`를 받을 수 있는 additive contract로
|
||
진화시킨다.
|
||
7. 기존 `inRead`는 항상 primary의 strong read다. replica는 명시적
|
||
`ReadConsistency`와 lag qualification 없이는 사용하지 않는다.
|
||
8. optimistic concurrency를 일반 aggregate의 기본값으로 한다. pessimistic lock은
|
||
짧고 bounded된 indexed critical section 또는 queue claim에만 사용한다.
|
||
9. SQLState, Spring/Hibernate exception, transaction phase를 하나의 translation boundary에서
|
||
typed failure와 retry disposition으로 변환한다.
|
||
10. connection failure가 commit 단계에 발생하면 `COMMIT_INDETERMINATE`로 분류하고
|
||
자동 재실행하지 않는다. stable operation ID 또는 business key로 먼저 reconcile한다.
|
||
11. Flyway가 physical schema의 유일한 writer다. production에서 Hibernate schema update,
|
||
create, create-drop은 금지한다.
|
||
12. schema 변경은 expand/bridge/backfill/switch/enforce/observe/contract로 진행하고
|
||
최소 N/N-1 application 호환성을 검증한다.
|
||
13. 같은 PostgreSQL transaction에 business write와 outbox/inbox/idempotency transition을
|
||
함께 넣을 때만 same-store atomicity를 주장한다.
|
||
14. database transaction 안에서 broker, HTTP, object storage, file server 같은 remote
|
||
side effect를 수행하지 않는다.
|
||
15. 실제 PostgreSQL, concurrency, timeout, migration, query-plan test가 필수 CI lane에서
|
||
통과하기 전에는 R2라고 표현하지 않는다.
|
||
|
||
## 2. 상위 설계와 이번 심화 설계의 관계
|
||
|
||
상위 통합 설계는 이미 다음을 결정했다.
|
||
|
||
- OSIV를 사용하지 않는다.
|
||
- transaction boundary는 application이 소유한다.
|
||
- Flyway migration과 failure translation을 사용한다.
|
||
- pool, timeout, batch/fetch, N+1, query plan을 운영 계약으로 다룬다.
|
||
- optimistic lock을 기본으로 하고 pessimistic lock을 제한한다.
|
||
- replica read에는 명시적 consistency가 필요하다.
|
||
- outbox, idempotency, inbox의 same-store transaction을 지원한다.
|
||
- PostgreSQL 전용 구현은 vendor package와 real-service test로 한정한다.
|
||
|
||
이번 문서는 위 결정을 구현 계획으로 바꿀 수 있도록 다음을 추가로 고정한다.
|
||
|
||
- 현재 코드의 실제 구현 수준과 결함;
|
||
- application transaction API의 additive evolution;
|
||
- transaction phase와 commit uncertainty;
|
||
- retry 가능 조건과 금지 조건;
|
||
- primary/replica routing 시점과 nested transaction 규칙;
|
||
- datasource, pool, admission, timeout의 산정과 validation;
|
||
- entity ID, time, enum, audit, version, relation baseline;
|
||
- query projection, fetch plan, cursor, statement budget, plan evidence;
|
||
- constraint/index/lock/tenant schema 계약;
|
||
- migration job과 application startup의 분리;
|
||
- owner-safe idempotency/outbox/inbox schema와 transaction choreography;
|
||
- 설정 activation, health, observability, security, shutdown;
|
||
- 실제 PostgreSQL CI lane과 readiness 승격 조건.
|
||
|
||
JPA/PostgreSQL 범위에서 이 문서와 상위 문서의 요약이 충돌하면 이 문서가 더 구체적인
|
||
정본이다. 다른 capability의 결정은 변경하지 않는다.
|
||
|
||
### 2.1 Normative decision ledger
|
||
|
||
| 결정 | 정본 |
|
||
| --- | --- |
|
||
| readiness와 capability card | §0, §9 |
|
||
| HARD invariant | §5 |
|
||
| 모듈/계층/package 소유권 | §7–§8 |
|
||
| application repository/query contract | §10 |
|
||
| entity와 mapping baseline | §11 |
|
||
| transaction contract/policy/propagation | §10.3–§10.4, §12 |
|
||
| isolation과 concurrency | §13 |
|
||
| locking/JDBC coordination | §14 |
|
||
| failure, retry, commit outcome | §15 |
|
||
| pool/admission/timeout | §16–§17 |
|
||
| write/batch/query/pagination | §18–§20 |
|
||
| primary/replica consistency | §21 |
|
||
| same-store idempotency/outbox/inbox | §22 |
|
||
| Flyway와 rolling migration | §23 |
|
||
| schema/index/query-plan | §24 |
|
||
| tenancy와 security | §25–§26 |
|
||
| configuration/activation/health | §27–§28 |
|
||
| observability/lifecycle/DR | §29–§30 |
|
||
| test/CI/evidence | §31 |
|
||
| Gradle/dependency/split trigger | §32 |
|
||
| 단계별 migration과 완료 기준 | §33–§34 |
|
||
|
||
예시 코드나 YAML이 표의 정본 절과 충돌하면 정본 절을 따른다. deprecated alias와 canonical
|
||
setting이 동시에 주어지면 임의 precedence를 선택하지 않고 startup을 실패시킨다.
|
||
|
||
## 3. 현재 코드의 증거 기반 진단
|
||
|
||
### 3.1 모듈 경계
|
||
|
||
`src/config/architecture/modules.json`은 다음을 유일한 registry로 정의한다.
|
||
|
||
```text
|
||
id adapter-outbound-persistence-jpa
|
||
source_path src/adapter/outbound/persistence-jpa
|
||
gradle_path :adapter:outbound:persistence-jpa
|
||
allowed_dependencies
|
||
- domain-core
|
||
- application-core
|
||
- shared-contract
|
||
```
|
||
|
||
실제 leaf는 `application-core`, `shared-contract`, Spring Data JPA, Spring Integration JDBC,
|
||
Flyway와 PostgreSQL runtime을 소유한다. 조사 시점에 다음 HARD-STOP 위반은 발견하지 않았다.
|
||
|
||
- `domain-core`의 Spring/JPA 의존;
|
||
- application의 inbound DTO 또는 JPA type 의존;
|
||
- controller의 repository 직접 호출;
|
||
- production leaf의 sample 의존;
|
||
- registry 밖 project dependency.
|
||
|
||
현재 한 leaf 안에 vendor-neutral JPA와 PostgreSQL 전용 SQL이 함께 있다. 두 번째 RDBMS나
|
||
vendor SDK의 독립 release/security boundary가 실제로 생기기 전에는 leaf를 늘리지 않는다.
|
||
|
||
### 3.2 현행 구현과 운영 의미
|
||
|
||
| 영역 | 현재 구현 | 운영상 의미 |
|
||
| --- | --- | --- |
|
||
| Transaction | write/read/requires-new template, 모두 `READ_COMMITTED` | deadline, stricter isolation, phase-aware outcome이 없다. |
|
||
| Read | `inRead`가 read-only hint만 설정 | primary/replica 의미와 read consistency가 없다. |
|
||
| Failure | SQLState mapping component | production repository가 translator를 호출하지 않아 raw exception이 escape한다. |
|
||
| Mapping merge | `putAll` | 같은 SQLState의 중복 등록이 조용히 덮어써진다. |
|
||
| Audit | `AuditableEntity`와 manual stamp | adapter ownership은 맞지만 bulk DML과 update copy 규칙이 명시되지 않았다. |
|
||
| Idempotency | unique scope + status + TTL | owner token과 owner-checked CAS가 없다. |
|
||
| Outbox | 단일 event row에 claim/delivery state | immutable event와 mutable delivery가 섞이고 completion owner 검증이 없다. |
|
||
| Outbox order | timestamp 중심 | 같은 timestamp와 aggregate별 strict sequence가 정의되지 않는다. |
|
||
| Maintenance | idempotency/outbox reaper가 `@Scheduled @Transactional`을 직접 소유 | 목표 named maintenance policy와 application command 경계로 이동해야 한다. |
|
||
| Lock | Spring Integration JDBC lock | efficiency coordination이며 fencing correctness를 제공하지 않는다. |
|
||
| Migration | Flyway V1/V3/V4/V5 | rolling compatibility, large backfill, nontransactional DDL 절차가 없다. |
|
||
| Pool validation | 일부 Hikari 제약 | `5s` 같은 Duration을 parse하지 못하면 검증을 조용히 건너뛴다. |
|
||
| ORM schema | env에서 `ddl-auto` 선택 | local default `update`가 있고 production runtime guard가 충분하지 않다. |
|
||
| Query | sample projection와 paging | max size, stable ordering, N+1/plan gate가 없다. |
|
||
| Real DB test | app-bootstrap/sample의 PostgreSQL test | Docker 부재 시 assumption으로 skip될 수 있다. |
|
||
| Metrics | registry에 `db.query.duration` | 실제 bounded query recorder는 확인되지 않았다. |
|
||
| Activation | broad entity/repository scan | explicit provider/topology activation과 disabled zero-side-effect가 없다. |
|
||
|
||
### 3.3 Failure translation은 실제 경로에 연결되지 않았다
|
||
|
||
`PersistenceExceptionTranslator`는 SQLState를 분류하지만 production code에서
|
||
`translate(...)`를 호출하는 repository/query adapter를 찾을 수 없다. web handler가
|
||
`PersistenceFailureException`을 처리해도 그 carrier가 만들어지지 않으면 계약은 성립하지
|
||
않는다.
|
||
|
||
목표 설계는 다음을 요구한다.
|
||
|
||
- 모든 aggregate repository, query adapter, same-store infrastructure store가 공통
|
||
operation executor 또는 동일한 translation rule을 통과한다.
|
||
- mapping duplicate는 startup fail-fast다.
|
||
- framework exception, SQLState, constraint name, transaction phase를 함께 본다.
|
||
- unknown failure를 성공이나 retryable로 추정하지 않는다.
|
||
- client 응답에는 SQL, SQLState, constraint/table/column 이름을 노출하지 않는다.
|
||
|
||
### 3.4 Idempotency V1의 stale-owner 위험
|
||
|
||
현재 `complete`와 `discard`는 scope만으로 row를 갱신한다. 만료된 owner A 뒤 owner B가 같은
|
||
scope를 재획득했을 때 A의 늦은 completion이 B의 claim을 변경할 수 있다. 또한
|
||
`DataIntegrityViolationException` 전체를 claim 경쟁으로 보는 것은 다른 schema 결함을
|
||
숨길 수 있다.
|
||
|
||
R2에서는 다음 owner-safe transition이 필요하다.
|
||
|
||
```text
|
||
ABSENT
|
||
-- claim(ownerToken, leaseUntil, requestHash) --> CLAIMED
|
||
|
||
CLAIMED(owner=A)
|
||
-- markExecutionStarted(owner=A) --------------> EXECUTING(owner=A)
|
||
-- renew(owner=A) -----------------------------> CLAIMED(owner=A)
|
||
-- expire + takeover(owner=B) -----------------> CLAIMED(owner=B, attempt+1)
|
||
-- releaseBeforeExecution(owner=A) ------------> ABSENT
|
||
|
||
EXECUTING(owner=A)
|
||
-- renew(owner=A) -----------------------------> EXECUTING(owner=A)
|
||
-- complete(owner=A, response) ----------------> COMPLETED
|
||
-- no-effect confirmed ------------------------> FAILED_RETRYABLE
|
||
-- expire/effect unknown ----------------------> ABANDONED / RECOVERY_REQUIRED
|
||
|
||
owner=A의 늦은 renew/complete/release
|
||
------------------------------------------------> OWNER/ATTEMPT/REVISION_MISMATCH, no mutation
|
||
```
|
||
|
||
만료된 `EXECUTING`은 blind takeover하지 않는다. committed/no-effect evidence를 inspect한 뒤
|
||
reconcile complete 또는 explicit reopen만 허용한다.
|
||
|
||
### 3.5 Outbox V1의 ownership와 ordering 위험
|
||
|
||
현재 claim은 PostgreSQL `SKIP LOCKED`를 사용하지만 `markPublished/Failed/Dead(eventId)`가
|
||
claim owner와 현재 status를 검증하지 않는다. timestamp-only ordering은 aggregate 단위
|
||
ordering을 보장하지 않으며 DEAD row가 뒤 event 진행을 막을 때의 operator 정책도 없다.
|
||
|
||
R2에서는 다음을 분리한다.
|
||
|
||
- `outbox_event_log_v2`: immutable business event envelope;
|
||
- `outbox_delivery_v2`: destination별 mutable claim/attempt/ack state;
|
||
- `(aggregate_type, aggregate_id, aggregate_version, event_ordinal)` unique order key;
|
||
- `claim_owner`, `claim_token`, `claim_until` owner-safe CAS;
|
||
- terminal transition의 current status + token 검증;
|
||
- operator requeue/skip/quarantine audit.
|
||
|
||
### 3.6 현재 test evidence의 한계
|
||
|
||
JPA leaf focused test는 unit/mock/wiring 중심이다. PostgreSQL integration test가 다른 module에
|
||
존재해도 Docker가 없을 때 skip되면 R2 gate가 아니다. 다음 항목은 real PostgreSQL에서
|
||
non-skippable task로 검증해야 한다.
|
||
|
||
- isolation anomaly와 whole-transaction retry;
|
||
- optimistic conflict, deadlock, lock/statement timeout;
|
||
- commit-uncertainty fault seam;
|
||
- pool exhaustion과 acquisition timeout;
|
||
- idempotency/outbox/inbox concurrent owner transition;
|
||
- rolling migration N/N-1 compatibility;
|
||
- index/query plan invariant;
|
||
- primary/replica routing과 lag/failover;
|
||
- backup restore와 migration forward recovery.
|
||
|
||
## 4. 범위와 명시적 비범위
|
||
|
||
### 4.1 primary-only JPA R2 baseline에 포함
|
||
|
||
- aggregate persistence를 위한 JPA entity, mapper, Spring Data repository adapter;
|
||
- application-owned write/read/requires-new transaction;
|
||
- transaction deadline와 PostgreSQL local timeout;
|
||
- failure translation, retry disposition, commit uncertainty;
|
||
- optimistic version과 제한적 pessimistic lock;
|
||
- purpose-built query projection, fetch plan, bounded paging/cursor;
|
||
- Hikari pool capacity, acquisition timeout, admission, lifecycle;
|
||
- Flyway schema validation과 expand-contract migration;
|
||
- PostgreSQL-specific SQLState, native query, lock/claim implementation;
|
||
- typed settings, startup validation, health, metrics, traces, runbook;
|
||
- real PostgreSQL integration, concurrency, migration, plan CI.
|
||
|
||
### 4.2 독립 승격 capability card
|
||
|
||
다음은 primary-only JPA R2의 자동 포함 항목이 아니라 같은 leaf가 제공할 수 있는 독립
|
||
capability card다.
|
||
|
||
- owner-safe JPA idempotency;
|
||
- immutable event outbox storage;
|
||
- owner-safe polling delivery;
|
||
- connector checkpoint 기반 CDC retention/cleanup;
|
||
- same-store inbox;
|
||
- primary/replica routing;
|
||
- tenant discriminator/RLS;
|
||
- JDBC efficiency coordination.
|
||
|
||
canonical optional card ID와 prerequisite는 다음과 같다.
|
||
|
||
| Optional card ID | Prerequisite card ID | Required non-skippable task |
|
||
| --- | --- | --- |
|
||
| `jpa-idempotency-owner-safe-v2` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest` |
|
||
| `jpa-outbox-storage-v2` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest` |
|
||
| `jpa-outbox-polling-delivery-v2` | `jpa-outbox-storage-v2`, `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest` |
|
||
| `jpa-outbox-cdc-retention-v1` | `jpa-outbox-storage-v2`, `jpa-observability-lifecycle`; external `messaging-cdc-dispatch.v1` R2 | `:adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest` |
|
||
| `jpa-inbox-same-store-v1` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest` |
|
||
| `jpa-primary-replica` | `jpa-transaction-runtime`, `jpa-query-model`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest` |
|
||
| `jpa-tenant-discriminator-rls` | `jpa-primary-foundation` | `:adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest` |
|
||
| `jpa-jdbc-efficiency-coordination` | `jpa-transaction-runtime`, `jpa-flyway-migration`, `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest` |
|
||
|
||
각 card는 자기 non-skippable PostgreSQL evidence manifest가 있을 때만 별도로 R2가 된다.
|
||
한 card의 증거를 다른 card나 JPA leaf 전체의 준비도로 합산하지 않는다. base card ID와
|
||
dependency graph, required task의 machine-readable 정본은 §31.3의
|
||
`src/config/jpa/readiness-cards.yaml`이며 이 표와 §34는 그 projection이다.
|
||
|
||
### 4.3 optional profile
|
||
|
||
- primary/replica read routing;
|
||
- tenant discriminator와 defense-in-depth RLS;
|
||
- JDBC efficiency coordination;
|
||
- database-backed scheduled maintenance;
|
||
- read-model용 native/JDBC projection;
|
||
- PgBouncer 또는 managed proxy.
|
||
|
||
optional profile도 활성화되면 해당 profile의 R2 gate를 모두 충족해야 한다. 사용하지 않는
|
||
profile의 bean, pool, scheduler, migration이 side effect를 만들면 안 된다.
|
||
|
||
### 4.4 이번 범위에서 제외
|
||
|
||
- business aggregate와 use case의 구체 설계: 목표 domain 또는 `sample-portfolio` 책임;
|
||
- controller validation, HTTP status, inbound DTO: inbound adapter 책임;
|
||
- idempotency response의 object-storage reference/finalization: 별도 cross-store response card
|
||
책임이며 이번 `SAME_STORE_TRANSACTIONAL` R2에서 제외;
|
||
- broker publish, external notification: messaging/notification adapter 책임;
|
||
- cache/session/rate-limit: Redis 또는 해당 provider 책임;
|
||
- MongoDB query와 document schema: persistence-mongo 책임;
|
||
- generic reporting/analytics warehouse;
|
||
- XA/2PC와 cross-store exactly-once;
|
||
- arbitrary SQL console 또는 application-facing generic query language;
|
||
- database provisioning, replication orchestration, managed-service control plane;
|
||
- DBA 운영 도구 자체 구현;
|
||
- 두 번째 RDBMS 지원을 가정한 선제 module split.
|
||
|
||
## 5. HARD invariants
|
||
|
||
다음 중 하나라도 위반하면 기능이 동작해도 완료가 아니다.
|
||
|
||
1. `domain-core`는 JPA, Hibernate, Spring, JDBC, SQL, database type을 import하지 않는다.
|
||
2. `application-core`는 `JpaRepository`, `EntityManager`, `Page`, `Pageable`, `Sort`,
|
||
`Specification`, persistence entity를 import하지 않는다.
|
||
3. controller는 repository, Spring Data interface, persistence entity를 직접 사용하지 않는다.
|
||
4. inbound DTO를 repository/query/application transaction contract에 전달하지 않는다.
|
||
5. entity mapper, converter, repository default method에 business invariant를 두지 않는다.
|
||
6. application use case 밖에서 business transaction boundary를 새로 만들지 않는다.
|
||
7. OSIV를 켜거나 lazy loading에 web serialization correctness를 의존하지 않는다.
|
||
8. Hibernate schema update/create를 production schema writer로 사용하지 않는다.
|
||
9. database connection을 보유한 채 broker/HTTP/object storage/file server를 호출하지 않는다.
|
||
10. replica read를 strong read 또는 read-your-writes라고 암묵적으로 표현하지 않는다.
|
||
11. `08*` connection error를 commit 여부가 확실한 일반 retryable failure로 합치지 않는다.
|
||
12. stable operation ID와 reconciliation 없이 commit-indeterminate command를 자동 재실행하지
|
||
않는다.
|
||
13. unique violation 전체를 idempotency claim race나 domain conflict로 간주하지 않는다.
|
||
14. application이 전달한 raw table/column/order/expression을 SQL identifier로 조합하지 않는다.
|
||
15. unbounded collection fetch, unbounded `IN`, unbounded offset/page size를 허용하지 않는다.
|
||
16. pessimistic lock 구간 안에서 remote I/O, user think time, unbounded computation을 수행하지
|
||
않는다.
|
||
17. efficiency JDBC lock을 fencing correctness lock으로 광고하지 않는다.
|
||
18. `REQUIRES_NEW`를 record별 loop나 동시 request마다 무제한 중첩하지 않는다.
|
||
19. migration 파일을 적용 후 수정하거나 checksum repair를 정상 배포 절차로 삼지 않는다.
|
||
20. tenant profile에서 tenant predicate 없는 query/unique/index를 허용하지 않는다.
|
||
21. 사용하지 않는 replica/maintenance/optional provider가 connection 또는 scheduler를
|
||
생성하지 않는다.
|
||
22. real PostgreSQL test를 조건부 skip한 결과로 R2를 주장하지 않는다.
|
||
23. SQL/parameter/PII/high-cardinality identifier를 metric tag나 일반 log에 기록하지 않는다.
|
||
24. `src/config/architecture/modules.json` 밖의 dependency edge를 설계 편의로 추가하지 않는다.
|
||
|
||
## 6. 대안 검토
|
||
|
||
### A. JPA entity를 domain entity로 통합
|
||
|
||
boilerplate가 줄어들지만 domain이 JPA annotation, lazy proxy, collection lifecycle,
|
||
no-arg constructor, persistence identity에 결합한다. template의 가장 중요한 교체 가능성과
|
||
HARD-STOP을 훼손하므로 선택하지 않는다.
|
||
|
||
### B. application에 Spring Data repository를 직접 노출
|
||
|
||
paging과 query 작성은 빠르지만 transport/framework type이 use case contract가 된다.
|
||
aggregate별 command port와 목적별 query port를 유지한다.
|
||
|
||
### C. 모든 query를 JPA entity graph로 해결
|
||
|
||
단순 조회에는 편하지만 reporting projection, keyset, PostgreSQL lock/claim, plan control에
|
||
불리하다. aggregate write는 JPA, read model은 JPQL projection 또는 native/JDBC를 선택하는
|
||
hybrid를 채택한다.
|
||
|
||
### D. 모든 query를 jOOQ로 전환
|
||
|
||
SQL type safety와 query visibility는 장점이지만 build/code-generation, license/edition,
|
||
schema source, module dependency가 추가된다. 현재 요구 증거가 없으므로 R2 baseline에서
|
||
도입하지 않는다. 복잡한 read model이 충분히 늘고 native query drift가 실제 비용이 될 때
|
||
별도 설계로 검토한다.
|
||
|
||
### E. repository adapter마다 `@Transactional`
|
||
|
||
호출 단위는 단순해지지만 하나의 use case가 여러 port를 원자적으로 묶기 어렵고 transaction
|
||
policy가 adapter에 흩어진다. application-owned `TransactionPort`를 유지한다.
|
||
|
||
### F. 모든 transient SQLState를 자동 retry
|
||
|
||
재시도 편의는 생기지만 callback에 remote side effect가 있거나 commit outcome이 unknown이면
|
||
중복 실행을 만든다. explicit replay-safe policy가 있는 whole transaction만 bounded retry한다.
|
||
|
||
### G. read-only transaction은 자동 replica
|
||
|
||
코드 변경이 적지만 read-after-write, lag, failover semantics가 숨겨진다. explicit
|
||
`ReadConsistency`와 route qualification을 선택한다.
|
||
|
||
### H. `REQUIRES_NEW`로 outbox publish까지 감싸기
|
||
|
||
broker publish와 DB mark 사이의 원자성은 생기지 않는다. business transaction은 outbox append만
|
||
포함하고 relay는 claim/publish/terminal transition을 분리한다.
|
||
|
||
### I. PostgreSQL provider를 즉시 별도 leaf로 분리
|
||
|
||
현재는 registry 증가와 composition 복잡성만 만든다. 두 번째 RDBMS, 독립 SDK release,
|
||
security boundary가 실제로 생길 때만 split한다.
|
||
|
||
### J. PgBouncer를 R2 필수 baseline으로 지정
|
||
|
||
deployment에 따라 유용하지만 transaction/session pooling mode, prepared statement,
|
||
startup parameter, failover topology가 달라진다. 별도 qualified optional profile로 둔다.
|
||
|
||
## 7. 목표 아키텍처
|
||
|
||
```text
|
||
adapter:inbound:*
|
||
|
|
||
v
|
||
application use case
|
||
| - validates command semantics
|
||
| - selects named TransactionPolicyId
|
||
| - owns retry/reconciliation decision
|
||
|
|
||
+--> TransactionPort -----------------------------+
|
||
| |
|
||
+--> AggregateRepositoryPort |
|
||
+--> PurposeBuiltQueryPort |
|
||
+--> OutboxAppendPort / IdempotencyPort / InboxPort|
|
||
v
|
||
adapter-outbound-persistence-jpa
|
||
+------------------------------+
|
||
| transaction |
|
||
| routing |
|
||
| failure |
|
||
| aggregate/<feature> |
|
||
| query/<feature> |
|
||
| idempotency/outbox/inbox |
|
||
| migration configuration |
|
||
| postgresql/ |
|
||
+------------------------------+
|
||
| |
|
||
v v
|
||
primary pool optional replica pool
|
||
| |
|
||
+------ PostgreSQL ------+
|
||
```
|
||
|
||
Transaction path:
|
||
|
||
```text
|
||
use case
|
||
-> resolve named policy
|
||
-> intersect policy timeout with CallBudget
|
||
-> resolve route before transaction begins
|
||
-> acquire admission permit
|
||
-> acquire connection / begin
|
||
-> SET LOCAL statement_timeout / lock_timeout / context
|
||
-> execute repository/query callbacks
|
||
-> flush
|
||
-> commit
|
||
-> classify phase-aware outcome
|
||
-> release connection and admission
|
||
```
|
||
|
||
`route`, `transaction`, `timeout`, `failure`는 repository별 임의 utility가 아니라 공통 runtime
|
||
boundary다. 그러나 application에는 하나의 generic persistence command API를 노출하지 않고
|
||
각 feature의 semantic port를 유지한다.
|
||
|
||
## 8. 모듈과 package 소유권
|
||
|
||
### 8.1 `domain-core`
|
||
|
||
소유:
|
||
|
||
- aggregate/entity/value object/domain event;
|
||
- invariant와 state transition;
|
||
- framework-free repository semantics가 정말 domain vocabulary일 때의 port.
|
||
|
||
금지:
|
||
|
||
- `@Entity`, `@MappedSuperclass`, `@Version`, `@Column`;
|
||
- `Instant`를 SQL timestamp로 변환하는 persistence 규칙;
|
||
- lazy collection, proxy, `EntityManager`;
|
||
- retry, SQLState, isolation, replica.
|
||
|
||
### 8.2 `application-core`
|
||
|
||
소유:
|
||
|
||
- command와 use case;
|
||
- aggregate별 repository port;
|
||
- purpose-built query port와 framework-neutral result/cursor;
|
||
- `TransactionPort`, `TransactionPolicyId`, `ReadConsistency`, `CallBudget`;
|
||
- idempotency/outbox/inbox semantic contract;
|
||
- typed application failure와 reconciliation command.
|
||
|
||
금지:
|
||
|
||
- Spring transaction annotation;
|
||
- JPA entity/repository/type;
|
||
- transport DTO;
|
||
- raw SQL, table/constraint name;
|
||
- provider topology와 JDBC URL.
|
||
|
||
### 8.3 `adapter-outbound-persistence-jpa`
|
||
|
||
소유:
|
||
|
||
- JPA entity, embedded ID, attribute converter;
|
||
- Spring Data repository;
|
||
- domain/persistence mapper;
|
||
- repository/query port implementation;
|
||
- transaction manager bridge와 route context;
|
||
- audit persistence metadata;
|
||
- failure translation;
|
||
- idempotency/outbox/inbox persistence implementation;
|
||
- Flyway migration;
|
||
- Hikari/JPA/PostgreSQL provider settings와 validation;
|
||
- PostgreSQL native query/SQLState/timeout/claim;
|
||
- real PostgreSQL qualification test source set.
|
||
|
||
권장 package shape:
|
||
|
||
```text
|
||
dev.caskeleton.adapter.outbound.persistence
|
||
├── audit
|
||
├── config
|
||
├── failure
|
||
├── routing
|
||
├── transaction
|
||
├── query
|
||
├── idempotency
|
||
├── outbox
|
||
├── inbox
|
||
├── lock
|
||
├── migration
|
||
└── postgresql
|
||
├── config
|
||
├── failure
|
||
├── routing
|
||
├── timeout
|
||
├── query
|
||
├── idempotency
|
||
├── outbox
|
||
└── inbox
|
||
```
|
||
|
||
feature aggregate의 persistence entity/repository/mapper는 production 목표 domain이 생기면
|
||
그 feature package에 둔다. `sample-portfolio` entity는 sample module에 남긴다.
|
||
|
||
### 8.4 `app-bootstrap`
|
||
|
||
소유:
|
||
|
||
- canonical activation SSOT;
|
||
- datasource/secret material binding;
|
||
- application artifact composition;
|
||
- Flyway startup vs external migration-job mode;
|
||
- readiness/liveness exposure;
|
||
- graceful shutdown orchestration.
|
||
|
||
business use case, repository mapping, SQL을 두지 않는다.
|
||
|
||
### 8.5 `sample-portfolio`
|
||
|
||
소유:
|
||
|
||
- WorkLog sample consumer;
|
||
- template 사용 예시와 fixture;
|
||
- sample-specific entity, query adapter, migration;
|
||
- sample integration test.
|
||
|
||
production leaf는 이 module에 의존하지 않는다. production readiness evidence가 sample에만
|
||
존재하면 JPA leaf R2 근거로 충분하지 않다.
|
||
|
||
## 9. Readiness와 guarantee 모델
|
||
|
||
### 9.1 readiness level
|
||
|
||
| Level | 의미 |
|
||
| --- | --- |
|
||
| R0 | interface, placeholder, unqualified seam만 있다. |
|
||
| R1 | deterministic unit/local integration evidence가 있다. |
|
||
| R2 | production profile의 real PostgreSQL, failure, concurrency, migration, observability evidence가 필수 CI에서 통과한다. |
|
||
| R3 | target topology에서 restore/failover/rolling migration/capacity rehearsal와 운영 SLO evidence가 있다. |
|
||
|
||
### 9.2 guarantee descriptor
|
||
|
||
각 deployment는 최소 다음 descriptor를 startup log와 diagnostics에 노출한다.
|
||
|
||
```text
|
||
provider postgresql
|
||
providerVersion 16.<qualified-minor>
|
||
ormVersion 7.1.8
|
||
primary enabled
|
||
replica disabled | enabled
|
||
readConsistencyProfiles STRONG[, BOUNDED_STALENESS, EVENTUAL]
|
||
migrationMode STARTUP | EXTERNAL_JOB
|
||
schemaCompatibility N_AND_N_MINUS_1
|
||
tenantMode NONE | DISCRIMINATOR | DISCRIMINATOR_RLS
|
||
transactionPolicies [COMMAND_DEFAULT, QUERY_PRIMARY, ...]
|
||
legacyWriteIdentity disabled | INVOCATION_UNCORRELATED
|
||
idempotency V1 | OWNER_SAFE_V2
|
||
outboxStorage V1_MUTABLE | IMMUTABLE_PARTITIONED_V2
|
||
outboxDispatchMode disabled | polling | cdc
|
||
outboxDispatchProfile disabled | POLLING_DELIVERY_V2 | CDC_RETENTION_V1
|
||
inbox disabled | SAME_STORE_V1
|
||
jdbcCoordination disabled | EFFICIENCY_ONLY
|
||
cardReadiness {cardId: R0 | R1 | R2 | R3}
|
||
evidenceManifestIds {cardId: immutableManifestId}
|
||
externalEvidenceIds {namespacedCardId: immutableManifestId}
|
||
migrationStreamRevisions {cardId: historyTable/revision/state}
|
||
durabilityProfile provider-qualified RPO/RTO descriptor
|
||
```
|
||
|
||
descriptor는 비밀, host, database/user 이름을 포함하지 않는다. configured 값이 아니라
|
||
startup validation과 probe가 성공한 effective capability를 나타낸다.
|
||
|
||
### 9.3 보장과 비보장
|
||
|
||
| 제공 가능한 보장 | 제공하지 않는 보장 |
|
||
| --- | --- |
|
||
| 한 primary DB transaction 안의 row/constraint atomicity | DB와 broker/object storage 사이 atomic commit |
|
||
| explicit version 또는 lock에 의한 lost-update 방지 | 모든 business conflict의 자동 해결 |
|
||
| policy-qualified primary read | replica의 무조건 최신 read |
|
||
| owner-safe same-store claim transition | cross-store exactly-once |
|
||
| migration checksum과 schema compatibility validation | arbitrary rollback migration의 무손실 |
|
||
| stable order key를 가진 cursor traversal | concurrent write 중 전체 dataset snapshot, 별도 transaction 없이는 보장 안 함 |
|
||
| explicit timeout의 bounded wait intent | network/driver/kernel을 포함한 완전한 hard deadline |
|
||
| commit-indeterminate typed outcome | 장애 중 commit 여부의 즉시 판정 |
|
||
|
||
## 10. Application 계약
|
||
|
||
### 10.1 aggregate repository port
|
||
|
||
repository port는 기술 CRUD가 아니라 aggregate use case에 필요한 의미를 표현한다.
|
||
|
||
```java
|
||
public interface WorkItemRepositoryPort {
|
||
Optional<WorkItem> findById(WorkItemId id);
|
||
void add(WorkItem aggregate);
|
||
SaveOutcome update(WorkItem aggregate, AggregateVersion expectedVersion);
|
||
}
|
||
```
|
||
|
||
규칙:
|
||
|
||
- `save(T)` 하나로 insert/update/upsert를 숨기지 않는다.
|
||
- not-found, version conflict, duplicate business key를 구분한다.
|
||
- persistence-generated ID에 business 흐름이 종속되지 않도록 ID는 transaction 전 생성한다.
|
||
- returned domain object에 lazy proxy가 남지 않는다.
|
||
- repository 호출 하나가 transaction을 자동 생성한다고 가정하지 않는다.
|
||
- aggregate 밖의 대량 조회나 reporting은 별도 query port로 분리한다.
|
||
|
||
### 10.2 purpose-built query port
|
||
|
||
```java
|
||
public interface WorkItemSummaryQueryPort {
|
||
WorkItemSlice findSummaries(
|
||
WorkItemQuery query,
|
||
WorkItemCursor cursor,
|
||
PageLimit limit);
|
||
}
|
||
```
|
||
|
||
application query에는 허용된 filter/sort를 typed value로 정의한다. inbound가 전달한 arbitrary
|
||
field name, direction, expression을 그대로 받지 않는다.
|
||
|
||
반환 타입은 다음을 포함할 수 있다.
|
||
|
||
- immutable application projection;
|
||
- `items`;
|
||
- opaque next cursor;
|
||
- `hasNext`;
|
||
- source consistency;
|
||
- optional snapshot/as-of marker.
|
||
|
||
Spring `Page`, `Slice`, `Sort`, `Pageable`은 adapter 내부에만 존재한다.
|
||
|
||
read consistency 선택의 SSOT는 query port 인자가 아니라 use case가 여는
|
||
`TransactionRequest.readConsistency`다. query port는 이미 고정된 transaction route에서
|
||
실행하며 별도의 consistency를 받아 route를 다시 선택하지 않는다. 결과 projection에는 실제
|
||
source consistency와 authority marker를 관측 정보로 담을 수 있지만, 이것은 입력 policy가
|
||
아니다.
|
||
|
||
### 10.3 transaction contract의 additive evolution
|
||
|
||
기존 호출자는 다음 facade를 계속 사용할 수 있다.
|
||
|
||
```java
|
||
<T> T inWrite(Supplier<T> action);
|
||
<T> T inRead(Supplier<T> action);
|
||
<T> T inNew(Supplier<T> action);
|
||
```
|
||
|
||
기존 interface에 새 abstract method를 바로 추가하면 모든 fake/provider의 source compatibility가
|
||
깨진다. 목표 contract는 additive sub-port로 named policy를 추가한다.
|
||
|
||
```java
|
||
public interface PolicyTransactionPort extends TransactionPort {
|
||
<T> TransactionResult<T> inTransaction(
|
||
TransactionRequest request,
|
||
Supplier<T> action);
|
||
}
|
||
|
||
public record TransactionRequest(
|
||
TransactionPolicyId policyId,
|
||
CallBudget callBudget,
|
||
Optional<ReadConsistency> readConsistency,
|
||
Optional<OperationId> operationId) {}
|
||
```
|
||
|
||
정확한 class shape는 구현 계획에서 다듬을 수 있지만 다음 의미는 바꾸지 않는다.
|
||
|
||
- caller가 임의 isolation/timeout/propagation 숫자를 전달하지 않는다.
|
||
- application이 allowlisted `TransactionPolicyId`를 선택한다.
|
||
- inbound DTO가 policy ID를 직접 고르지 않는다.
|
||
- `CallBudget`은 absolute monotonic deadline이며 wall-clock으로 serialize하지 않는다.
|
||
- command의 stable `OperationId`는 commit-indeterminate reconciliation에 사용한다.
|
||
- 기존 `inRead`는 `QUERY_PRIMARY + STRONG`이다.
|
||
- ID 인자가 없는 기존 `inWrite`/`inNew`는 각각 legacy non-replayable policy에만 연결한다.
|
||
새 `COMMAND_DEFAULT`/`MAINTENANCE_NEW`의 operation identity를 임의 UUID로 가장하지 않는다.
|
||
|
||
`TransactionResult<T>`는 nullable value/exception 조합이 아니라 다음 sealed algebra와 동등해야
|
||
한다.
|
||
|
||
```text
|
||
COMMITTED(value, optional operationId)
|
||
PARTICIPATING_PENDING_OUTER(value)
|
||
DETERMINATE_ROLLBACK(failure)
|
||
INDETERMINATE(optional operationId, lastObservedPhase, optional reconciliationReference)
|
||
COMMITTED_WITH_POST_COMMIT_FAILURE(value, optional operationId, operationalFailure)
|
||
```
|
||
|
||
- final outcome은 root physical transaction owner만 만든다.
|
||
- participant는 value를 반환할 수 있지만 outer 종료 전 commit 성공을 주장하지 않는다.
|
||
- `INDETERMINATE`에는 자동 replay 권한이 없다. `PolicyTransactionPort` write policy에서는
|
||
stable `OperationId`와 reconciliation reference가 필수이고 legacy facade에서만 둘이 없을 수
|
||
있다.
|
||
- `COMMITTED_WITH_POST_COMMIT_FAILURE`는 rollback failure가 아니며 value가 이미 commit된
|
||
결과다. semantic side effect를 `afterCommit`에 두지 않는다는 architecture rule을 전제로
|
||
replay하지 않는다.
|
||
- `PolicyTransactionPort`의 command/infrastructure write policy는 route/admission 전에
|
||
`operationId` 존재를 검증한다.
|
||
`OUTBOX_APPEND` participant는 outer operation identity를 상속한다. replay-safe read만
|
||
operation ID를 생략할 수 있다.
|
||
|
||
Spring adapter 한 instance가 `TransactionPort`와 `PolicyTransactionPort`를 함께 구현하고,
|
||
기존 read method는 canonical read policy로, 기존 write/new method는 아래 legacy-only
|
||
policy로 위임한다. 기존 fake/caller는 그대로 compile되며
|
||
named policy가 필요한 use case만 새 sub-port로 순차 이동한다. 모든 provider/fake와
|
||
architecture rule이 전환되기 전 기존 interface에 abstract method를 추가하거나 legacy method를
|
||
삭제하지 않는다.
|
||
|
||
legacy root write/new는 invocation 간 stable operation identity가 없으므로 transaction replay를
|
||
하지 않는다. commit outcome이 indeterminate면 replay-disabled
|
||
`LegacyTransactionOutcomeIndeterminateException`과 sanitized correlation/phase만 반환하고,
|
||
operator가 allowlisted business key 또는 DB fact로 수동 reconcile한다. 별도 호출의 중복 방지를
|
||
주장하지 않는다. outer policy transaction에 참여하면 outer operation identity를 상속한다.
|
||
호출자는 순차적으로 `PolicyTransactionPort.inTransaction(...)`으로 이동해 use-case/message/job
|
||
identity를 명시한다. 현재 `PublishPendingOutboxEventsUseCase` 같은 `inNew` 호출도 이 migration
|
||
대상이며, adapter가 생성한 random ID를 business intent와 안정적으로 연결된 ID로 취급하지
|
||
않는다.
|
||
|
||
`@UseCaseCapability.transactionMode`는 유스케이스의 정적 transaction shape에 대한 canonical
|
||
선언이고, `TransactionPolicyId`는 그 mode 안의 runtime refinement다. 둘의 허용 관계를
|
||
application registry와 ArchUnit이 함께 검증한다.
|
||
|
||
| `TransactionMode` | 허용 policy family |
|
||
| --- | --- |
|
||
| `WRITE` | REQUIRED, primary, read-write command policy |
|
||
| `READ_ONLY` | REQUIRED, read-only query policy |
|
||
| `REQUIRES_NEW` | allowlisted REQUIRES_NEW infrastructure policy |
|
||
|
||
예를 들어 `READ_ONLY` use case가 `COMMAND_DEFAULT`를 선택하거나 `WRITE` use case가
|
||
`QUERY_REPLICA_ELIGIBLE`을 선택하면 startup/architecture test가 실패한다. generic
|
||
`inTransaction(...)` 도입 시 기존 “`inWrite`/`inRead`/`inNew` 직접 호출” ArchUnit 규칙을
|
||
policy-family coherence 규칙으로 함께 교체하며, 어느 한쪽만 바꿔 enforcement 공백을 만들지
|
||
않는다.
|
||
|
||
`@UseCaseCapability.externalOutboundAllowed=true`는 use case가 remote port를 사용할 수 있다는
|
||
선언이지 DB transaction callback 안 remote I/O 허가가 아니다. remote call은 transaction
|
||
전후의 명시적 phase 또는 outbox/workflow로 분리한다. transaction callback이 external
|
||
provider port를 직접 호출하는 call graph는 architecture test로 거절하고, 간접 호출은 code
|
||
review와 fault test가 보강한다.
|
||
|
||
### 10.4 named policy
|
||
|
||
초기 canonical policy:
|
||
|
||
| Policy | Propagation | Isolation | Read-only | Route | 허용 `ReadConsistency` | Operation ID | Retry |
|
||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||
| `COMMAND_DEFAULT` | REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | required | none |
|
||
| `COMMAND_SERIALIZABLE_REPLAY_SAFE` | REQUIRED | SERIALIZABLE | false | PRIMARY | absent only | required | bounded whole transaction |
|
||
| `QUERY_PRIMARY` | REQUIRED | READ_COMMITTED | true | PRIMARY | `STRONG`, `READ_YOUR_WRITES` | optional | none |
|
||
| `QUERY_REPLICA_ELIGIBLE` | REQUIRED | READ_COMMITTED | true | qualified replica | `EVENTUAL`, `BOUNDED_STALENESS` | optional | query-only bounded |
|
||
| `OUTBOX_APPEND` | join caller | caller | false | PRIMARY | absent only | inherit outer | none |
|
||
| `INBOX_AND_HANDLER` | REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | required message operation | explicit message policy |
|
||
| `MAINTENANCE_NEW` | REQUIRES_NEW | READ_COMMITTED | false | PRIMARY | absent only | required batch/job operation | bounded batch only |
|
||
| `COMMAND_LEGACY_NON_REPLAYABLE` | REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | unavailable unless inherited | none |
|
||
| `MAINTENANCE_LEGACY_NON_REPLAYABLE` | REQUIRES_NEW | READ_COMMITTED | false | PRIMARY | absent only | unavailable unless inherited | none |
|
||
|
||
read policy는 consistency가 반드시 있어야 하고 command/infrastructure policy에는 없어야 한다.
|
||
policy-consistency 조합은 route/admission 전에 resolve해 허용 표 밖이면 fail-fast한다. nested
|
||
`REQUIRED`는 이미 resolved된 outer context와 호환되는 요청만 참여하고 route/consistency를
|
||
재선택하지 않는다.
|
||
|
||
두 legacy policy ID는 `PolicyTransactionPort.TransactionRequest`가 선택할 수 없는
|
||
adapter-internal compatibility entry다. descriptor는 legacy 호출 count와
|
||
`INVOCATION_UNCORRELATED` commit-uncertainty risk를 노출하고 호출 count가 0이 된 뒤 제거한다.
|
||
|
||
`QUERY_REPLICA_ELIGIBLE`은 replica profile이 R2가 아니면 startup에 등록하지 않는다. 등록되지
|
||
않은 policy를 primary/default에 조용히 매핑하지 않고 configuration failure로 처리한다.
|
||
|
||
## 11. Entity와 mapping baseline
|
||
|
||
### 11.1 domain과 persistence entity 분리
|
||
|
||
```text
|
||
domain aggregate
|
||
<-> explicit mapper
|
||
<-> JPA persistence entity
|
||
```
|
||
|
||
mapper 책임:
|
||
|
||
- ID/value object와 column representation 변환;
|
||
- nullable/optional representation 변환;
|
||
- persistence child collection과 domain collection 변환;
|
||
- storage enum/version compatibility 변환.
|
||
|
||
mapper 금지:
|
||
|
||
- status transition;
|
||
- authorization;
|
||
- price/limit/eligibility 계산;
|
||
- default business policy;
|
||
- remote lookup;
|
||
- repository 호출;
|
||
- transaction 시작.
|
||
|
||
invariant가 깨진 row를 읽으면 조용히 보정하지 않고 typed corruption/incompatible-schema
|
||
failure로 격리한다.
|
||
|
||
### 11.2 ID
|
||
|
||
- application/domain에서 UUID를 먼저 생성한다.
|
||
- 외부 노출 ID와 내부 surrogate key를 분리할 필요가 있으면 명시적으로 둘 다 모델링한다.
|
||
- PostgreSQL UUID column을 기본으로 하고 string UUID 저장은 migration 호환 사유가 있을 때만
|
||
사용한다.
|
||
- database sequence가 필요한 high-throughput batch aggregate는 별도 benchmark/evidence 후
|
||
선택한다.
|
||
- ID generator 변경은 rolling compatibility migration으로 다룬다.
|
||
|
||
### 11.3 time
|
||
|
||
- business/audit instant는 Java `Instant`, PostgreSQL `timestamptz`를 기본으로 한다.
|
||
- JVM, JDBC, database session timezone은 UTC로 검증한다.
|
||
- local business date/time은 의미가 있을 때 `LocalDate`/`LocalTime`과 timezone ID를
|
||
별도로 저장한다.
|
||
- ordering에 timestamp 하나만 사용하지 않는다. 동일 timestamp tie-breaker로 stable ID 또는
|
||
sequence를 포함한다.
|
||
- database time과 application time 중 correctness authority를 operation별로 하나만 선택한다.
|
||
|
||
lease/claim/expiry는 database transaction 안에서 비교할 때 PostgreSQL clock을 사용한다.
|
||
domain event occurred time은 application clock port를 사용할 수 있다.
|
||
|
||
### 11.4 enum
|
||
|
||
- JPA ordinal enum은 금지한다.
|
||
- string code를 저장하고 rolling deploy에서 old/new version이 모두 이해하는 additive 순서를
|
||
따른다.
|
||
- DB check constraint를 쓰면 새 value 허용을 old application switch보다 먼저 배포한다.
|
||
- unknown future value를 무조건 기존 enum으로 강제 변환하지 않는다. read compatibility
|
||
strategy가 없으면 schema incompatibility로 fail한다.
|
||
|
||
### 11.5 relation과 cascade
|
||
|
||
- relation은 기본 LAZY다.
|
||
- `EAGER`를 N+1 해결책으로 사용하지 않는다.
|
||
- aggregate boundary 안의 owned child에만 cascade/orphan removal을 사용한다.
|
||
- `CascadeType.ALL`을 기본값으로 두지 않는다.
|
||
- aggregate 간 relation은 ID reference를 우선하며 하나의 거대한 object graph를 만들지 않는다.
|
||
- collection은 deterministic order가 필요하면 order column 또는 explicit key를 정의한다.
|
||
|
||
### 11.6 optimistic version
|
||
|
||
- mutable aggregate root에는 version을 둔다.
|
||
- application의 expected version과 persistence `@Version`을 일관되게 매핑한다.
|
||
- version conflict는 generic internal error가 아니라 typed concurrent modification이다.
|
||
- conflict 후 자동 merge는 domain policy가 명시한 경우에만 한다.
|
||
- bulk update/delete는 JPA version과 persistence context를 우회하므로 일반 aggregate
|
||
command에 사용하지 않는다.
|
||
|
||
### 11.7 audit
|
||
|
||
- audit actor/request context는 adapter의 `AuditContextPort`에서 받는다.
|
||
- create/update stamp는 persistence entity mapping의 기술 정보다.
|
||
- business event의 actor/reason은 domain/application command에 별도로 남긴다.
|
||
- bulk/native DML은 audit/version을 자동 적용하지 않으므로 별도 명시 SQL과 test가 필요하다.
|
||
- update 시 기존 creation audit을 보존하기 위해 불필요한 추가 read를 강제하지 않도록
|
||
persistence context와 mapping strategy를 설계한다.
|
||
- actor가 없을 때 `system` fallback을 허용하는 operation 목록을 명시한다.
|
||
|
||
### 11.8 column baseline
|
||
|
||
- 금액은 scale/precision이 명시된 decimal 또는 smallest-unit integer다.
|
||
- JSON은 schema/version/size/query requirement가 있을 때만 사용한다.
|
||
- large binary는 DB가 correctness/transaction boundary여야 하는 작은 payload에만 사용하고,
|
||
일반 object는 object-storage reference를 사용한다.
|
||
- nullable column은 migration compatibility와 domain optionality를 구분한다.
|
||
- natural/business key에는 명시적 unique constraint name을 부여한다.
|
||
- 모든 FK/index/constraint 이름은 deterministic naming convention을 사용한다.
|
||
|
||
## 12. Transaction semantics
|
||
|
||
### 12.1 transaction boundary
|
||
|
||
권장 command shape:
|
||
|
||
```text
|
||
use case
|
||
validate pure input
|
||
derive stable operationId from the application command identity
|
||
-> transactionPort.inTransaction(COMMAND_DEFAULT, budget, operationId) {
|
||
load aggregate
|
||
apply domain transition
|
||
persist aggregate
|
||
append outbox
|
||
}
|
||
-> return application result
|
||
```
|
||
|
||
금지 shape:
|
||
|
||
```text
|
||
controller @Transactional
|
||
repository adapter @Transactional
|
||
mapper starts transaction
|
||
transaction {
|
||
write DB
|
||
call broker/HTTP/object storage
|
||
}
|
||
```
|
||
|
||
Spring scheduler가 maintenance trigger를 소유할 수는 있으나 business policy와 transaction
|
||
selection은 application command를 호출해야 한다. 순수 infrastructure reaper도 명시적인
|
||
maintenance policy와 bounded batch를 가져야 한다.
|
||
|
||
### 12.2 propagation
|
||
|
||
기본은 `REQUIRED`다.
|
||
|
||
- command 내 여러 repository 호출은 하나의 physical transaction에 참여한다.
|
||
- nested `REQUIRED`가 rollback-only가 되면 outer caller에게 명확히 실패한다.
|
||
- `NESTED` savepoint는 R2 baseline에서 제공하지 않는다.
|
||
- `NOT_SUPPORTED`, `NEVER`, `MANDATORY`를 application-facing generic option으로 노출하지
|
||
않는다.
|
||
- `REQUIRES_NEW`는 outbox/audit/compensation이라는 이름만으로 자동 허용하지 않는다.
|
||
caller transaction과 독립 commit이 실제 invariant인지 검토한다.
|
||
|
||
`REQUIRES_NEW` pool capacity 하한:
|
||
|
||
```text
|
||
required connections
|
||
>= max concurrent outer transactions
|
||
+ max concurrent REQUIRES_NEW transactions
|
||
+ maintenance/migration/health reserve
|
||
```
|
||
|
||
한 outer transaction이 동시에 하나의 inner transaction만 열어도 각 active outer connection이
|
||
반납되지 않는다. record별 `REQUIRES_NEW` loop는 금지하고 bounded batch transaction을 사용한다.
|
||
|
||
R2 baseline은 `REQUIRES_NEW` 최대 중첩을 1로 제한하고 outer lane과 분리된 inner
|
||
connection/permit reserve를 둔다. outer가 primary connection과 permit을 보유한 채 일반
|
||
command lane의 permit을 다시 기다리는 순환은 금지한다. acquire 순서는
|
||
`outer permit -> outer connection -> inner-reserve permit -> inner connection`으로 고정하며,
|
||
inner reserve가 없으면 outer transaction을 시작하기 전에 해당 policy를 거절한다. capacity
|
||
test는 모든 outer가 동시에 inner를 요구하는 barrier scenario에서도 유한 시간 안에 진행하거나
|
||
명시적으로 admission reject하는지 검증한다.
|
||
|
||
### 12.3 read-only
|
||
|
||
read-only는 다음을 의미한다.
|
||
|
||
- transaction intent와 ORM flush optimization;
|
||
- primary/replica route eligibility의 한 입력;
|
||
- PostgreSQL read-only transaction 설정 검증.
|
||
|
||
다음을 의미하지 않는다.
|
||
|
||
- replica 자동 사용;
|
||
- stale read 허용;
|
||
- database가 모든 accidental write를 항상 막는다는 무조건 보장;
|
||
- transaction 없이 lazy load 허용.
|
||
|
||
### 12.4 flush
|
||
|
||
- normal command는 commit 직전 flush에 의존할 수 있다.
|
||
- constraint/version failure를 특정 application step에서 분류해야 하면 그 step 뒤에 explicit
|
||
flush한다.
|
||
- explicit flush는 commit 성공을 의미하지 않는다.
|
||
- bulk loop는 batch마다 flush/clear하고 detached entity를 domain result로 반환하지 않는다.
|
||
- query-before-commit의 implicit flush 비용을 query design에 포함한다.
|
||
|
||
### 12.5 checked exception과 rollback
|
||
|
||
application callback은 현재 `Supplier`/`Runnable` 기반 RuntimeException contract를 유지한다.
|
||
checked failure가 필요한 port는 application typed RuntimeException carrier로 감싸며 원인을
|
||
보존한다. 임의 `catch (Exception)` 후 성공 결과를 반환하지 않는다.
|
||
|
||
rollback failure가 원래 action failure를 대체할 수 있으므로 transaction outcome에는 primary
|
||
failure와 cleanup/rollback failure를 함께 보존한다. client에는 하나의 안전한 error code만
|
||
노출한다.
|
||
|
||
## 13. Isolation과 concurrency
|
||
|
||
### 13.1 기본 isolation
|
||
|
||
PostgreSQL의 `READ_COMMITTED`를 일반 command/query 기본값으로 유지한다. 각 statement는
|
||
statement 시작 시점의 snapshot을 볼 수 있으므로 한 transaction 안의 두 query가 다른
|
||
committed state를 볼 수 있음을 문서화한다.
|
||
|
||
`READ_COMMITTED`로 충분한 경우:
|
||
|
||
- primary key로 aggregate를 읽고 `@Version`으로 update conflict를 검출;
|
||
- unique/check/FK constraint가 correctness를 최종 보장;
|
||
- queue claim이 single statement 또는 lock-protected transition;
|
||
- read-only projection이 repeatable snapshot을 요구하지 않음.
|
||
|
||
### 13.2 REPEATABLE_READ
|
||
|
||
다음 경우 named policy로만 사용한다.
|
||
|
||
- 한 transaction 내 여러 query가 동일 snapshot을 봐야 하는 export/snapshot 계산;
|
||
- write skew가 DB constraint/optimistic version으로 방지되는지 별도 검토된 경우.
|
||
|
||
long-running snapshot은 vacuum과 replica replay를 방해할 수 있으므로 row/time budget,
|
||
statement timeout, 운영 관측을 필수로 한다.
|
||
|
||
### 13.3 SERIALIZABLE
|
||
|
||
다음 조건을 모두 만족할 때 사용한다.
|
||
|
||
- business invariant를 constraint나 single-row version만으로 표현하기 어렵다.
|
||
- transaction callback 전체가 replay-safe다.
|
||
- serialization failure 시 transaction 전체를 처음부터 재실행한다.
|
||
- max attempts, jitter, absolute budget이 있다.
|
||
- 외부 side effect가 callback 안에 없다.
|
||
|
||
serialization failure 하나의 statement만 재시도하지 않는다. 이전 read에 의존한 모든 판단을
|
||
다시 수행한다.
|
||
|
||
### 13.4 database constraint가 최종 correctness authority
|
||
|
||
“먼저 조회한 뒤 없으면 insert”만으로 uniqueness를 보장하지 않는다. 다음을 사용한다.
|
||
|
||
- named unique constraint;
|
||
- check constraint;
|
||
- FK;
|
||
- exclusion constraint가 실제 interval conflict에 필요하면 PostgreSQL-specific migration;
|
||
- atomic conditional `UPDATE ... WHERE ...`;
|
||
- version predicate.
|
||
|
||
application pre-check는 친절한 메시지나 빠른 거절을 위한 optimization일 뿐 race correctness가
|
||
아니다.
|
||
|
||
### 13.5 conflict 결과
|
||
|
||
| 원인 | application 의미 | 기본 retry |
|
||
| --- | --- | --- |
|
||
| optimistic version mismatch | concurrent modification | 없음; caller/domain policy |
|
||
| allowlisted business unique constraint | duplicate/conflict | 없음 |
|
||
| serialization `40001` | replay-safe transaction conflict | bounded whole transaction |
|
||
| deadlock `40P01` | lock ordering/runtime conflict | replay-safe일 때만 bounded |
|
||
| lock timeout `55P03` | contention timeout | 기본 없음 |
|
||
| statement cancel/timeout `57014` | deadline/resource | budget이 남고 query-only일 때만 |
|
||
|
||
## 14. Locking
|
||
|
||
### 14.1 optimistic locking이 기본
|
||
|
||
일반 aggregate update:
|
||
|
||
```text
|
||
read aggregate + version
|
||
apply domain transition
|
||
UPDATE ... WHERE id = ? AND version = ?
|
||
affected rows == 1 -> success
|
||
affected rows == 0 -> conflict/not-found distinction
|
||
```
|
||
|
||
장점:
|
||
|
||
- connection을 보유한 대기 시간이 짧다.
|
||
- application이 conflict 의미를 결정할 수 있다.
|
||
- cluster node 수와 무관하게 DB row version이 authority다.
|
||
|
||
### 14.2 pessimistic lock 허용 조건
|
||
|
||
다음을 모두 만족해야 한다.
|
||
|
||
- lock target을 index로 빠르게 찾는다.
|
||
- transaction이 짧고 remote I/O가 없다.
|
||
- deterministic lock order가 있다.
|
||
- `lock_timeout`이 finite다.
|
||
- max rows가 bounded다.
|
||
- timeout/conflict가 typed outcome이다.
|
||
- real PostgreSQL concurrency test가 있다.
|
||
|
||
`PESSIMISTIC_WRITE` 또는 `SELECT ... FOR UPDATE`는 해당 query method에 명시한다. repository
|
||
전체에 broad default를 적용하지 않는다.
|
||
|
||
### 14.3 lock ordering
|
||
|
||
여러 row/aggregate를 잠글 때 stable key ascending 같은 단일 order를 정의한다. 서로 다른
|
||
feature가 같은 table을 잠그면 공유 lock-order 문서와 test를 갖는다.
|
||
|
||
deadlock은 완전히 제거할 수 있다고 주장하지 않는다. `40P01`을 관측하고 replay-safe
|
||
transaction에만 bounded retry한다.
|
||
|
||
### 14.4 `SKIP LOCKED`
|
||
|
||
`SKIP LOCKED`는 queue-like work claim에만 사용한다.
|
||
|
||
- 일반 사용자 조회에 사용하지 않는다.
|
||
- 결과가 일관된 snapshot이나 모든 row를 포함한다고 주장하지 않는다.
|
||
- deterministic eligibility/order와 batch limit가 필요하다.
|
||
- claim 후 owner token/lease가 별도 row state에 기록되어야 한다.
|
||
- starvation, DEAD head, reaper 정책을 운영 지표로 관측한다.
|
||
|
||
### 14.5 advisory lock
|
||
|
||
PostgreSQL advisory lock은 R2 baseline에서 사용하지 않는다. 도입 시:
|
||
|
||
- session vs transaction scope;
|
||
- key collision;
|
||
- connection pool 반환;
|
||
- failover;
|
||
- fencing 부재;
|
||
- observability
|
||
|
||
를 별도 설계한다. schema migration serialization은 Flyway의 지원 계약을 우선한다.
|
||
|
||
### 14.6 JDBC distributed lock의 한계
|
||
|
||
현행 Spring Integration JDBC lock은 다음 용도만 허용한다.
|
||
|
||
- duplicate scheduler work를 줄이는 efficiency coordination;
|
||
- 재실행 가능한 maintenance batch;
|
||
- correctness가 DB constraint/CAS로 별도 보호되는 작업.
|
||
|
||
다음을 보장하지 않는다.
|
||
|
||
- stale worker write 차단;
|
||
- fencing token;
|
||
- exactly-once;
|
||
- remote resource ownership;
|
||
- lease renewal 중 network partition safety.
|
||
|
||
release는 owner-safe하고 idempotent한 결과로 진화해야 하며 interruption과 timeout을 구분한다.
|
||
correctness가 필요하면 resource write가 fencing token을 검증하는 별도 contract를 사용한다.
|
||
|
||
## 15. Failure, retry와 commit outcome
|
||
|
||
### 15.1 하나의 translation boundary
|
||
|
||
모든 persistence operation은 다음 boundary를 통과한다.
|
||
|
||
```text
|
||
framework exception
|
||
+ SQLException chain / SQLState
|
||
+ constraint name
|
||
+ transaction phase
|
||
+ operation kind
|
||
-> PersistenceFailure
|
||
-> application-safe error + retry disposition + reconciliation requirement
|
||
```
|
||
|
||
권장 internal shape:
|
||
|
||
```java
|
||
record PersistenceFailure(
|
||
PersistenceFailureCode code,
|
||
RetryDisposition retry,
|
||
TransactionOutcome outcome,
|
||
String operationId,
|
||
Throwable cause) {}
|
||
```
|
||
|
||
application-facing contract가 이 exact record를 가져야 한다는 뜻은 아니다. 중요한 것은
|
||
분류 정보가 generic `INTERNAL_ERROR` 하나로 소실되지 않는 것이다.
|
||
|
||
### 15.2 transaction phase
|
||
|
||
transaction manager 주변 collaborator는 최소 다음 상태를 기록한다.
|
||
|
||
```text
|
||
ROUTE_ADMISSION
|
||
-> CONNECTION_ACQUIRED
|
||
-> ACTIVE
|
||
-> FLUSHED
|
||
-> COMMIT_REQUESTED
|
||
-> COMMIT_ACKED
|
||
-> SYNCHRONIZATION_CLEANUP
|
||
```
|
||
|
||
| Phase | failure 의미 | DB effect |
|
||
| --- | --- | --- |
|
||
| route/admission 전 | 실행 안 됨 | 없음 |
|
||
| connection acquire/begin | transaction 시작 실패 | 없음으로 판정 가능해야 함 |
|
||
| active action/flush | statement, mapping, constraint 실패 | rollback 확인 시 determinate rollback |
|
||
| `COMMIT_REQUESTED`, ACK 없음 | commit 요청/응답 중 연결 유실 | `COMMIT_INDETERMINATE` 가능 |
|
||
| `COMMIT_ACKED` 뒤 synchronization/cleanup | DB commit은 확인됐으나 후처리 실패 | committed + post-commit failure, replay 금지 |
|
||
| rollback | cleanup 실패 | 원래 failure와 함께 운영 escalation |
|
||
|
||
classification precedence는 단순히 “commit method가 예외를 던졌다”가 아니다.
|
||
|
||
1. `40001`, `40P01`, rollback-only 또는 `UnexpectedRollbackException`이고 resource rollback이
|
||
확인되면 determinate rollback이다.
|
||
2. commit 요청 뒤 `08007`, connection loss, socket timeout이 발생했고 ACK를 확인하지 못하면
|
||
indeterminate다.
|
||
3. JDBC commit ACK 뒤 transaction synchronization 또는 resource cleanup이 실패하면
|
||
committed post-commit failure다. callback을 재실행하지 않는다.
|
||
4. phase를 관측하지 못하면 더 안전한 unknown/indeterminate로 강등한다.
|
||
|
||
`08*` connection class를 어느 phase에서든 같은 `DB_UNAVAILABLE`로만 반환하면 commit
|
||
uncertainty를 잃는다. 각 상태의 resource-level fault injection seam이 있어야 하며,
|
||
framework exception class보다 실제 phase/outcome 증거가 우선한다.
|
||
|
||
#### 선택한 Spring 관측 지점
|
||
|
||
JPA leaf의 정본 구현 방향은 `PhaseAwareTransactionExecutor`가
|
||
`PlatformTransactionManager` decorator와 가장 먼저 실행되는 ordered
|
||
`TransactionSynchronization` sentinel을 함께 사용하는 것이다.
|
||
|
||
- decorator는 delegate `getTransaction`, `commit`, `rollback` 호출 전후의 phase를 기록한다.
|
||
- `TransactionStatus.isNewTransaction()`이 true인 physical owner만 final
|
||
`COMMITTED/ROLLED_BACK/UNKNOWN`을 판정한다.
|
||
- 기존 outer transaction에 참여한 `REQUIRED` boundary는 정상 반환을
|
||
`PARTICIPATING_PENDING_OUTER`로 기록하며 committed outcome을 노출하지 않는다. 최종 결과는
|
||
outer physical owner가 결정한다.
|
||
- phase tracker와 sentinel은 logical method call마다가 아니라 physical transaction identity별
|
||
하나다. `REQUIRES_NEW`는 outer tracker를 suspend하고 독립 tracker/outcome을 만든 뒤 outer를
|
||
resume한다.
|
||
- sentinel `afterCommit` 진입은 physical commit 뒤의 `COMMIT_ACKED` 관측으로 사용한다.
|
||
- `afterCompletion(STATUS_COMMITTED)`는 `COMMITTED`,
|
||
`STATUS_ROLLED_BACK`은 `ROLLED_BACK`, `STATUS_UNKNOWN`은 `UNKNOWN`이다.
|
||
- physical owner의 delegate `commit()`이 정상 반환하면 `COMMITTED`다. participant의
|
||
`commit()` 정상 반환은 physical commit 증거가 아니다.
|
||
- delegate `commit()`이 예외를 던져도 sentinel이 commit ACK/committed를 이미 관측했다면
|
||
`COMMITTED_WITH_POST_COMMIT_FAILURE`다.
|
||
- rollback-only/flush failure 뒤 `STATUS_ROLLED_BACK`이면 determinate rollback이다.
|
||
- commit 요청 뒤 ACK와 completion status가 모두 없으면 `UNKNOWN`이며
|
||
`DB_COMMIT_INDETERMINATE`로만 번역한다.
|
||
|
||
sentinel은 business callback을 실행하지 않고 상태만 기록한다. user-defined
|
||
`afterCommit`/`afterCompletion` callback의 실패가 이 관측을 가리지 않도록 ordering을
|
||
고정한다. Spring/JPA 버전 변경 때 이 ordering과 callback lifecycle을 integration test로
|
||
재검증한다. 이 seam을 우회해 raw `TransactionTemplate`을 production에 별도 생성하지 않는다.
|
||
integration test는 inner `REQUIRED` 정상 반환 뒤 outer rollback, inner 정상 반환 뒤 outer
|
||
commit ACK 유실, `REQUIRES_NEW`의 독립 physical outcome을 구분한다.
|
||
|
||
legacy `inWrite/inRead/inNew` facade는 기존처럼 value를 반환하며 참여 boundary에서 final commit
|
||
성공을 새로 노출하지 않는다. 결과 매핑은 다음으로 고정한다.
|
||
|
||
| Physical result | `PolicyTransactionPort` | legacy facade |
|
||
| --- | --- | --- |
|
||
| `COMMITTED` | committed result + value | value 반환 |
|
||
| `PARTICIPATING_PENDING_OUTER` | pending result + value | value 반환, commit 보장 없음 |
|
||
| `DETERMINATE_ROLLBACK` | typed rollback result | 기존 translated persistence exception |
|
||
| `INDETERMINATE` | operation/reconciliation을 포함한 indeterminate result | replay-disabled typed exception; legacy root면 operation reference 없음 |
|
||
| `COMMITTED_WITH_POST_COMMIT_FAILURE` | committed value와 operational failure를 함께 반환 | value 반환 + mandatory incident metric/trace/readiness degradation |
|
||
|
||
legacy에서 post-commit failure를 rollback/retryable exception처럼 던지지 않는다. 실패한
|
||
callback은 observation/resource cleanup만 허용하며, semantic callback은 startup architecture
|
||
검증에서 거절하고 outbox/workflow로 옮긴다. cleanup 실패 connection은 폐기하고 incident를
|
||
운영자가 확인할 때까지 readiness policy에 반영한다. `UNKNOWN` completion은 commit 요청 이후면
|
||
`INDETERMINATE(DB_COMMIT_INDETERMINATE)`, commit 요청 전 rollback/resource discard도 확인하지
|
||
못했으면 `INDETERMINATE(DB_TRANSACTION_OUTCOME_UNKNOWN)`으로 정규화한다. 둘 다 operation
|
||
ledger/reconciliation 전에는 facade callback을 재실행하지 않는다.
|
||
|
||
### 15.3 internal failure code와 public error compatibility
|
||
|
||
다음은 adapter/application 내부 `PersistenceFailureCode`의 최소 분류다. 곧바로
|
||
`shared-contract.OperationalError`의 public 이름을 교체한다는 뜻이 아니다.
|
||
|
||
| Code | 대표 근거 | 의미 |
|
||
| --- | --- | --- |
|
||
| `DB_UNAVAILABLE` | acquire/begin의 `08*`, resource failure | 실행 전 또는 확실한 rollback 후 unavailable |
|
||
| `DB_COMMIT_INDETERMINATE` | commit 중 `08007` 또는 connection loss | reconcile before retry |
|
||
| `DB_TRANSACTION_OUTCOME_UNKNOWN` | commit 전 rollback/resource discard도 확인 불가 | reconcile/fatal cleanup, no blind retry |
|
||
| `DB_POST_COMMIT_FAILURE` | commit ACK 뒤 synchronization/cleanup failure | committed, replay 금지, 운영 복구 |
|
||
| `DB_SERIALIZATION_FAILURE` | `40001` | whole transaction replay 후보 |
|
||
| `DB_DEADLOCK` | `40P01` | replay-safe일 때 후보 |
|
||
| `DB_CONSTRAINT_VIOLATION` | `23*` | allowlist로 세분화하지 못한 constraint |
|
||
| `DB_UNIQUE_VIOLATION` | `23505` | allowlist가 application semantic conflict로 번역할 수 있음 |
|
||
| `DB_FK_VIOLATION` | `23503` | referenced state conflict |
|
||
| `DB_NULL_VIOLATION` | `23502` | schema/data contract defect 또는 invalid input |
|
||
| `DB_CHECK_VIOLATION` | `23514` | invariant/schema conflict |
|
||
| `DB_READ_ONLY` | `25006` | route/role/config drift |
|
||
| `DB_IDLE_TRANSACTION_TIMEOUT` | `25P03` | idle transaction operational guard |
|
||
| `DB_LOCK_TIMEOUT` | `55P03` | bounded contention |
|
||
| `DB_QUERY_TIMEOUT` | `57014` + server timeout marker | statement timeout |
|
||
| `DB_QUERY_CANCELLED` | `57014` + caller cancel marker | explicit cancellation |
|
||
| `DB_STATEMENT_INDETERMINATE` | `40003` | statement completion unknown, reconcile/no blind retry |
|
||
| `DB_RESOURCE_EXHAUSTED` | `53*` | PostgreSQL resource capacity |
|
||
| `ADMISSION_REJECTED` | local admission | DB operation 시작 전 lane 거절 |
|
||
| `POOL_ACQUISITION_TIMEOUT` | Hikari wait | pool wait timeout, statement 미실행 |
|
||
| `DB_CONNECT_TIMEOUT` | login/connect bootstrap | physical connection 생성 실패 |
|
||
| `DB_OPTIMISTIC_CONFLICT` | ORM optimistic exception | concurrent aggregate update |
|
||
| `DB_PESSIMISTIC_CONFLICT` | ORM lock exception | lock acquisition failure |
|
||
| `DB_SCHEMA_INCOMPATIBLE` | missing relation/column/type | deploy/migration mismatch |
|
||
| `DB_UNKNOWN` | unmapped | retry false, secure diagnostic |
|
||
|
||
SQLState exact mapping이 중복되면 startup을 실패시킨다. broad class mapping보다 exact mapping이
|
||
우선하되, priority를 암묵적인 bean order로 정하지 않는다.
|
||
|
||
`57014` 하나만 보고 timeout과 caller cancellation을 추정하지 않는다. executor가 설치한
|
||
server timeout과 explicit cancellation token/statement cancel 관측을 함께 사용하며, 구분할
|
||
증거가 없으면 더 좁은 자동 retry 권한을 부여하지 않는다.
|
||
|
||
현재 public registry의 다음 아홉 이름은 동결한다.
|
||
|
||
```text
|
||
DB_UNAVAILABLE
|
||
DB_SERIALIZATION_FAILURE
|
||
DB_DEADLOCK
|
||
DB_NULL_VIOLATION
|
||
DB_FK_VIOLATION
|
||
DB_UNIQUE_VIOLATION
|
||
DB_CHECK_VIOLATION
|
||
DB_IDLE_IN_TX_TIMEOUT
|
||
DB_QUERY_CANCELED
|
||
```
|
||
|
||
internal/public 호환 mapping은 명시적 registry로 관리한다.
|
||
|
||
| Internal | Existing public |
|
||
| --- | --- |
|
||
| `DB_UNAVAILABLE` | `DB_UNAVAILABLE` |
|
||
| `DB_SERIALIZATION_FAILURE` | `DB_SERIALIZATION_FAILURE` |
|
||
| `DB_DEADLOCK` | `DB_DEADLOCK` |
|
||
| `DB_NULL_VIOLATION` | `DB_NULL_VIOLATION` |
|
||
| `DB_FK_VIOLATION` | `DB_FK_VIOLATION` |
|
||
| `DB_UNIQUE_VIOLATION` | `DB_UNIQUE_VIOLATION` |
|
||
| `DB_CHECK_VIOLATION` | `DB_CHECK_VIOLATION` |
|
||
| `DB_IDLE_TRANSACTION_TIMEOUT` | `DB_IDLE_IN_TX_TIMEOUT` |
|
||
| `DB_QUERY_TIMEOUT`, `DB_QUERY_CANCELLED` | `DB_QUERY_CANCELED` |
|
||
|
||
`DB_COMMIT_INDETERMINATE`, `DB_TRANSACTION_OUTCOME_UNKNOWN`을 포함한 새 internal outcome은
|
||
registry migration 전에 기존 `DB_UNAVAILABLE`로 뭉개거나 public code로 직접 노출하지 않는다.
|
||
해당 target result를 production에 활성화하기 전에 `docs/registries/error-codes.yaml`,
|
||
`OperationalError`, web mapping, category/HTTP/retryable contract와 consumer compatibility를
|
||
하나의 migration으로 변경한다. indeterminate public code는 `retryable=false`이고
|
||
stable operation identity가 있는 policy는 reconciliation reference를 별도 안전한 response
|
||
field/header로 전달한다. legacy root는 reference를 만들지 않고
|
||
`reconciliationAvailable=false`만 노출한다. 어느 쪽도 raw DB 정보를 포함하지 않는다.
|
||
allowlisted constraint의 business 의미는 application error로 번역하며
|
||
skeleton-wide `shared-contract`에 `DUPLICATE` 같은 domain vocabulary를 추가하지 않는다.
|
||
|
||
public error의 기존 `retryable=true`는 transport/client advisory일 뿐
|
||
`SAFE_WHOLE_TRANSACTION` 허가가 아니다. 자동 transaction replay는 §15.5의 별도 disposition과
|
||
operation ledger 조건을 모두 충족해야 한다. `57014`를 재시도하더라도 같은 transaction의
|
||
statement만 반복하지 않고 결과가 노출되지 않은 replay-safe whole read transaction을 새로
|
||
시작한다.
|
||
|
||
### 15.4 constraint allowlist
|
||
|
||
constraint name은 adapter 내부 registry에서 semantic error로 매핑한다.
|
||
|
||
```text
|
||
uk_work_item_external_key -> WORK_ITEM_ALREADY_EXISTS
|
||
fk_work_item_owner -> WORK_ITEM_OWNER_MISSING
|
||
ck_work_item_status -> PERSISTED_STATE_INVALID
|
||
```
|
||
|
||
규칙:
|
||
|
||
- allowlist에 없는 `23505`를 idempotency race로 취급하지 않는다.
|
||
- DB constraint name을 client message에 노출하지 않는다.
|
||
- rename migration은 old/new name을 rolling window 동안 모두 인식한다.
|
||
- mapping coverage를 migration/test가 검증한다.
|
||
|
||
### 15.5 retry disposition
|
||
|
||
```text
|
||
NEVER
|
||
SAFE_WHOLE_TRANSACTION
|
||
RECONCILE_FIRST
|
||
CALLER_POLICY
|
||
```
|
||
|
||
retry 조건:
|
||
|
||
- transaction policy가 replay-safe를 선언한다.
|
||
- callback에 remote side effect가 없다.
|
||
- ID, clock/random 결과가 retry 간 안정적이거나 command intent에 고정된다.
|
||
- absolute `CallBudget`이 남아 있다.
|
||
- attempts와 exponential backoff/jitter가 bounded다.
|
||
- commit outcome이 determinate rollback이다.
|
||
|
||
금지:
|
||
|
||
- commit-indeterminate 자동 retry;
|
||
- controller/filter의 blanket retry;
|
||
- statement 하나만 재시도;
|
||
- 모든 `DataIntegrityViolationException` retry;
|
||
- 이미 소비한 one-shot stream callback retry;
|
||
- 새 operation ID를 생성한 재시도.
|
||
|
||
### 15.6 commit-indeterminate reconciliation
|
||
|
||
replay 가능한 command는 stable `OperationId`와 canonical `intentDigest`를 transaction의 첫
|
||
write로 operation ledger에 기록한다. `operation_id`는 unique이고 같은 ID의 다른 digest는
|
||
conflict다. 원 transaction과 replay가 겹치면 이 unique row/lock이 database 안에서 둘을
|
||
중재한다.
|
||
|
||
```text
|
||
operation_ledger
|
||
operation_id primary key
|
||
operation_catalog_id
|
||
intent_digest
|
||
source_revision?
|
||
result_digest?
|
||
committed_at
|
||
```
|
||
|
||
raw request나 response를 ledger에 복제하지 않는다. 성공 복원에 필요한 bounded receipt만
|
||
저장하고 retention은 idempotency/retry window보다 짧지 않게 한다.
|
||
|
||
```text
|
||
commit response lost
|
||
-> return typed INDETERMINATE
|
||
-> inspect current writable authority by operation ID
|
||
-> if committed intent matches: restore success
|
||
-> if conflicting intent: application/operator conflict
|
||
-> if absent but authority/timeline/RPO is not qualified: remain indeterminate
|
||
-> if same authoritative timeline is qualified and replay is allowed:
|
||
retry the same operation ID + intent digest through the unique ledger
|
||
(the database, not an absent read, arbitrates)
|
||
-> if conflicting intent: operator/application conflict
|
||
-> if still unknown: remain indeterminate
|
||
```
|
||
|
||
“connection error이므로 실패했다” 또는 “retry 후 성공했으므로 한 번만 실행됐다”라고
|
||
추론하지 않는다. 한 번의 primary absent read는 original commit이 아직 진행 중이거나
|
||
topology가 전환 중일 수 있으므로 safe retry 증거가 아니다. reconciliation은 current writable
|
||
role, authority/timeline epoch, observation horizon, declared RPO를 함께 검증한다. failover가
|
||
acknowledged write를 잃을 수 있는 window라면 absent여도 `INDETERMINATE`를 유지한다.
|
||
operation ledger가 없거나 operation key로 duplicate safety를 증명하지 못하는 command도 자동
|
||
replay하지 않는다. legacy facade root write는 이 범주이며 sanitized correlation과 allowlisted
|
||
business key를 이용한 manual inspection만 가능하다. invocation 뒤 새 operation ID를 만들어
|
||
ledger에 소급 삽입하거나 별도 재요청을 같은 operation으로 가장하지 않는다.
|
||
|
||
## 16. Datasource, pool과 admission
|
||
|
||
### 16.1 pool은 deployment-wide budget이다
|
||
|
||
pool size는 한 pod의 성능 숫자가 아니라 각 PostgreSQL server/proxy의 실제 application
|
||
connection budget에서 역산한다. primary와 서로 다른 replica의 `max_connections`를 하나의
|
||
합계로 더하지 않는다.
|
||
|
||
```text
|
||
usable_application_connections(server)
|
||
= configured_server_limit
|
||
- superuser/provider reserved slots
|
||
- platform agents and monitoring
|
||
- migration/admin/break-glass reserve
|
||
- failover safety reserve
|
||
|
||
required_connections(server, topology_state)
|
||
= sum(maximum pools that can target server in topology_state)
|
||
+ health/maintenance reserve
|
||
|
||
max(required_connections(server, every qualified topology_state))
|
||
<= usable_application_connections(server)
|
||
```
|
||
|
||
`topology_state`에는 HPA 최대 instance, rolling surge, blue/green overlap, replica promotion,
|
||
replica query의 primary fallback을 포함한다. primary, 각 replica, proxy quota마다 별도 표를
|
||
남긴다. 현재 pod 수나 정상 상태 하나만 사용하지 않는다.
|
||
|
||
현행 D12의 per-process 하한도 보존한다.
|
||
|
||
```text
|
||
hikari.maximumPoolSize
|
||
>= concurrent_threads * (1 + max_inNew_depth) + 1
|
||
```
|
||
|
||
추가 health/maintenance reserve가 1보다 크면 별도 합산한다. R2 baseline의
|
||
`max_inNew_depth`는 1이고 §12.2의 전용 inner reserve를 포함한다. 이 local 하한과 server별
|
||
deployment 상한을 동시에 만족하지 못하면 concurrency를 낮추거나 `REQUIRES_NEW` 구조를
|
||
제거해야지 pool 설정을 강제로 통과시키지 않는다.
|
||
|
||
### 16.2 primary와 replica pool 분리
|
||
|
||
replica profile은 route와 관측을 위해 별도 datasource/pool을 사용한다.
|
||
|
||
| Pool | 역할 | minimum idle | maximum | 필수 role probe |
|
||
| --- | --- | --- | --- | --- |
|
||
| primary | write, strong read, reconciliation | explicit | capacity-derived | writable primary |
|
||
| replica | eventual/bounded read only | explicit | capacity-derived | read-only standby/qualified endpoint |
|
||
|
||
한 JDBC URL의 multi-host failover 기능만으로 semantic primary/replica routing을 대신하지 않는다.
|
||
primary pool은 `targetServerType=primary`와 role probe로 writable endpoint를 검증한다.
|
||
replica pool은 exact secondary/read-only endpoint를 요구한다. `preferSecondary`처럼 primary로
|
||
조용히 fallback하는 설정은 replica consistency descriptor와 충돌하므로 사용하지 않는다.
|
||
|
||
router가 replica failure 시 primary로 fallback할 수 있는 policy는 별도로 이름 붙인다.
|
||
fallback은 consistency를 강화하지만 primary load를 증가시키므로 metric과 admission을 거친다.
|
||
fallback은 transaction 시작 전 qualification 실패 때만 바로 허용한다. transaction/query가
|
||
시작된 뒤에는 같은 transaction에서 route를 바꾸지 않는다. 새 primary transaction에서 전체
|
||
query를 replay하는 fallback은 결과가 한 row도 caller에 노출되지 않았고 query가
|
||
replay-safe/materialized인 경우에만 허용한다. streaming 또는 일부 row 소비 뒤 failure는 typed
|
||
`CONSISTENCY_UNAVAILABLE`/`DB_UNAVAILABLE`로 끝내며 중간부터 이어 읽지 않는다.
|
||
|
||
### 16.3 fixed-size와 minimum idle
|
||
|
||
Hikari의 fixed-size 권장은 capacity가 산정된 production profile에서만 적용한다.
|
||
|
||
```text
|
||
minimumIdle == maximumPoolSize
|
||
```
|
||
|
||
를 선택하면 startup/warmup connection storm, failover, rolling deploy의 총 연결 수를 검증한다.
|
||
elastic pool을 선택하면 minimum, idle timeout, cold acquisition SLO를 별도로 검증한다.
|
||
|
||
template은 모든 환경에 하나의 숫자를 강제하지 않는다. 대신 다음을 강제한다.
|
||
|
||
- 모든 pool shape 값이 explicit;
|
||
- deployment-wide capacity equation;
|
||
- invalid/ambiguous Duration fail-fast;
|
||
- primary/replica별 metric;
|
||
- load evidence와 운영 owner.
|
||
|
||
### 16.4 virtual thread와 admission
|
||
|
||
Java 21 virtual thread는 JDBC connection 수를 늘리지 않는다. 많은 request가 작은 pool 앞에
|
||
동시에 대기하면 memory와 tail latency가 커진다.
|
||
|
||
각 DB operation class에 application-level admission/bulkhead를 둔다.
|
||
|
||
```text
|
||
accepted concurrency
|
||
<= pool capacity + bounded wait queue
|
||
```
|
||
|
||
admission acquire는 `CallBudget`을 사용하며 connection을 얻기 전에 실패할 수 있다.
|
||
command/query/maintenance가 같은 permit을 무제한 경쟁하지 않도록 lane 또는 reserve를 둔다.
|
||
health probe가 request pool을 고갈시키지 않게 한다.
|
||
|
||
각 named policy는 `admissionBudget`, `acquireBudget`, `beginBudget`,
|
||
`minimumActionWindow`, `completionMargin`을 갖는다. Hikari `connectionTimeout`은 pool 전역
|
||
설정이므로 request마다 mutate하지 않는다. 한 pool을 공유하는 모든 수용 policy에 대해:
|
||
|
||
```text
|
||
connectionTimeout <= policy.acquireBudget
|
||
```
|
||
|
||
를 startup에 검증한다. 더 짧은 acquisition class가 필요하면 별도 capacity가 산정된 pool을
|
||
만들거나 보수적으로 fail-fast하며, 같은 pool의 전역 timeout을 동적으로 바꾸지 않는다.
|
||
|
||
### 16.5 connection lifecycle
|
||
|
||
필수 설정과 조건:
|
||
|
||
- `connectionTimeout`은 Hikari가 허용하는 finite 값이며 minimum보다 작지 않다.
|
||
- `validationTimeout < connectionTimeout`.
|
||
- `keepaliveTime < maxLifetime`.
|
||
- `maxLifetime`은 infrastructure connection lifetime보다 충분히 짧고 jitter를 고려한다.
|
||
- `idleTimeout`은 elastic pool일 때만 의미가 있다.
|
||
- leak detection은 진단 도구이며 correctness나 timeout 대체가 아니다.
|
||
- initialization fail timeout과 startup retry 정책을 명시한다.
|
||
- JDBC login/connect/socket timeout도 bootstrap budget 안에 둔다.
|
||
- `connectionTimeout`은 그 pool을 사용하는 모든 policy의 최소 acquisition budget 이하이다.
|
||
- admission 뒤 남은 budget이 worst-case pool wait와 begin/action/completion 최소 window를
|
||
담지 못하면 `getConnection()` 전에 거절한다.
|
||
|
||
Duration parser가 `5s`, ISO-8601, millisecond 중 canonical format을 정확히 읽지 못하면 값을
|
||
무시하지 않고 startup을 실패시킨다.
|
||
|
||
### 16.6 pool exhaustion outcome
|
||
|
||
pool acquisition timeout은 query timeout이나 DB unavailable과 구분한다.
|
||
|
||
```text
|
||
ADMISSION_REJECTED application lane capacity
|
||
POOL_ACQUISITION_TIMEOUT Hikari wait exhausted
|
||
DB_CONNECT_TIMEOUT physical connection/bootstrap
|
||
DB_UNAVAILABLE server/route unavailable
|
||
```
|
||
|
||
각 결과는 서로 다른 운영 대응과 metric을 가진다. acquire 실패에는 DB statement가 실행되지
|
||
않았음을 보존한다.
|
||
|
||
## 17. Deadline과 timeout 계층
|
||
|
||
### 17.1 기본 부등식
|
||
|
||
한 operation의 목표 계층:
|
||
|
||
```text
|
||
0 < lock_timeout
|
||
< statement_timeout
|
||
<= Spring transaction timeout
|
||
< remaining CallBudget
|
||
```
|
||
|
||
connection acquisition과 admission도 `remaining CallBudget` 안에 있어야 하며 response
|
||
serialization/cancellation margin을 남긴다.
|
||
|
||
Hikari가 operation별 wait timeout을 받지 않으므로 “남은 시간과 동적으로 교차한다”라고
|
||
과장하지 않는다. baseline은 두 번의 fail-fast gate를 사용한다.
|
||
|
||
```text
|
||
before admission:
|
||
remaining >= admissionBudget
|
||
+ connectionTimeout
|
||
+ beginBudget
|
||
+ minimumActionWindow
|
||
+ completionMargin
|
||
|
||
after admission, before getConnection:
|
||
remaining >= connectionTimeout
|
||
+ beginBudget
|
||
+ minimumActionWindow
|
||
+ completionMargin
|
||
```
|
||
|
||
두 번째 gate를 통과하지 못하면 pool을 호출하지 않는다.
|
||
|
||
정확히 모든 operation에 lock timeout이 필요한 것은 아니다. lock을 사용하지 않는 query는
|
||
policy의 작은 default를 유지하거나 명시적으로 적용하지 않을 수 있다. 그러나 무한 대기는
|
||
허용하지 않는다.
|
||
|
||
### 17.2 effective timeout 계산
|
||
|
||
```text
|
||
remaining = callBudget.remaining(now)
|
||
preAcquireRequired =
|
||
pool.connectionTimeout
|
||
+ policy.beginBudget
|
||
+ policy.minimumActionWindow
|
||
+ policy.completionMargin
|
||
|
||
if remaining < preAcquireRequired:
|
||
reject before pool acquisition
|
||
|
||
safeTxWindow =
|
||
remaining
|
||
- pool.connectionTimeout
|
||
- policy.beginBudget
|
||
- policy.completionMargin
|
||
|
||
springTimeoutSeconds =
|
||
floor_seconds(min(policy.transactionTimeout, safeTxWindow))
|
||
|
||
if springTimeoutSeconds < 1:
|
||
reject before delegate.getTransaction()
|
||
|
||
definition.timeout = springTimeoutSeconds
|
||
status = delegate.getTransaction(definition)
|
||
|
||
remainingAfterBegin = callBudget.remaining(now)
|
||
springWindowRemaining =
|
||
springTimeoutSeconds - elapsedSinceGetTransactionStarted
|
||
statementWindow =
|
||
min(remainingAfterBegin - completionMargin, springWindowRemaining)
|
||
|
||
statementTimeout = min(policy.statementTimeout, statementWindow - txMargin)
|
||
lockTimeout = min(policy.lockTimeout, statementTimeout - lockMargin)
|
||
```
|
||
|
||
Spring timeout은 per-call `TransactionDefinition`을 만든 뒤
|
||
`delegate.getTransaction(definition)`을 호출하기 전에 결정한다. begin 뒤 남은 window가
|
||
statement/lock timeout에 부족하면 첫 business statement 전에 rollback한다. 어느 변환에서도
|
||
0이 framework default/unlimited 의미가 되지 않게 ceil/floor 규칙을 test한다.
|
||
|
||
Spring transaction timeout은 초 단위 정수이므로 usable budget을 넘지 않는 양의 floor만
|
||
사용한다. floor가 1초 미만이면 transaction을 시작하지 않는다. PostgreSQL millisecond timeout이
|
||
있다고 Spring timeout을 0/default로 두지 않는다. `999ms`, `1000ms`, `1001ms`와 margin
|
||
경계 test가 안전한 reject/1초 선택을 고정한다.
|
||
|
||
### 17.3 PostgreSQL local timeout
|
||
|
||
PostgreSQL package가 transaction 시작 직후 다음을 transaction-local로 적용한다.
|
||
|
||
```sql
|
||
select set_config('statement_timeout', :statement_timeout_text, true);
|
||
select set_config('lock_timeout', :lock_timeout_text, true);
|
||
select set_config('idle_in_transaction_session_timeout', :idle_guard_timeout_text, true);
|
||
```
|
||
|
||
또는 동등한 parameterized `SET LOCAL` protocol을 사용한다.
|
||
|
||
규칙:
|
||
|
||
- session-level state를 pool에 누출하지 않는다.
|
||
- route/tenant/timeout context는 첫 business statement 전에 설정한다.
|
||
- local 설정 실패 시 business query를 진행하지 않는다.
|
||
- nested `REQUIRED`는 outer transaction timeout/route를 늘리거나 바꾸지 못한다.
|
||
- participating inner deadline이 더 짧으면 physical transaction context의
|
||
statement/lock/idle GUC를 현재 값과 inner effective 값의 minimum으로 한 번 더 낮춘다.
|
||
이 축소는 inner 종료/예외 뒤 복원하지 않고 physical transaction 종료까지 sticky하다.
|
||
outer의 이후 statement도 축소된 GUC와 absolute `CallBudget` pre-gate를 사용한다.
|
||
- participant는 Spring transaction timeout을 다시 설정하지 않고, local GUC를 늘리거나
|
||
outer absolute deadline을 연장할 수 없다.
|
||
- PostgreSQL 16 baseline에 없는 기능을 사용 가능하다고 가정하지 않는다.
|
||
- `set_config`의 value는 PostgreSQL이 요구하는 text로 명시적으로 변환하며, bind parameter를
|
||
지원하지 않는 raw `SET LOCAL ... ?` 문자열을 만들지 않는다.
|
||
|
||
sticky minimum을 선택한 이유는 같은 physical transaction에서 `set_config(..., true)`가
|
||
transaction 종료까지 유지되기 때문이다. 저장/복원으로 outer budget을 다시 늘리지 않는다.
|
||
`outer-before → tighter inner → outer-after`, caught inner exception, inner statement timeout과
|
||
absolute deadline 경계를 real PostgreSQL에서 검증한다. inner timeout으로 transaction이 abort
|
||
상태가 되면 catch 후 계속하지 않고 rollback-only로 종료한다.
|
||
|
||
PostgreSQL 16에서는 server-side `statement_timeout`, `lock_timeout`,
|
||
`idle_in_transaction_session_timeout`과 Spring transaction timeout을 조합한다. 이것을
|
||
kernel/network까지 포괄하는 hard cancellation이라고 표현하지 않는다.
|
||
|
||
`idle_in_transaction_session_timeout`은 statement/lock total-deadline 부등식의 한 항이
|
||
아니라 “business statement 사이에 허용할 최대 idle gap”을 막는 별도 operational guard다.
|
||
remote I/O나 user think time을 transaction 안에서 기다리지 않는다는 invariant에서 policy별로
|
||
산정하고, statement/lock timeout과 같은 `:milliseconds` 변수를 재사용하지 않는다.
|
||
|
||
### 17.4 JDBC timeout
|
||
|
||
- query timeout은 statement execution guard다.
|
||
- socket timeout은 network read guard지만 commit uncertainty를 만들 수 있다.
|
||
- connect/login timeout은 physical connection bootstrap guard다.
|
||
- Hikari connection timeout은 pool wait guard다.
|
||
|
||
하나의 `DB_TIMEOUT`으로 합치지 않는다. driver property의 단위와 interaction을 typed settings
|
||
validation과 real fault test로 검증한다.
|
||
|
||
### 17.5 cancellation
|
||
|
||
caller cancellation이 transaction thread interruption과 정확히 같은 의미라고 가정하지 않는다.
|
||
|
||
- cancel signal을 받으면 가능한 경우 JDBC statement cancel을 요청한다.
|
||
- rollback/connection cleanup 완료 전 성공 또는 재실행 가능 결과를 반환하지 않는다.
|
||
- cancel이 commit 단계와 겹치면 outcome은 indeterminate일 수 있다.
|
||
- interrupted flag를 보존한다.
|
||
- cancellation metric은 query timeout과 분리한다.
|
||
|
||
## 18. Write, batch와 persistence context
|
||
|
||
### 18.1 단일 aggregate command
|
||
|
||
- aggregate를 primary transaction에서 읽고 변경한다.
|
||
- expected version이 있으면 version predicate를 검증한다.
|
||
- domain transition 뒤 mapper가 persistence state를 반영한다.
|
||
- outbox가 필요하면 같은 transaction에서 append한다.
|
||
- error mapping이 필요한 constraint는 commit 전 explicit flush할 수 있다.
|
||
- remote publication은 commit 뒤 별도 relay가 한다.
|
||
|
||
### 18.2 insert batching
|
||
|
||
batching은 entity ID strategy, JDBC driver rewrite, Hibernate ordering과 함께 검증한다.
|
||
|
||
초기 후보:
|
||
|
||
```text
|
||
hibernate.jdbc.batch_size
|
||
hibernate.order_inserts
|
||
hibernate.order_updates
|
||
```
|
||
|
||
잠긴 Hibernate `7.1.8`의 실제 `BatchSettings`에 존재하는 설정만 허용한다. 다른 버전의
|
||
문서에서 본 property를 추정해 추가하지 않으며, startup property allowlist test로 exact
|
||
version을 검증한다. 설정 존재만으로 batching이 작동한다고 주장하지 않는다. real
|
||
PostgreSQL에서 versioned entity를 포함한 statement/round trip 또는 datasource proxy evidence를
|
||
확인한다.
|
||
|
||
UUID application-generated ID는 insert batching과 잘 맞지만 index locality와 page split 비용을
|
||
부하 test로 본다. sequence를 도입하면 allocation size와 rollback gap을 정상 동작으로
|
||
문서화한다.
|
||
|
||
### 18.3 bounded batch
|
||
|
||
maintenance/backfill/import:
|
||
|
||
```text
|
||
claim/read bounded keys
|
||
-> transaction per bounded batch
|
||
-> write
|
||
-> flush
|
||
-> clear
|
||
-> persist checkpoint
|
||
-> next batch
|
||
```
|
||
|
||
규칙:
|
||
|
||
- row count와 byte/time budget을 모두 둔다.
|
||
- 전체 dataset을 persistence context에 보관하지 않는다.
|
||
- transaction마다 remote I/O를 하지 않는다.
|
||
- failure 후 같은 checkpoint에서 안전하게 재시작한다.
|
||
- batch size는 configuration upper bound를 넘지 않는다.
|
||
- partial progress와 retry semantics를 runbook에 남긴다.
|
||
|
||
### 18.4 bulk DML
|
||
|
||
JPQL/native bulk update/delete는 다음 조건에서만 허용한다.
|
||
|
||
- aggregate invariant를 우회해도 되는 infrastructure state;
|
||
- explicit version/audit predicate와 mutation;
|
||
- persistence context clear;
|
||
- affected-row count assertion;
|
||
- concurrent worker test;
|
||
- named operation/query ID.
|
||
|
||
일반 domain aggregate의 상태 전환을 bulk DML에 숨기지 않는다.
|
||
|
||
### 18.5 upsert
|
||
|
||
PostgreSQL `INSERT ... ON CONFLICT`는 adapter 내부의 명시적 arbitration operation에만 사용한다.
|
||
|
||
- conflict target을 named schema constraint와 맞춘다.
|
||
- insert와 update의 application 의미를 typed outcome으로 분리한다.
|
||
- update predicate에 owner token/version을 포함한다.
|
||
- arbitrary entity save를 upsert로 바꾸지 않는다.
|
||
- returned row와 affected-row semantics를 real PostgreSQL에서 검증한다.
|
||
|
||
## 19. Query, fetch와 N+1
|
||
|
||
### 19.1 query catalog
|
||
|
||
운영 가치가 있는 query에는 stable low-cardinality ID를 부여한다.
|
||
|
||
```text
|
||
work_item.summary.by_owner.v1
|
||
outbox.delivery.claim.v2
|
||
idempotency.resolve.v2
|
||
```
|
||
|
||
query catalog는 최소 다음을 기록한다.
|
||
|
||
| Field | 의미 |
|
||
| --- | --- |
|
||
| query catalog ID | metric/trace/plan의 stable low-cardinality key |
|
||
| owner port/method | application semantic owner |
|
||
| consistency | strong/eventual/bounded |
|
||
| max rows/bytes | resource bound |
|
||
| sort/order | deterministic order |
|
||
| expected index | structural plan expectation |
|
||
| statement budget | N+1 포함 최대 count |
|
||
| timeout policy | named policy |
|
||
| sensitive fields | log/trace redaction |
|
||
|
||
raw SQL text나 parameter를 metric tag로 쓰지 않는다.
|
||
이 ID는 요청별 query/operation instance나 reconciliation `OperationId`가 아니다.
|
||
|
||
### 19.2 projection 우선
|
||
|
||
list/search/read-model은 필요한 column만 application projection으로 읽는다.
|
||
|
||
- JPQL constructor/interface projection은 단순한 provider-neutral query에 사용한다.
|
||
- native/JDBC projection은 PostgreSQL-specific operator, CTE, window, keyset, claim이 필요한
|
||
경우 사용한다.
|
||
- entity 전체를 읽은 뒤 web DTO로 대량 변환하는 것을 기본으로 하지 않는다.
|
||
- projection constructor와 alias drift를 compile/integration test한다.
|
||
|
||
### 19.3 fetch plan
|
||
|
||
aggregate load에는 use case별 explicit fetch plan을 둔다.
|
||
|
||
- entity graph;
|
||
- fetch join;
|
||
- batch fetch;
|
||
- secondary bounded query.
|
||
|
||
하나의 global eager mapping으로 해결하지 않는다. collection fetch join과 paging의 조합은
|
||
row multiplication/메모리 paging 위험이 있으므로 사용하지 않거나 two-step key query로
|
||
분리한다.
|
||
|
||
### 19.4 N+1 budget
|
||
|
||
대표 use case test는 result correctness와 함께 statement count upper bound를 검증한다.
|
||
|
||
예:
|
||
|
||
```text
|
||
summary page 50 rows:
|
||
expected <= 2 statements
|
||
aggregate detail:
|
||
expected <= 3 statements
|
||
```
|
||
|
||
정확한 count는 query design에 따라 다르지만 row 수에 비례해 증가하면 실패해야 한다.
|
||
Hibernate statistics 또는 datasource instrumentation은 test profile에서만 상세 정보를
|
||
수집하고 production에서는 bounded recorder를 사용한다.
|
||
|
||
### 19.5 count query
|
||
|
||
total count는 비용이 있으므로 API가 정말 요구할 때만 실행한다.
|
||
|
||
- `Slice`/cursor는 `limit + 1`로 `hasNext`를 계산한다.
|
||
- Page total이 필요하면 목적별 count query와 index를 설계한다.
|
||
- collection join이 있는 auto-generated count query를 신뢰하기 전에 plan/result를 검증한다.
|
||
- approximate count는 정확한 total과 다른 typed contract로 분리한다.
|
||
|
||
### 19.6 dynamic query
|
||
|
||
허용 filter/sort catalog를 application enum/value로 고정한다.
|
||
|
||
- empty predicate 의미를 정의한다.
|
||
- optional filter 조합 수와 plan을 검증한다.
|
||
- string concatenated SQL을 만들지 않는다.
|
||
- native identifier가 필요하면 allowlist에서만 선택한다.
|
||
- generic `Specification`을 application port로 노출하지 않는다.
|
||
|
||
### 19.7 query timeout과 slow query
|
||
|
||
query ID별 timeout policy를 사용한다. slow query log는 다음을 지킨다.
|
||
|
||
- SQL parameter/PII 미기록;
|
||
- normalized query ID;
|
||
- elapsed time, row count, route, outcome;
|
||
- sampling/rate limit;
|
||
- trace correlation;
|
||
- stack trace는 반복 rate limit.
|
||
|
||
PostgreSQL `pg_stat_statements`를 운영 query aggregate 근거로 사용할 수 있지만 extension 설치와
|
||
data retention은 deployment responsibility다. application metric과 database view를 query
|
||
ID/normalized shape로 연결한다.
|
||
|
||
## 20. Pagination과 large read
|
||
|
||
### 20.1 inbound bound
|
||
|
||
inbound validation과 application value object가 다음을 강제한다.
|
||
|
||
- positive limit;
|
||
- per-query maximum;
|
||
- allowlisted sort;
|
||
- stable tie-breaker;
|
||
- cursor maximum length;
|
||
- malformed/expired/version-unknown cursor rejection.
|
||
|
||
adapter가 음수/과대 page를 임의 default로 바꿔 성공시키지 않는다.
|
||
|
||
### 20.2 offset pagination
|
||
|
||
offset은 다음에만 사용한다.
|
||
|
||
- shallow, bounded admin/list page;
|
||
- total page UX가 실제 요구;
|
||
- maximum offset이 명시됨;
|
||
- stable order와 index가 있음.
|
||
|
||
deep offset export/scan에는 사용하지 않는다.
|
||
|
||
### 20.3 keyset cursor
|
||
|
||
keyset 기본 shape:
|
||
|
||
```text
|
||
ORDER BY sort_key DESC, id DESC
|
||
WHERE (sort_key, id) < (:last_sort_key, :last_id)
|
||
LIMIT :limit_plus_one
|
||
```
|
||
|
||
cursor는 versioned opaque envelope로 만든다.
|
||
|
||
```text
|
||
version
|
||
queryShapeId
|
||
sortKey
|
||
tieBreaker
|
||
filterFingerprint
|
||
issuedAt/optional expiry
|
||
integrity MAC when client-visible tampering matters
|
||
```
|
||
|
||
cursor에 PII를 plaintext로 넣지 않는다. filter/sort가 바뀐 cursor를 재사용하면
|
||
`CURSOR_MISMATCH`로 거절한다.
|
||
|
||
### 20.4 snapshot 의미
|
||
|
||
여러 page request 사이에는 일반적으로 새 write가 들어올 수 있다. keyset은 duplicate/skip을
|
||
줄이지만 전체 snapshot을 보장하지 않는다.
|
||
|
||
정확한 snapshot이 필요하면:
|
||
|
||
- bounded single transaction;
|
||
- materialized export job/snapshot table;
|
||
- version/as-of predicate;
|
||
- 별도 analytical store
|
||
|
||
중 하나를 선택한다. web request에 long-running open transaction을 유지하는 것을 기본으로
|
||
하지 않는다.
|
||
|
||
### 20.5 streaming
|
||
|
||
JPA stream은 transaction과 connection을 stream close까지 보유한다. 따라서:
|
||
|
||
- application port에 raw `Stream<Entity>`를 노출하지 않는다.
|
||
- try-with-resources close ownership을 adapter가 보장한다.
|
||
- row/time/byte limit를 둔다.
|
||
- HTTP client 속도에 DB connection lifetime을 직접 묶지 않는다.
|
||
- large export는 checkpointed job이 DB batch를 읽고 file/object storage에 publish한다.
|
||
|
||
## 21. Primary/replica와 read consistency
|
||
|
||
### 21.1 consistency vocabulary
|
||
|
||
```java
|
||
public sealed interface ReadConsistency {
|
||
record Strong() implements ReadConsistency {}
|
||
record ReadYourWrites(SessionWriteMarker marker) implements ReadConsistency {}
|
||
record BoundedStaleness(Duration maximumLag) implements ReadConsistency {}
|
||
record Eventual() implements ReadConsistency {}
|
||
}
|
||
```
|
||
|
||
exact API shape는 구현 계획에서 조정할 수 있지만 의미는 다음과 같이 고정한다.
|
||
|
||
| Consistency | v1 route | 보장 |
|
||
| --- | --- | --- |
|
||
| `STRONG` | primary | 현재 writable authority에서 각 statement 시작 시점의 committed snapshot |
|
||
| `READ_YOUR_WRITES` | primary | 같은 authority/timeline 안에서 marker까지 포함한 read |
|
||
| `BOUNDED_STALENESS(maxLag)` | endpoint-bound lag-qualified replica, 실패 시 명시 policy | 실제 query backend에 결속된 보수적 관측 lag가 bound 이내 |
|
||
| `EVENTUAL` | role-qualified replica | 최신성 bound 없음 |
|
||
|
||
replica read가 linearizable하다고 주장하지 않는다. primary read도 여러 statement 사이 repeatable
|
||
snapshot을 뜻하지 않는다.
|
||
|
||
### 21.2 route 결정 시점
|
||
|
||
route는 transaction/connection acquisition 전에 결정한다.
|
||
|
||
```text
|
||
resolve policy + consistency
|
||
-> bind route context
|
||
-> begin transaction
|
||
-> datasource router selects pool
|
||
-> connection acquired
|
||
```
|
||
|
||
transaction이 시작된 뒤 route를 바꾸지 않는다. nested `REQUIRED` query는 outer primary
|
||
transaction 안에서 replica로 downgrade되지 않는다.
|
||
|
||
### 21.3 nested rule
|
||
|
||
- outer write transaction 안의 모든 read는 primary다.
|
||
- outer strong read 안의 nested eventual request도 primary다.
|
||
- outer replica transaction 안에서 write를 시도하면 fail-fast/DB read-only failure다.
|
||
- `REQUIRES_NEW`로 route를 바꾸는 것은 allowlisted policy에서만 가능하고 pool capacity를
|
||
포함해 검증한다.
|
||
- async thread에 route context를 암묵적으로 전파하지 않는다.
|
||
|
||
Spring transaction과 JDBC는 thread-bound이므로 transaction callback 안에서 async/fork를
|
||
금지한다. virtual thread 하나가 transaction lifetime 동안 같은 logical execution을 유지한다.
|
||
|
||
### 21.4 lag qualification
|
||
|
||
bounded-staleness route는 provider가 다음을 제공할 때만 활성화한다.
|
||
|
||
- monotonic 또는 conservative lag observation;
|
||
- observation timestamp와 TTL;
|
||
- replica replay state;
|
||
- stale/unknown 상태;
|
||
- failover role detection.
|
||
|
||
qualification 결과는 일반 boolean이 아니라 다음 internal value와 동등해야 한다.
|
||
|
||
```text
|
||
QualifiedReplicaRoute(
|
||
endpointId,
|
||
poolGeneration,
|
||
roleEpoch,
|
||
observedAt,
|
||
observedLagUpperBound,
|
||
observationErrorMargin,
|
||
expiresAt,
|
||
evidence)
|
||
```
|
||
|
||
replica pool generation 하나는 qualification 대상인 한 physical/provider logical endpoint에
|
||
고정한다. borrowed connection의 backend identity와 read-only role이 qualification의
|
||
endpoint/role epoch와 같은지 첫 business query 전에 확인한다. reconnect, DNS target change,
|
||
promotion/failover, pool generation 교체가 발생하면 기존 qualification을 즉시 폐기한다.
|
||
여러 standby를 숨긴 load-balanced endpoint가 이 결속을 제공하지 못하면 그 pool은
|
||
bounded-staleness에 사용할 수 없다.
|
||
|
||
query 직전 eligibility는 monotonic time으로 다음을 계산한다.
|
||
|
||
```text
|
||
effectiveLagUpperBound =
|
||
observedLagUpperBound
|
||
+ (monotonicNow - observedAt)
|
||
+ observationErrorMargin
|
||
|
||
eligible iff
|
||
monotonicNow <= expiresAt
|
||
and effectiveLagUpperBound <= requestedMaximumLag
|
||
|
||
expiresAt <=
|
||
observedAt
|
||
+ requestedMaximumLag
|
||
- observedLagUpperBound
|
||
- observationErrorMargin
|
||
```
|
||
|
||
음수/0 window는 즉시 ineligible이다. provider가 준 TTL을 그대로 신뢰하지 않고 위 식과
|
||
provider TTL 중 더 이른 시각을 사용한다.
|
||
|
||
v1 `BOUNDED_STALENESS` query는 statement budget 1인 fully materialized projection으로 제한한다.
|
||
N+1, lazy load, data+count 두 statement, streaming을 허용하지 않는다. 따라서 qualification은
|
||
borrowed backend 확인 뒤 첫 business statement 직전에 한 번 검증하고 result를 모두
|
||
materialize한 뒤 connection을 반환한다. multi-statement bounded read가 필요하면 같은 backend의
|
||
각 statement 전 재qualification 또는 pinned snapshot semantics를 별도 card로 설계한다.
|
||
|
||
provider가 replay timestamp/LSN을 요청 시각과 보수적으로 비교할 time-lag oracle을 제공하지
|
||
못하면 v1은 `EVENTUAL`만 활성화하고 `BOUNDED_STALENESS` descriptor를 등록하지 않는다. lag
|
||
unknown은 bound satisfied가 아니다. fallback policy:
|
||
|
||
```text
|
||
FAIL_CLOSED
|
||
FALLBACK_PRIMARY
|
||
RETURN_STALE_UNAVAILABLE
|
||
```
|
||
|
||
를 query policy별로 고정한다. fallback primary는 metric과 trace event를 남긴다.
|
||
qualification 실패의 primary fallback은 transaction 시작 전에만 가능하다. mid-query
|
||
disconnect 또는 일부 결과 노출 뒤에는 §16.2의 replay 제한을 적용한다.
|
||
|
||
### 21.5 read-your-writes
|
||
|
||
v1은 같은 request/session의 RYW를 primary route와 `SessionWriteMarker(authorityEpoch,
|
||
operationId/sourceRevision)`로 제공한다. 단순히 primary URL을 선택했다는 이유만으로 failover
|
||
뒤 RYW를 주장하지 않는다. asynchronous replication의 RPO window에서 acknowledged write가 새
|
||
primary에 없을 수 있으므로 authority/timeline이 바뀌면 marker를 operation ledger로 reconcile해
|
||
확인하거나 `CONSISTENCY_UNAVAILABLE`을 반환한다.
|
||
|
||
read consistency와 durability는 별도 descriptor다. RYW/STRONG label이 provider의 synchronous
|
||
commit, zero-RPO 또는 failover durability를 암시하지 않는다. WAL LSN token을 client에
|
||
전달하고 replica replay를 기다리는 최적화는 다음을 별도 검증한 뒤에만 도입한다.
|
||
|
||
- token integrity와 topology binding;
|
||
- failover timeline;
|
||
- wait timeout;
|
||
- privacy;
|
||
- replica replay API;
|
||
- primary fallback.
|
||
|
||
### 21.6 health와 failover
|
||
|
||
replica가 optional인 strong-only deployment에서는 replica 장애가 application readiness를
|
||
내리지 않는다. replica-required query profile이면 readiness descriptor에 degraded/unavailable을
|
||
반영한다.
|
||
|
||
primary endpoint가 read-only standby로 바뀌면 write readiness가 실패해야 한다. driver
|
||
multi-host failover 뒤에도 role probe를 다시 수행한다.
|
||
|
||
multi-replica qualification, 다른 backend가 선택되는 load-balanced endpoint, reconnect,
|
||
promotion 전후 marker, result를 일부 소비한 뒤 disconnect를 real topology/fault test에
|
||
포함한다.
|
||
|
||
## 22. Same-store reliability capability
|
||
|
||
### 22.1 공통 원칙
|
||
|
||
idempotency, outbox, inbox는 단순한 table helper가 아니다. 각자 semantic port, state machine,
|
||
owner token, retention, reconciliation, metric, runbook을 가진 capability card다.
|
||
|
||
공통 invariant:
|
||
|
||
- scope/key는 canonical versioned encoding;
|
||
- request/message/event intent hash가 있다.
|
||
- claim은 owner token과 finite lease를 반환한다.
|
||
- renew/complete/release는 current owner+attempt+operation ID+state revision+status를 CAS한다.
|
||
- affected row count를 assert한다.
|
||
- database clock으로 lease를 비교한다.
|
||
- payload size/schema/version upper bound가 있다.
|
||
- reaper가 live owner를 삭제하지 않는다.
|
||
- idempotency/inbox와 polling delivery의 terminal delete는 business occurred time이 아니라
|
||
각 state machine의 terminal time을 기준으로 한다.
|
||
- unknown outcome은 같은 key로 reconcile한다.
|
||
|
||
lease 비교는 lock wait 전에 고정되는 `transaction_timestamp()`/`statement_timestamp()`가 아니라
|
||
row lock을 얻은 뒤 mutation CTE에서 한 번 평가한 `clock_timestamp()`의 `db_now`를
|
||
predicate와 새 `lease_until`에 함께 사용한다. audit/event `created_at`처럼 한 transaction의
|
||
일관된 기록 시각은 `transaction_timestamp()`를 사용할 수 있으나 lease authority와 섞지
|
||
않는다. CDC outbox에는 terminal row/time이 없으므로 이 terminal-time reaper invariant를
|
||
적용하지 않는다. CDC의 `retention_bucket`은 trusted database insertion time만으로 정하고
|
||
`occurred_at`은 business intent/audit 값일 뿐 retention authority가 아니다. CDC cleanup은
|
||
§22.3의 checkpoint/high-watermark, replay retention, incident/legal hold, snapshot 조건을
|
||
모두 충족해야 한다.
|
||
|
||
### 22.2 owner-safe JPA idempotency V2
|
||
|
||
canonical scope:
|
||
|
||
```text
|
||
tenant?
|
||
principal/client?
|
||
operation
|
||
idempotencyKeyDigest
|
||
```
|
||
|
||
raw client key를 table/index/log/metric에 저장하지 않는다. request fingerprint에는 method/path
|
||
같은 transport 문자열이 아니라 canonical application intent를 포함한다.
|
||
|
||
저엔트로피 key에 단순 hash만 적용하면 database 유출 뒤 사전 대입이 가능하다. scope에는
|
||
versioned HMAC digest를 기본으로 하고 `key_digest_version`을 저장한다. 새 write는 active key
|
||
version, read/replay는 제한된 prior-version window만 허용하며 rotation 완료 뒤 old version을
|
||
contract한다. key 자체가 검증된 충분한 entropy를 가진다고 주장하려면 입력 계약과 test
|
||
evidence가 필요하다.
|
||
|
||
권장 schema:
|
||
|
||
```text
|
||
idempotency_record
|
||
scope_hash primary/unique key component
|
||
key_digest_version
|
||
operation_code
|
||
request_fingerprint
|
||
state CLAIMED | EXECUTING | COMPLETED | FAILED_RETRYABLE | ABANDONED
|
||
state_revision
|
||
owner_token
|
||
lease_until
|
||
attempt
|
||
claim_operation_id
|
||
last_transition_operation_id
|
||
last_transition_kind
|
||
last_transition_result_digest
|
||
reconciliation_evidence_digest
|
||
response_schema
|
||
response_inline
|
||
response_digest
|
||
replay_until
|
||
created_at
|
||
updated_at
|
||
completed_at
|
||
expires_at
|
||
```
|
||
|
||
JPA와 Redis provider는 `application-core`의 동일한 Idempotency V2 state/result contract를
|
||
구현한다. provider가 다르다고 claim/execution vocabulary를 축약하지 않는다. claim algorithm은
|
||
single statement UPSERT 또는 lock/CAS로 최소 다음 결과를 구분한다.
|
||
|
||
```text
|
||
ACQUIRED(ownerToken, attempt, leaseUntil)
|
||
REPLAYED_ACQUIRE(ownerToken, attempt, leaseUntil)
|
||
TAKEN_OVER_CLAIMED(ownerToken, attempt, leaseUntil)
|
||
COMPLETED_REPLAY(response, replayUntil)
|
||
IN_PROGRESS(retryAfter)
|
||
RECOVERY_REQUIRED(currentAttempt)
|
||
FINGERPRINT_MISMATCH
|
||
OWNER_OPERATION_CONFLICT
|
||
INDETERMINATE(operationId)
|
||
UNAVAILABLE
|
||
```
|
||
|
||
state machine:
|
||
|
||
```text
|
||
CLAIMED -> EXECUTING -> COMPLETED
|
||
| |-----> FAILED_RETRYABLE
|
||
| \-----> ABANDONED
|
||
\-> release before execution
|
||
|
||
expired CLAIMED -> takeover CLAIMED with attempt+1
|
||
expired EXECUTING -> ABANDONED/RECOVERY_REQUIRED, no blind takeover
|
||
ABANDONED -> verified committed reconciliation
|
||
-> verified no-effect reopen
|
||
```
|
||
|
||
`markExecutionStarted`, `renew`, `complete`, `markFailed`, `releaseBeforeExecution`,
|
||
`inspect`, `reconcileCommitted`, `reconcileNoEffectAndReopen`의 typed outcomes와 replay rule도
|
||
Redis §24의 application contract를 그대로 사용한다. 모든 mutation은 다음 tuple을
|
||
검증하고 `state_revision`을 증가시킨다.
|
||
|
||
```sql
|
||
update idempotency_record
|
||
set state = 'COMPLETED',
|
||
state_revision = state_revision + 1,
|
||
last_transition_operation_id = :transition_operation_id,
|
||
...
|
||
where scope_hash = :scope
|
||
and state = 'EXECUTING'
|
||
and owner_token = :owner
|
||
and attempt = :attempt
|
||
and state_revision = :expected_revision
|
||
and claim_operation_id = :claim_operation_id
|
||
```
|
||
|
||
exact column 사용은 transition별로 다듬되 `scope + current state + owner + attempt +
|
||
operation ID + state revision`보다 약한 CAS는 허용하지 않는다. 같은 transition operation ID와
|
||
result digest의 duplicate는 prior result를 replay하고 다른 digest는 conflict다. affected
|
||
row가 0이면 성공으로 간주하지 않는다. read-back/inspect로 owner mismatch, state revision
|
||
conflict, expired takeover, already completed, effect unknown을 분류한다.
|
||
|
||
#### SAME_STORE_TRANSACTIONAL
|
||
|
||
inline response가 bounded한 command의 R2 기본 choreography는 business write와 idempotency
|
||
transition을 같은 primary PostgreSQL transaction에 넣는다.
|
||
|
||
```text
|
||
transaction {
|
||
claim row INSERT/SELECT FOR UPDATE
|
||
verify fingerprint/state/owner/attempt/revision
|
||
CLAIMED -> EXECUTING
|
||
business write
|
||
outbox append
|
||
idempotency complete
|
||
}
|
||
```
|
||
|
||
pre-claim을 admission 목적으로 별도 transaction에서 commit한 profile도 business mutation의
|
||
첫 단계에서 claim row를 bounded `FOR UPDATE`로 잠그고 owner/status/lease/attempt/revision을
|
||
다시 검증한 뒤 lease를 갱신한다. 이 row lock은 business write와 completion commit까지
|
||
유지한다. A가 lock을 가진 동안 lease 시각이 지나도 B의 takeover update는 진행할 수 없으며,
|
||
A가 rollback하면 business write와 completion이 함께 사라진다. B가 먼저 takeover했다면 A는
|
||
business write 전에 owner CAS에서 실패한다. completion affected-row 0은 callback failure로
|
||
전파해 전체 business transaction을 rollback한다.
|
||
|
||
lock을 잡은 채 remote I/O, user callback 대기, unbounded work를 하지 않는다. row lock 없이
|
||
pre-claim owner를 읽기만 한 뒤 business write를 시작하는 choreography는 금지한다.
|
||
`lease expires while A holds the business transaction and B attempts takeover` barrier test는
|
||
B가 동시에 business mutation을 실행하지 못하고 business row가 정확히 한 번만 바뀌는지
|
||
검증한다.
|
||
|
||
`SAME_STORE_TRANSACTIONAL` R2는 bounded inline response만 광고한다. DB transaction 안에서
|
||
object storage `put/get/complete`를 호출하지 않는다. 큰 response reference는
|
||
`PENDING_RESPONSE -> staged upload -> finalize/reconcile`와 orphan cleanup을 가진 별도
|
||
cross-store response card가 설계·검증되기 전에는 이 guarantee에서 제외한다. 현행 V1
|
||
`response_ref` compatibility read가 필요해도 이를 V2 atomic guarantee로 표시하지 않으며
|
||
object-store 미활성 상태의 reference는 silent miss가 아니라 profile incompatibility다.
|
||
|
||
### 22.3 immutable outbox event와 delivery V2
|
||
|
||
권장 schema:
|
||
|
||
```text
|
||
outbox_event_identity_v2 unpartitioned uniqueness guard
|
||
tenant?
|
||
event_id
|
||
aggregate_type
|
||
aggregate_id
|
||
aggregate_version
|
||
event_ordinal
|
||
retention_bucket
|
||
created_at
|
||
|
||
outbox_publication_control_v2 one row for PRIMARY outbox scope
|
||
scope_id
|
||
active_epoch
|
||
active_authority LEGACY_POLLING | POLLING_V2 | CDC
|
||
state PREPARING | ACTIVE | DRAINING
|
||
revision
|
||
updated_at
|
||
|
||
outbox_publication_cutover_v2 immutable authority sentinel/audit
|
||
scope_id
|
||
active_epoch
|
||
previous_epoch
|
||
transition_kind GENESIS_FRESH | GENESIS_LEGACY | CUTOVER
|
||
active_authority
|
||
legacy_row_count
|
||
legacy_pending_count
|
||
legacy_digest
|
||
schema_manifest_id
|
||
external_manifest_id?
|
||
activated_at
|
||
|
||
outbox_event_log_v2 RANGE(retention_bucket)
|
||
retention_bucket
|
||
event_id
|
||
aggregate_type
|
||
aggregate_id
|
||
aggregate_version
|
||
event_ordinal
|
||
event_type
|
||
event_schema
|
||
logical_destination
|
||
partition_key?
|
||
publication_epoch
|
||
dispatch_authority LEGACY_SHADOW | POLLING_V2 | CDC
|
||
content_type
|
||
correlation_id?
|
||
causation_id?
|
||
occurred_at
|
||
payload
|
||
payload_digest
|
||
trace_parent?
|
||
tenant?
|
||
created_at
|
||
|
||
outbox_delivery_v2
|
||
tenant?
|
||
retention_bucket
|
||
event_id
|
||
destination
|
||
state PENDING | CLAIMED | PUBLISHED | RETRY_WAIT | DEAD
|
||
claim_owner
|
||
claim_token
|
||
claim_until
|
||
attempt
|
||
next_attempt_at
|
||
last_error_code
|
||
published_at
|
||
dead_at
|
||
version
|
||
```
|
||
|
||
constraints:
|
||
|
||
- `outbox_publication_control_v2`의 baseline `scope_id=PRIMARY` primary key와 정확히 한
|
||
active authority;
|
||
- `outbox_publication_cutover_v2(scope_id, active_epoch)` primary key와 cutover procedure의
|
||
same-scope `previous_epoch + 1` monotonic check;
|
||
- unpartitioned identity guard의 `event_id` primary key;
|
||
- identity guard의
|
||
`(aggregate_type, aggregate_id, aggregate_version, event_ordinal)` unique;
|
||
- identity guard의 `(event_id, retention_bucket)` unique;
|
||
- range-partitioned event의 `(retention_bucket, event_id)` primary key와 같은 두 column의
|
||
identity guard foreign key;
|
||
- delivery의 `(retention_bucket, event_id, destination)` primary key와 event composite
|
||
foreign key;
|
||
- claim eligibility/order index;
|
||
- payload size/check constraint;
|
||
- state별 required field check.
|
||
|
||
위 key 표기는 기본 `tenantMode=NONE` profile이다. tenant card를 선택하면 identity/event/
|
||
delivery의 `tenant_id`는 non-null이고 aggregate uniqueness, identity-event FK와
|
||
event-delivery PK/FK를 tenant-prefixed composite key로 바꿔 cross-tenant reference를
|
||
database가 거절한다. opaque `event_id`의 global uniqueness는 그대로 유지하고 tenant
|
||
composite constraint를 추가한다. publication control/cutover sentinel은 deployment authority이므로
|
||
tenant-owned row가 아니며 §25의 별도 role/manifest 경계를 따른다.
|
||
|
||
PostgreSQL 16 declarative partition table의 `PRIMARY KEY`/`UNIQUE`에는 모든 partition key가
|
||
포함돼야 한다. 따라서 partitioned `outbox_event_log_v2`에 `event_id` 단독 PK나 aggregate tuple
|
||
단독 UNIQUE를 선언하지 않는다. 전역 event/aggregate-tuple uniqueness는 같은 transaction에
|
||
먼저 쓰는 compact unpartitioned `outbox_event_identity_v2`가 보장하고, heavy envelope는
|
||
time-range partition에서 보존/제거한다. identity guard는 R2 baseline에서 system lifetime
|
||
동안 보존하고 용량/backup을 별도로 산정한다. 이를 purge해 uniqueness를 bounded horizon으로
|
||
낮추는 profile은 별도 card/evidence 없이는 활성화하지 않는다. guard에는 payload,
|
||
correlation/trace나 직접 PII를 두지 않고 opaque aggregate/event identity와 ordering tuple만
|
||
둔다. 그 identity 자체가 개인정보가 될 수 있는 fork는 privacy owner, erasure/tombstone
|
||
정책과 guarantee downgrade를 별도로 설계한다.
|
||
|
||
business transaction:
|
||
|
||
```text
|
||
assert active primary read-write transaction
|
||
assert transaction manager/resource identity
|
||
select PRIMARY publication control FOR SHARE
|
||
derive allowed row authority from active control
|
||
assert publication epoch and authority mapping
|
||
aggregate update
|
||
+ immutable outbox_event_identity_v2 insert
|
||
+ immutable partitioned outbox_event_log_v2 insert with the same event/bucket
|
||
+ active POLLING_V2 only: initial outbox_delivery_v2 insert
|
||
COMMIT
|
||
```
|
||
|
||
`OUTBOX_APPEND`는 새 transaction을 여는 policy가 아니라 caller transaction에 반드시 참여하는
|
||
operation이다. adapter는 Spring Data `save()`를 호출하기 전에 active primary read-write
|
||
transaction과 같은 `EntityManager`/datasource resource identity를 검증하고 없거나 다른
|
||
transaction manager이면 fail-fast한다. repository의 암묵적 transaction으로 event row만
|
||
commit되는 경로를 허용하지 않는다. architecture/integration test는 outer transaction 부재,
|
||
read-only transaction, 다른 transaction manager를 각각 거절하는지 검증한다.
|
||
|
||
publication control의 `FOR SHARE`는 event insert와 business write가 commit/rollback될 때까지
|
||
유지한다. 따라서 cutover의 `FOR UPDATE`와 충돌해 cutover 전 시작된 append가 commit 또는
|
||
rollback되기 전에 authority가 바뀌지 않는다. V2 insert trigger/check는 row의
|
||
`publication_epoch`이 잠근 control epoch와 같은지, authority가
|
||
`LEGACY_POLLING -> LEGACY_SHADOW`, `POLLING_V2 -> POLLING_V2`, `CDC -> CDC` mapping인지
|
||
검증한다. bridge의 `LEGACY_POLLING` authority에서는 full-intent V2 copy를
|
||
`LEGACY_SHADOW`로만 허용하고 delivery insert를 거절한다.
|
||
|
||
fresh install은 선택된 target authority로 epoch 1 control과
|
||
`GENESIS_FRESH(previous_epoch=0, legacy counts=0, empty-set digest, exact schema/external
|
||
manifest IDs)` sentinel을 같은 migration transaction에서 만든다. 여기서 legacy adoption은
|
||
`LEGACY_ADOPTED` origin을 처리하는 outbox-storage stream의 `V1__initialize_or_adopt`를
|
||
뜻한다. 이 path는 `LEGACY_POLLING` epoch 1 control, 현재 V1 count/digest를 기록한
|
||
`GENESIS_LEGACY` sentinel과 legacy mutation trigger를 같은 transaction에서 만든다. 어느
|
||
genesis sentinel도 없거나 control/manifest와 다르면 writer/dispatcher/startup은
|
||
fail-closed한다.
|
||
|
||
ordering authority의 기본은 optimistic aggregate version과 transaction-local event ordinal의
|
||
tuple이다. 한 domain transition에서 발생한 event는 application이 deterministic ordinal을
|
||
부여한다. `event_id`, aggregate version, ordinal, occurred-at, payload와 digest는
|
||
whole-transaction retry loop에 들어가기 전에 replay context로 고정한다. update command는
|
||
expected aggregate version을 pin하고 retry 중 더 새 version을 만나면 event를 새 version으로
|
||
조용히 rebase하지 않고 optimistic conflict로 끝낸다. `MAX(sequence)+1`은 금지한다. aggregate
|
||
version을 제공하지 못하는 destination은 descriptor를 `UNORDERED`로 낮추거나, 별도
|
||
per-aggregate atomic counter row 설계와 contention evidence를 가져야 한다.
|
||
|
||
relay:
|
||
|
||
```text
|
||
short claim transaction:
|
||
select eligible delivery FOR UPDATE SKIP LOCKED
|
||
set CLAIMED + owner/token/until
|
||
COMMIT
|
||
|
||
outside transaction:
|
||
publish message with stable event ID
|
||
|
||
short completion transaction:
|
||
update by event + destination + owner/token + CLAIMED
|
||
-> PUBLISHED or RETRY_WAIT/DEAD
|
||
COMMIT
|
||
```
|
||
|
||
broker publish와 DB completion 사이 응답 유실은 duplicate publish를 만들 수 있다. consumer
|
||
idempotency/inbox가 필요하며 exactly-once라고 표현하지 않는다.
|
||
|
||
aggregate strict order가 요구되면 order tuple N이 terminal/explicitly skipped되기 전 N+1을
|
||
claim하지 않는다. 여기서 순서는 `(aggregate_version, event_ordinal)` tuple이다. DEAD head를
|
||
무시할지 block할지는 destination policy와 operator audit로 결정한다.
|
||
|
||
polling delivery reaper는 `published_at`/`dead_at`을 기준으로 terminal retention을 계산하고
|
||
delivery를 먼저 제거한 뒤 payload partition/row를 정리한다. identity guard는 이 reaper가
|
||
삭제하지 않는다.
|
||
|
||
상위 activation SSOT의 `outbox.dispatch-mode`에 따라 storage/worker shape를 고정한다.
|
||
|
||
- `polling`: `jpa-outbox-storage-v2`와 `jpa-outbox-polling-delivery-v2`를 selected로 하고
|
||
immutable identity/event와 delivery를 같은 business transaction에 쓰며 polling relay만
|
||
활성화한다.
|
||
- `cdc`: `jpa-outbox-storage-v2`, `jpa-outbox-cdc-retention-v1`과 external
|
||
`messaging-cdc-dispatch.v1`을 selected/R2로 요구한다. immutable identity/event만 쓰며 CDC
|
||
connector가 consume하고 application polling delivery scheduler/table stream을 만들지 않는다.
|
||
- `disabled`: event append가 필요한 use case composition을 fail-fast하고 scheduler/table
|
||
activity가 없다.
|
||
|
||
polling과 CDC worker가 같은 event를 동시에 publish하지 않도록 mode는 상호 배타적이다. CDC
|
||
connector offset/delivery guarantee는 별도 messaging/CDC card의 증거이며, JPA event insert만으로
|
||
broker delivery R2를 주장하지 않는다. polling relay와 CDC connector predicate는 각각
|
||
`outbox_publication_control_v2`와 같은 active epoch/authority 및 그 immutable authority
|
||
sentinel만 수용하고 `LEGACY_SHADOW`, stale/future epoch를 거절한다.
|
||
|
||
CDC profile에서 immutable `outbox_event_log_v2`는 trusted database insertion time 기준 range
|
||
partition을 사용하고 event envelope에 logical destination과 stable `partition_key`를 저장한다.
|
||
application이 고정한 `occurred_at`은 과거/미래 시각일 수 있으므로 partition routing이나
|
||
retention 판단에 사용하지 않는다.
|
||
ordered destination은 non-null key가 필수이며 기본 key는 tenant가 활성화되면 tenant와 aggregate
|
||
identity, 아니면 aggregate identity에서 deterministic하게 만든다. connector는 이 값을 broker
|
||
key로 전달한다. destination 설정이 ordered인데 key mapping을 증명하지 못하면 startup/card
|
||
activation을 실패시킨다.
|
||
|
||
partition router는 identity insert에서 trusted DB time으로 결정하고 반환한 bounded
|
||
`retention_bucket`을 event/delivery에 동일하게 저장한다. application-supplied bucket과 open
|
||
partition allowlist 밖 bucket을 거절한다. identity conflict를 만난 reconciliation은 저장된
|
||
bucket/digest를 읽지 새 bucket에 다시 넣지 않는다. adjacent partition에 같은 `event_id` 또는
|
||
같은 aggregate version/ordinal을 동시에 넣는 race가 identity guard에서 정확히 한 건만
|
||
성공하는지 PostgreSQL 16 concurrency/migration test로 검증한다.
|
||
|
||
CDC에는 polling delivery의 `published_at`가 없으므로 terminal-row reaper를 재사용하지 않는다.
|
||
closed partition 제거 조건은 모두 충족해야 한다.
|
||
|
||
1. connector가 partition의 source high watermark보다 뒤의 checkpoint를 durable하게 commit했다.
|
||
2. connector/control-plane이 그 high watermark까지 event를 consume했다는 immutable evidence를
|
||
제공한다.
|
||
3. configured replay retention과 incident hold가 지났다.
|
||
4. snapshot/recovery/replay가 그 partition을 더 요구하지 않는다.
|
||
|
||
JPA maintenance code가 connector offset을 추측하지 않는다. bootstrap이 provider-neutral
|
||
checkpoint evidence contract를 조합하거나 외부 운영 job이 동일 조건을 증명하며, evidence가
|
||
없거나 stale하면 cleanup은 fail-closed한다. cleanup delete/partition detach가 CDC change
|
||
record나 broker tombstone으로 routing되지 않도록 connector predicate를 고정한다. detach/drop은
|
||
audit manifest, row/time bound와 restore reference를 남긴다.
|
||
|
||
CDC card evidence에는 connector outage 동안의 partition growth, checkpoint 정지, restart,
|
||
snapshot cutover, retention 직전/직후, cleanup record filtering과 polling↔CDC mode 전환
|
||
rehearsal가 포함된다. 전환은 write freeze 또는 backlog drain, connector offset/high-watermark
|
||
검증, duplicate 방지와 rollback point를 runbook으로 고정한다. 이 cross-card evidence가 없으면
|
||
JPA outbox storage는 R2여도 CDC delivery/retention R2를 주장하지 않는다.
|
||
|
||
### 22.4 same-store inbox
|
||
|
||
scope:
|
||
|
||
```text
|
||
consumer_group
|
||
handler_name
|
||
tenant?
|
||
message_id
|
||
```
|
||
|
||
권장 state:
|
||
|
||
```text
|
||
RECEIVED -> PROCESSING(owner, lease) -> COMPLETED
|
||
\------> RETRYABLE / DEAD
|
||
```
|
||
|
||
DB-writing handler:
|
||
|
||
```text
|
||
transaction {
|
||
claim row INSERT/SELECT FOR UPDATE
|
||
verify owner/attempt/state revision
|
||
apply business write
|
||
append outgoing outbox
|
||
complete inbox
|
||
}
|
||
ack broker after commit
|
||
```
|
||
|
||
commit 응답 유실 시 broker redelivery가 같은 inbox key를 reconcile한다. broker ack가 먼저
|
||
나가면 안 된다. handler의 remote side effect는 outbox/workflow로 옮긴다.
|
||
|
||
inbox execution mode를 혼합하지 않는다.
|
||
|
||
- `TRANSACTIONAL_CLAIM`: claim/business write/outbox/completion을 한 transaction에 두는 R2
|
||
기본 mode다.
|
||
- `LEASED_PRECLAIM`: broker admission을 위해 claim을 먼저 commit할 수 있지만 business
|
||
transaction 첫 단계에서 row를 `FOR UPDATE`하고 owner/attempt/revision을 재검증하며 commit까지
|
||
lock을 유지한다.
|
||
|
||
preclaim을 읽기만 하고 business mutation을 수행하거나, completion CAS 실패를 broker ack 뒤
|
||
경고로만 처리하는 구현은 금지한다. lease 만료 중 takeover barrier test는 old/new handler가
|
||
business row를 동시에 변경하지 못하고 affected row가 정확히 한 번인지 검증한다.
|
||
|
||
### 22.5 reaper와 maintenance ownership
|
||
|
||
- bounded batch;
|
||
- owner-safe eligibility predicate;
|
||
- primary route;
|
||
- finite transaction/lock timeout;
|
||
- per-state retention;
|
||
- delete affected rows metric;
|
||
- dry-run/count diagnostic;
|
||
- shutdown 시 새 claim 중단;
|
||
- multi-instance efficiency lock이 실패해도 DB predicate가 correctness를 보장.
|
||
|
||
scheduler adapter가 직접 policy를 숨긴 `@Transactional` method를 실행하지 않고 application
|
||
maintenance command 또는 명시적 infrastructure transaction policy를 호출한다.
|
||
|
||
### 22.6 schema evolution 연계
|
||
|
||
JPA idempotency V1에서 V2로 이동할 때 Redis 심화 문서 §24가 사용하는
|
||
`application-core` owner-safe state/result contract를 exact reuse한다.
|
||
|
||
```text
|
||
expand owner/lease/attempt/operation/state-revision columns nullable
|
||
-> bridge V1 read + V2 write
|
||
-> backfill/version existing rows
|
||
-> switch full state machine and tuple CAS
|
||
-> drain old IN_PROGRESS rows
|
||
-> enforce NOT NULL/check/unique
|
||
-> observe owner mismatch/unknown outcome
|
||
-> contract V1 code/column
|
||
```
|
||
|
||
outbox V1에서 V2는 event/delivery dual-read보다 명시적 bridge가 필요하다. §23.3의 legacy
|
||
adoption 뒤 target-only 새 event는 identity guard + partitioned storage에 쓰고, polling mode만
|
||
delivery stream에 함께 쓴다. 다만 현재 V3 row에는 V2의 aggregate type/version/ordinal과
|
||
destination contract가 없으므로 legacy row를 fabricated ordering 값으로 V2에 backfill하지
|
||
않는다. old writer가 만든 V1-only row는 V1 relay로 drain한다. bridge release가 full V2 intent를
|
||
가진 새 event를 같은 stable event ID로 V1과 V2에 dual-write할 때만 V2 copy를
|
||
`LEGACY_SHADOW`로 남긴다. 이 copy에는 delivery row를 만들지 않고 CDC connector도 filter한다.
|
||
V1/V2 event-ID reconciliation manifest가 legacy-only, matched-shadow, mismatch를 구분하며
|
||
mismatch면 cutover를 중단한다. CDC cutover는 external connector watermark/epoch가 준비되기
|
||
전 V1 row나 legacy shadow를 production CDC로 route하지 않는다.
|
||
|
||
현재 V3가 만든 일반 table `outbox_event`는 PostgreSQL 16에서 in-place declarative partitioned
|
||
table로 바꿀 수 없으므로 V2 physical object는 처음부터 별도 이름
|
||
`outbox_publication_control_v2`, `outbox_publication_cutover_v2`,
|
||
`outbox_event_identity_v2`, `outbox_event_log_v2`, `outbox_delivery_v2`를 사용한다.
|
||
`LEGACY_ADOPTED` origin의 outbox-storage stream `V1__initialize_or_adopt`가 V1 table의
|
||
INSERT와 status UPDATE/DELETE trigger를 추가한다. legacy
|
||
`db/migration/postgresql` stream의 additive adoption migration은 fingerprint/marker만
|
||
소유하고 이 target object를 만들지 않는다. trigger는 `PRIMARY` control row를 `FOR SHARE`로
|
||
읽고 authority가 `LEGACY_POLLING`일 때만 mutation을 허용한다. 이 lock은 old append
|
||
transaction이 끝날 때까지 유지되므로 cutover `FOR UPDATE`가 in-flight old append를
|
||
추월하지 못한다. trigger rejection은 old writer의 business transaction 전체를
|
||
rollback시키며 event 없이 aggregate만 commit하는 경로를 허용하지 않는다.
|
||
|
||
```text
|
||
bridge window:
|
||
DB control = (PRIMARY, epoch=N, LEGACY_POLLING, ACTIVE)
|
||
old writer -> outbox_event V1
|
||
bridge/new writer with full V2 intent -> V1 + V2 LEGACY_SHADOW with the same event ID
|
||
V1 relay only; V2 relay/CDC production route disabled
|
||
checkpointed reconciler -> classify legacy-only/matched-shadow/mismatch, digest/row-count compare
|
||
|
||
cutover:
|
||
freeze new outbox append
|
||
drain V1 pending/in-flight and stop old writer/relay binary
|
||
verify migrated already-published V2 events are immutable LEGACY_SHADOW rows with no delivery
|
||
external migration authority begins one DB transaction
|
||
lock PRIMARY control row FOR UPDATE and recheck epoch=N/LEGACY_POLLING
|
||
recheck V1 pending/in-flight=0 and matched-shadow digest
|
||
REVOKE INSERT, UPDATE, DELETE ON outbox_event FROM the exact runtime role
|
||
update control to (epoch=N+1, target authority, ACTIVE)
|
||
insert immutable outbox_publication_cutover_v2 sentinel for N+1
|
||
COMMIT; target writer/dispatcher require the same control+sentinel
|
||
start exactly one target dispatcher
|
||
resume with a V2-only writer; new rows carry the new epoch/target dispatch authority
|
||
|
||
contract:
|
||
pre-cutover rollback window와 V1 usage 0 확인
|
||
drop old outbox_event only in a forward contract migration
|
||
keep V2 physical names stable; optional read-only diagnostic compatibility view만 허용
|
||
```
|
||
|
||
V1/V2 relay가 같은 production destination을 동시에 publish하지 않는다. write freeze를 생략하는
|
||
online dual-authority migration은 별도 fenced epoch/ACL 설계와 evidence 없이는 허용하지 않는다.
|
||
publication epoch은 immutable event row를 update하는 값이 아니라 DB control row의 monotonic
|
||
fencing 값이다. application activation manifest는 원하는 mode와 DB epoch/authority/sentinel이
|
||
일치하는지만 검증하며 authority가 아니다. paused/partitioned old pod가 cutover 후 복귀하면
|
||
legacy ACL과 trigger 중 적어도 하나에서 fail-closed하고 그 business transaction도 rollback한다.
|
||
runtime role이 legacy table owner/superuser여서 revoke/trigger를 우회할 수 있는 배포는 security
|
||
card와 cutover gate를 통과하지 못한다. append가 재개된 뒤에는 V1이 V2-only event를 표현할 수
|
||
없으므로 old-binary rollback을 허용하지 않고 forward recovery만 수행한다. rollback rehearsal은
|
||
target append를 다시 시작하기 전 pre-cutover point에서 target backlog 0/격리와 legacy schema
|
||
compatibility를 확인한다.
|
||
|
||
## 23. Flyway와 rolling migration
|
||
|
||
### 23.1 schema authority
|
||
|
||
- production physical schema writer는 Flyway다.
|
||
- Hibernate `ddl-auto`는 production에서 `validate` 또는 `none`만 허용한다.
|
||
- `update`, `create`, `create-drop`은 startup validator가 production profile에서 거절한다.
|
||
- migration이 external job이어도 application startup은 schema compatibility를 검증한다.
|
||
- applied versioned migration은 immutable이다.
|
||
- checksum mismatch를 repair로 숨기지 않는다.
|
||
|
||
application artifact는 `acceptedSchemaEpoch[min,max]`와 활성 feature별
|
||
`requiredSchemaRevision` manifest를 포함한다. Flyway가 관리하는 infrastructure marker에는
|
||
current schema epoch와 feature revision을 둔다. readiness validator는 다음을 서로 다른
|
||
결과로 검증한다.
|
||
|
||
1. Flyway history와 applied checksum integrity;
|
||
2. `min <= current schema epoch <= max`;
|
||
3. 활성 capability의 required feature revision 충족;
|
||
4. critical table/column/constraint/index shape probe;
|
||
5. Hibernate mapping validation.
|
||
|
||
Flyway checksum이나 `ddl-auto=validate` 하나만으로 N/N-1 compatibility를 주장하지 않는다.
|
||
지원 matrix의 각 application artifact/schema epoch 조합과 negative startup 결과를 CI evidence
|
||
manifest에 남긴다.
|
||
|
||
### 23.2 migration execution mode
|
||
|
||
| Mode | 용도 | 규칙 |
|
||
| --- | --- | --- |
|
||
| `STARTUP` | local/dev, 단일 instance test | explicit lock/wait budget, failure 시 app startup 실패 |
|
||
| `EXTERNAL_JOB` | production 권장 | dedicated credential/job, app는 migrate하지 않고 validate |
|
||
|
||
production에서 여러 pod가 동시에 migration을 시도하는 방식을 기본으로 하지 않는다.
|
||
external job이 성공하기 전 new application traffic을 열지 않는다.
|
||
|
||
모든 production migration은 script metadata/runbook에 finite `lock_timeout`,
|
||
`statement_timeout`, maximum wall budget, dedicated `application_name`, transactional 여부를
|
||
기록한다. job은 적용 전에 blocking/long-running transaction과 예상 lock conflict를
|
||
read-only preflight하고 허용 범위를 넘으면 traffic을 막는 DDL을 시작하지 않는다.
|
||
|
||
- transactional migration은 transaction 시작 직후 `SET LOCAL`/`set_config(..., true)`로
|
||
timeout을 적용한다.
|
||
- nontransactional migration은 dedicated migration connection/session에 timeout을 설정하고
|
||
성공/실패 뒤 session reset이 증명되지 않으면 connection을 폐기한다.
|
||
- external job의 wall budget은 process/orchestrator deadline으로도 제한한다.
|
||
- timeout/failure 뒤 과거 migration을 수정하지 않고 partial object를 진단한 후 forward
|
||
recovery migration/runbook을 사용한다.
|
||
|
||
old application DML이 계속되는 동안 일반 `ALTER TABLE` lock contention, timeout, partial
|
||
nontransactional artifact, forward recovery를 production-size PostgreSQL test에서 검증한다.
|
||
|
||
### 23.3 optional capability migration stream
|
||
|
||
한 Flyway history에서 비활성 card의 낮은 version을 건너뛰고 나중에 `outOfOrder=false`로
|
||
적용하는 구조를 쓰지 않는다. core와 schema-bearing optional card는 독립 location/history
|
||
stream을 가진다.
|
||
|
||
| Stream | Location | History table |
|
||
| --- | --- | --- |
|
||
| legacy adoption, transition only | `db/migration/postgresql` | `flyway_schema_history` |
|
||
| core target | `db/migration/jpa/core` | `flyway_jpa_core_history` |
|
||
| `jpa-idempotency-owner-safe-v2` | `db/migration/jpa/idempotency` | `flyway_jpa_idempotency_history` |
|
||
| `jpa-outbox-storage-v2` | `db/migration/jpa/outbox-storage` | `flyway_jpa_outbox_storage_history` |
|
||
| `jpa-outbox-polling-delivery-v2` | `db/migration/jpa/outbox-polling` | `flyway_jpa_outbox_polling_history` |
|
||
| `jpa-inbox-same-store-v1` | `db/migration/jpa/inbox` | `flyway_jpa_inbox_history` |
|
||
| `jpa-tenant-discriminator-rls` | `db/migration/jpa/tenant` | `flyway_jpa_tenant_history` |
|
||
| `jpa-jdbc-efficiency-coordination` | `db/migration/jpa/coordination` | `flyway_jpa_coordination_history` |
|
||
|
||
`jpa-primary-replica`, `jpa-outbox-cdc-retention-v1`처럼 자기 schema object가 없는 card는 빈
|
||
Flyway stream/history table을 만들지 않는다. 각 stream의 version은 그 stream 안에서만 단조
|
||
증가하고 `outOfOrder=false`, `baselineOnMigrate=false`를 유지한다. optional migration은 core
|
||
object의 소유권을 임의로 이전하지 않으며 card별 schema revision과 core epoch prerequisite를
|
||
`capability_schema_registry` marker에 기록한다. location/history/revision metadata의
|
||
machine-readable SSOT도 §31.3의 card registry다.
|
||
|
||
production external job은 하나의 allowlisted global migration orchestration lock 아래에서
|
||
core를 먼저, 활성 optional stream을 dependency 순서로 migrate/validate한다. stream별 Flyway
|
||
lock만으로 서로 다른 history table의 DDL 충돌을 막을 수 있다고 가정하지 않는다. application
|
||
readiness는 활성 card의 history/checksum/revision을 요구하고, 이미 설치된 비활성 stream의
|
||
revision도 현재 binary가 이해할 수 있는 범위인지 core marker로 확인한다.
|
||
|
||
#### 현재 단일 history 채택 절차
|
||
|
||
현재 `db/migration/postgresql`의 V1/V3/V4/V5와 `flyway_schema_history`를 새 table에 복사하거나
|
||
적용된 script를 수정하지 않는다. 전환은 다음 release sequence로만 수행한다.
|
||
|
||
1. bridge release가 legacy V1/V3/V4/V5 checksum과 idempotency/outbox/`INT_LOCK` 실제
|
||
object fingerprint를 exact allowlist로 검증한다. sample artifact의 V2 같은 registered
|
||
contribution은 sample owner manifest로 별도 검증하고 target sample history로 채택하며
|
||
production core로 흡수하지 않는다.
|
||
2. 같은 legacy stream의 새 additive adoption migration이
|
||
`capability_schema_registry`와 installation origin `LEGACY_ADOPTED`를 만든다. 예상 checksum,
|
||
column/constraint/index가 하나라도 다르면 중단한다. 이 legacy stream migration은
|
||
fingerprint/marker만 소유하고 target outbox control/sentinel/trigger를 만들지 않는다.
|
||
3. 이 bridge release는 legacy history/location을 계속 사용하고 old/new application DML
|
||
compatibility를 제공한다. 아직 새 stream migration을 실행하지 않는다.
|
||
4. 모든 old migrator binary를 retire하고 external migration authority를 한 job으로 만든 뒤,
|
||
allowlisted `adoptJpaMigrationStreams` command만 각 target history를 explicit baseline
|
||
version `0`으로 초기화한다. 이것은 `baselineOnMigrate=true`가 아니며 preflight fingerprint,
|
||
global lock, operator approval와 audit manifest 없이는 실행할 수 없다.
|
||
5. 각 target stream의 immutable `V1__initialize_or_adopt`는 marker가 `FRESH`면 새 object를
|
||
만들고, `LEGACY_ADOPTED`면 검증된 legacy object를 유지한 채 additive V2 table/column과
|
||
dual-read/write bridge를 만든다. outbox storage stream은 기존 V3 `outbox_event`를
|
||
유지한 채 `outbox_publication_control_v2`, `outbox_publication_cutover_v2`,
|
||
`outbox_event_identity_v2`, `outbox_event_log_v2`와 legacy mutation guard를 새로 만들며,
|
||
같은 migration transaction에서 origin별 epoch 1 genesis sentinel도 만든다. polling
|
||
stream을 선택했을 때만 `outbox_delivery_v2`를 만든다. 그 밖의 shape/origin이거나
|
||
control/sentinel 한쪽만 생성되면 실패한다.
|
||
6. backfill과 dual-write observation 뒤 target read/write를 switch한다. legacy table/path 사용
|
||
0과 old binary 0을 확인한 뒤에만 contract migration을 수행한다.
|
||
7. legacy history와 V1/V3/V4/V5 resource는 declared rollback window 동안 read-only
|
||
compatibility evidence로 유지하며 repair/delete하지 않는다.
|
||
|
||
완전히 빈 database는 origin `FRESH`로 초기화하고 같은 explicit version-0 stream initialization
|
||
후 target core/selected optional V1부터 실행한다. legacy V1/V3/V4/V5를 실행하지 않으므로 fresh
|
||
disabled card는 schema/history side effect가 없다. legacy history가 있는데 adoption marker가
|
||
없거나, 두 history authority가 동시에 write 가능하거나, non-empty schema인데 origin이 없으면
|
||
fail-closed한다.
|
||
|
||
CI는 실제 현재 V1/V3/V4/V5 schema/history snapshot에서 bridge→explicit baseline→V1
|
||
adopt→backfill/switch를 실행한다. legacy checksum 한 글자 변경, constraint/index drift,
|
||
중단된 baseline/adoption, N/N-1 binary overlap과 rollback window를 negative/rolling test로
|
||
검증한다.
|
||
|
||
lifecycle 의미:
|
||
|
||
```text
|
||
never installed + disabled
|
||
-> optional Flyway instance/history/schema object 없음
|
||
|
||
disabled -> enabled
|
||
-> external job이 해당 stream 전체 checksum 검증/migrate
|
||
-> feature revision 충족 뒤 runtime bean/worker 활성
|
||
|
||
enabled -> disabled
|
||
-> worker/bean/새 migration 실행 중단
|
||
-> 기존 schema/history는 파괴하지 않고 INSTALLED_INACTIVE
|
||
|
||
installed inactive -> enabled
|
||
-> 기존 checksum/accepted revision 검증
|
||
-> forward migration 뒤 활성
|
||
```
|
||
|
||
disable은 destructive rollback이 아니다. fresh disabled profile의 “schema 없음”과 이전에 설치된
|
||
card를 비활성화한 “inert schema 잔존”을 descriptor에서 구분한다. 각 schema-bearing card는
|
||
fresh disabled, first enable, disable after use, re-enable, interrupted migration, old/new binary
|
||
rolling 조합을 real PostgreSQL에서 검증한다.
|
||
|
||
### 23.4 expand-contract 순서
|
||
|
||
```text
|
||
1. EXPAND
|
||
additive nullable column/table/index/constraint support
|
||
2. BRIDGE
|
||
old/new application이 함께 읽고 쓸 수 있는 code
|
||
3. BACKFILL
|
||
checkpointed bounded data conversion
|
||
4. SWITCH
|
||
canonical read/write를 new representation으로 전환
|
||
5. ENFORCE
|
||
NOT NULL, validation, uniqueness, FK/check
|
||
6. OBSERVE
|
||
old path 사용 0, drift/invalid row 0
|
||
7. CONTRACT
|
||
old column/index/code 제거
|
||
```
|
||
|
||
N/N-1 compatibility:
|
||
|
||
- schema S+1은 application N과 N-1을 안전하게 실행한다.
|
||
- application N은 migration 전/후 허용된 schema window를 명시한다.
|
||
- enum/check/column rename/drop은 한 release에 끝내지 않는다.
|
||
- rollback은 old binary가 new writes를 이해할 때만 가능하다.
|
||
|
||
### 23.5 large backfill
|
||
|
||
large backfill을 하나의 Flyway transaction에 넣지 않는다.
|
||
|
||
- additive schema migration만 먼저 적용;
|
||
- 별도 application/ops job;
|
||
- stable key cursor와 checkpoint;
|
||
- bounded batch/timeout;
|
||
- idempotent update predicate;
|
||
- throttle와 pause;
|
||
- progress/error metric;
|
||
- old/new read reconciliation;
|
||
- 완료 후 validation/enforcement migration.
|
||
|
||
backfill code와 schema window를 release artifact에 함께 추적한다.
|
||
|
||
### 23.6 index migration
|
||
|
||
large table의 production index는 `CREATE INDEX CONCURRENTLY`를 검토한다.
|
||
|
||
- PostgreSQL transaction block 안에서 실행할 수 없는 migration은 Flyway
|
||
`executeInTransaction=false`로 명시한다.
|
||
- 하나의 mixed migration에서 transactional/nontransactional statement를 섞지 않는다.
|
||
- 실패한 concurrent build의 invalid index를 detect/cleanup하는 runbook이 필요하다.
|
||
- 같은 table에서 concurrent index build 제한과 deploy concurrency를 반영한다.
|
||
- index 생성 후 query plan과 write amplification을 관측한다.
|
||
|
||
### 23.7 constraint enforcement
|
||
|
||
대형 table의 check/FK는 가능한 경우:
|
||
|
||
```text
|
||
ADD CONSTRAINT ... NOT VALID
|
||
-> validate existing rows/background repair
|
||
-> VALIDATE CONSTRAINT
|
||
```
|
||
|
||
를 사용한다. exact lock level과 PostgreSQL version 동작은 migration review에서 확인한다.
|
||
NOT NULL 전환은 null row 0, writer bridge, lock/time evidence 뒤에 수행한다.
|
||
|
||
### 23.8 destructive change
|
||
|
||
column/table drop, type narrowing, irreversible rewrite:
|
||
|
||
- explicit data-retention owner;
|
||
- backup/restore point;
|
||
- old binary 사용 0;
|
||
- dual-read/write 종료;
|
||
- query/index dependency 검사;
|
||
- production-size rehearsal;
|
||
- roll-forward plan;
|
||
- change window.
|
||
|
||
schema rollback file을 자동 생성한다고 무손실 rollback을 주장하지 않는다. 기본 복구 전략은
|
||
forward fix다.
|
||
|
||
### 23.9 migration location
|
||
|
||
production core와 §23.3에서 selected인 optional stream location을 external migration
|
||
composition에서 명시적으로 결합한다. application runtime validator는 같은 stream registry를
|
||
읽되 external-job mode에서 migrate하지 않는다. customizer가 default location을 교체하는지
|
||
추가하는지 test한다. legacy `db/migration/postgresql` location은 `LEGACY_ADOPTED` transition
|
||
job/rollback window에서만 읽고 fresh target installation에는 결합하지 않는다. sample migration은 별도 sample-owned stream/history를 사용하며 production
|
||
artifact에 들어가지 않는다. sample artifact에는 필요한 production core/selected stream이
|
||
빠지지 않게 manifest를 검증한다.
|
||
|
||
### 23.10 PostgreSQL version
|
||
|
||
현재 local baseline은 PostgreSQL 16이다. production qualification은:
|
||
|
||
- 정확한 supported minor;
|
||
- container image digest 또는 managed engine version;
|
||
- pgjdbc/Flyway/Hibernate compatibility;
|
||
- extension 목록;
|
||
- upgrade/failover rehearsal
|
||
|
||
를 기록한다. floating `postgres:16-alpine`은 local convenience일 뿐 immutable production
|
||
evidence가 아니다.
|
||
|
||
## 24. Schema, index와 query-plan 설계
|
||
|
||
### 24.1 schema naming
|
||
|
||
- application-owned explicit schema를 사용한다.
|
||
- runtime role의 `search_path`는 trusted `pg_catalog, <application_schema>`로 고정하고
|
||
startup borrowed connection마다 검증한다. `"$user"`나 untrusted writable schema를 넣지
|
||
않는다.
|
||
- `PUBLIC`과 runtime role의 `public` schema `CREATE`를 revoke하고 runtime role에는 application
|
||
schema `CREATE`/owner 권한을 주지 않는다. 필요하지 않으면 database `TEMP`도 부여하지 않는다.
|
||
- Hibernate `default_schema`를 application schema로 고정하고 native SQL은 schema-qualified
|
||
object를 사용한다. function/operator 호출도 allowlist/schema qualification을 적용한다.
|
||
- extension은 migration role이 승인된 trusted schema에 설치하며 `public`에 암묵적으로 생기는
|
||
것을 피한다.
|
||
- table/column/index/constraint 이름은 lower snake case와 bounded length를 사용한다.
|
||
- reserved keyword와 quoted mixed-case identifier를 피한다.
|
||
|
||
startup/security integration test는 writable-schema injection과 동일 이름 function/table
|
||
shadowing을 시도해 runtime query가 공격자 object를 resolve하지 않는지 검증한다.
|
||
|
||
### 24.2 index는 query/constraint에서 파생
|
||
|
||
각 index에는 owner query/constraint가 있어야 한다.
|
||
|
||
```text
|
||
query predicate equality columns
|
||
-> range/sort columns
|
||
-> stable tie-breaker
|
||
-> optional INCLUDE projection columns
|
||
```
|
||
|
||
multicolumn index의 column order를 “selectivity가 높은 순” 하나의 규칙으로 결정하지 않는다.
|
||
실제 predicate, ordering, prefix usability를 본다.
|
||
|
||
### 24.3 unique
|
||
|
||
- business uniqueness는 named unique constraint/index로 표현한다.
|
||
- nullable unique semantics를 명시한다.
|
||
- soft delete가 있으면 active-row partial unique index를 고려한다.
|
||
- tenant mode에서는 tenant key가 uniqueness scope에 포함된다.
|
||
- case-insensitive uniqueness는 normalization authority와 collation을 명시한다.
|
||
|
||
application normalize와 DB expression/collation이 다르면 correctness가 깨지므로 canonical
|
||
normalization test를 둔다.
|
||
|
||
### 24.4 foreign key
|
||
|
||
- aggregate delete/cascade semantics를 domain lifecycle과 맞춘다.
|
||
- broad `ON DELETE CASCADE`를 편의 기본값으로 쓰지 않는다.
|
||
- FK source column에 필요한 index를 query/delete workload 기준으로 검토한다.
|
||
- cyclic FK와 deferrable constraint는 별도 transaction semantics가 있을 때만 사용한다.
|
||
|
||
### 24.5 partial/covering/expression index
|
||
|
||
PostgreSQL-specific index는 `.postgresql` migration/query card가 소유한다.
|
||
|
||
- predicate가 query와 논리적으로 일치하는지;
|
||
- INCLUDE가 write/storage 비용보다 이득인지;
|
||
- expression normalization이 application과 같은지;
|
||
- index-only scan이 visibility map과 workload에서 실제로 가능한지
|
||
|
||
를 real plan/test로 검증한다.
|
||
|
||
### 24.6 representative plan evidence
|
||
|
||
`EXPLAIN` test는 전체 textual plan snapshot을 brittle하게 고정하지 않는다. 대표적인
|
||
production-scale fixture/statistics에서 구조 invariant를 검증한다.
|
||
|
||
예:
|
||
|
||
- forbidden sequential scan on large selective table;
|
||
- expected index 또는 bitmap path;
|
||
- bounded estimated/actual rows;
|
||
- no disk sort above threshold;
|
||
- no nested loop explosion;
|
||
- query completes within generous CI budget.
|
||
|
||
`EXPLAIN ANALYZE`는 실제 query를 실행하므로 destructive/mutating statement에 무심코 사용하지
|
||
않는다. fixture scale과 statistics drift를 versioned test data로 관리한다.
|
||
|
||
### 24.7 plan change governance
|
||
|
||
- query ID별 representative plan artifact;
|
||
- PostgreSQL/Hibernate/driver upgrade 전 comparison;
|
||
- index 추가/제거의 write/read impact;
|
||
- `ANALYZE`/statistics requirement;
|
||
- parameter skew와 generic/custom plan 위험;
|
||
- production slow query observation;
|
||
- rollback/roll-forward index plan.
|
||
|
||
plan text가 달라졌다는 이유만으로 실패시키지 않고, 성능/correctness invariant가 깨질 때
|
||
실패시킨다.
|
||
|
||
## 25. Tenant isolation
|
||
|
||
### 25.1 기본 profile
|
||
|
||
template 기본은 `tenantMode=NONE`이다. tenant concept를 모든 새 project에 억지로 넣지 않는다.
|
||
활성화 시 discriminator를 baseline으로 한다.
|
||
|
||
### 25.2 discriminator invariant
|
||
|
||
- 모든 tenant-owned row에 non-null `tenant_id`;
|
||
- 모든 repository/query predicate에 tenant key;
|
||
- primary key 또는 lookup index prefix에 tenant requirement 반영;
|
||
- 모든 business unique constraint에 tenant key;
|
||
- FK가 다른 tenant row를 참조하지 못하도록 composite key/FK 또는 별도 validation;
|
||
- outbox/idempotency/inbox scope에 tenant;
|
||
- cursor/filter fingerprint에 tenant context;
|
||
- cache/object/message key에도 동일 canonical tenant authority.
|
||
|
||
tenant ID는 inbound request body에서 신뢰하지 않고 authenticated application context에서
|
||
얻는다.
|
||
|
||
### 25.3 enforcement
|
||
|
||
repository developer의 기억만으로 tenant predicate를 보장하지 않는다.
|
||
|
||
- tenant-aware base collaborator 또는 query builder;
|
||
- integration test에서 cross-tenant fixture;
|
||
- native query review/check;
|
||
- query catalog tenant flag;
|
||
- schema constraint;
|
||
- optional RLS defense-in-depth.
|
||
|
||
generic JPA filter가 bulk/native SQL과 maintenance를 모두 자동 보호한다고 주장하지 않는다.
|
||
|
||
### 25.4 RLS optional profile
|
||
|
||
RLS를 사용하면:
|
||
|
||
- runtime role은 table owner, superuser, `BYPASSRLS`가 아니다.
|
||
- 필요한 table에 `ENABLE`과 `FORCE ROW LEVEL SECURITY`를 검토한다.
|
||
- transaction-local tenant setting을 첫 query 전에 parameterized하게 설정한다.
|
||
- pool 반환 시 session state가 남지 않는다.
|
||
- migration/maintenance role은 별도다.
|
||
- missing tenant context는 empty result가 아니라 fail-closed가 되어야 한다.
|
||
- partition, FK, unique, background job, backup/restore를 test한다.
|
||
- startup role probe와 negative integration test가 owner/superuser/`BYPASSRLS` 우회를 각각
|
||
거절한다.
|
||
|
||
RLS만으로 encryption, authorization, tenant-aware uniqueness를 대신하지 않는다.
|
||
|
||
### 25.5 schema/database per tenant
|
||
|
||
schema-per-tenant와 database-per-tenant는 tenant 수, migration fan-out, pool explosion,
|
||
provisioning/backup/legal isolation 요구가 실제로 있을 때 별도 capability로 설계한다. 이번
|
||
R2 baseline에 넣지 않는다.
|
||
|
||
## 26. Security, secret와 privacy
|
||
|
||
### 26.1 transport security
|
||
|
||
production PostgreSQL connection은 TLS와 hostname/certificate verification을 사용한다.
|
||
pgjdbc의 `sslmode=verify-full` 또는 deployment가 동등하게 검증한 설정을 canonical로 한다.
|
||
`require`만으로 hostname verification까지 되었다고 주장하지 않는다.
|
||
|
||
trust material:
|
||
|
||
- secret reference/volume로 주입;
|
||
- repository, image, example에 secret 없음;
|
||
- permission 최소화;
|
||
- rotation 절차;
|
||
- expiry metric/alert;
|
||
- 새 connection 검증 뒤 old pool drain.
|
||
|
||
### 26.2 role 분리
|
||
|
||
| Role | 권한 |
|
||
| --- | --- |
|
||
| migration | DDL과 승인된 migration DML |
|
||
| runtime-primary | 필요한 schema DML/sequence execute |
|
||
| runtime-replica | read only |
|
||
| monitoring | 승인된 statistics/health view |
|
||
| break-glass admin | 평시 application에서 미사용, audit |
|
||
|
||
runtime role에 schema owner/superuser/replication 권한을 주지 않는다. production application은
|
||
Flyway migration credential를 상시 보유하지 않는 external-job mode를 우선한다.
|
||
|
||
### 26.3 SQL injection과 identifier
|
||
|
||
- value는 bind parameter;
|
||
- sort/table/column/function은 allowlisted enum에서만 선택;
|
||
- native query string에 client input concat 금지;
|
||
- LIKE pattern escaping 의미를 명시;
|
||
- full-text/search extension query도 typed builder 사용;
|
||
- migration placeholder에 untrusted runtime input 금지.
|
||
|
||
### 26.4 data classification
|
||
|
||
entity/column/query card에 classification을 둔다.
|
||
|
||
```text
|
||
PUBLIC
|
||
INTERNAL
|
||
CONFIDENTIAL
|
||
RESTRICTED
|
||
```
|
||
|
||
RESTRICTED data:
|
||
|
||
- log/trace/query parameter 미기록;
|
||
- 최소 projection;
|
||
- retention/delete owner;
|
||
- backup 포함 암호화;
|
||
- access audit;
|
||
- lower environment masking/synthetic fixture;
|
||
- support dump redaction.
|
||
|
||
database encryption-at-rest는 application logging/authorization/column exposure 문제를 해결하지
|
||
않는다. field-level encryption이 필요하면 query/index/key rotation과 domain ownership을 별도
|
||
설계한다.
|
||
|
||
### 26.5 error privacy
|
||
|
||
client:
|
||
|
||
- stable application error code;
|
||
- safe message;
|
||
- correlation ID.
|
||
|
||
server diagnostic:
|
||
|
||
- query ID;
|
||
- SQLState;
|
||
- semantic constraint ID;
|
||
- route/pool;
|
||
- transaction phase;
|
||
- retry/outcome;
|
||
- sanitized exception class.
|
||
|
||
SQL text, bind value, JDBC URL credential, raw tenant/principal/idempotency key는 제외한다.
|
||
|
||
## 27. Configuration design
|
||
|
||
### 27.1 activation SSOT
|
||
|
||
권장 canonical shape:
|
||
|
||
```yaml
|
||
ca-skeleton:
|
||
capabilities:
|
||
persistence:
|
||
provider: jpa-postgresql
|
||
idempotency:
|
||
provider: jdbc
|
||
guarantee: same-store-transactional
|
||
outbox:
|
||
dispatch-mode: polling
|
||
inbox:
|
||
provider: disabled
|
||
lock:
|
||
provider: disabled
|
||
|
||
providers:
|
||
jpa-postgresql:
|
||
implementation-version: jpa-postgresql-v2
|
||
migration-mode: external-job
|
||
schema-compatibility: n-and-n-minus-1
|
||
tenant-mode: none
|
||
read-routing: primary-only
|
||
primary: {}
|
||
replica: {}
|
||
jdbc-coordination:
|
||
guarantee: efficiency-only
|
||
```
|
||
|
||
provider/mode 선택의 유일한 SSOT는 상위 platform 설계의
|
||
`ca-skeleton.capabilities.*.provider`와 `outbox.dispatch-mode`다. JPA provider subtree는
|
||
선택된 provider의 tuning/schema implementation version만 가지며 별도 `enabled`나 `mode`로
|
||
다시 활성화하지 않는다. V1/V2는 activation 선택이 아니라 descriptor와 rolling schema
|
||
compatibility를 위한 implementation version이다. 선택 값과 provider subtree가 불일치하면
|
||
startup을 실패시킨다. card별 schema revision을 operator configuration scalar로 복제하지
|
||
않는다. §31.3 registry의 selected card, `schema-stream`, migration location/history,
|
||
required core epoch와 feature revision이 유일한 schema-capability SSOT다. compiled runtime
|
||
descriptor와 immutable migration evidence는 그 metadata를 그대로 담는다. 따라서 CDC
|
||
selection에는 storage revision과 external messaging manifest만 있고 polling delivery
|
||
revision은 없으며, polling selection에는 storage와 polling revision이 각각 존재한다.
|
||
|
||
Spring `spring.datasource.*`, `spring.jpa.*`, `spring.flyway.*`를 자유로운 외부 public contract로
|
||
노출하는 대신 typed settings가 canonical env를 검증하고 필요한 framework properties를
|
||
composition한다. migration 기간의 legacy env alias는 충돌 시 fail-fast한다.
|
||
|
||
### 27.2 typed settings group
|
||
|
||
최소 그룹:
|
||
|
||
```text
|
||
JpaCapabilitySettings
|
||
PrimaryDataSourceSettings
|
||
ReplicaDataSourceSettings
|
||
PoolSettings
|
||
TransactionPolicySettings
|
||
PostgreSqlTimeoutSettings
|
||
MigrationSettings
|
||
QueryGuardSettings
|
||
TenantSettings
|
||
IdempotencyJpaSettings
|
||
OutboxJpaSettings
|
||
InboxJpaSettings
|
||
JdbcCoordinationSettings
|
||
```
|
||
|
||
string map으로 arbitrary policy를 받기보다 validated record와 allowlisted policy catalog를
|
||
사용한다.
|
||
|
||
### 27.3 startup validation
|
||
|
||
활성 profile에서 다음을 fail-fast한다.
|
||
|
||
- persistence provider가 `jpa-postgresql`이 아니거나 capability/provider subtree가 불일치;
|
||
- primary URL/credential/role/schema 누락;
|
||
- production에서 `ddl-auto=update/create/create-drop`;
|
||
- OSIV true 또는 canonical setting 누락;
|
||
- pool size <= 0 또는 deployment budget 초과;
|
||
- invalid/unknown Duration;
|
||
- validation timeout >= connection timeout;
|
||
- pool `connectionTimeout`이 수용 policy의 최소 acquisition budget보다 큼;
|
||
- policy 최소 remaining budget이 admission + connection acquire + begin + action +
|
||
completion window를 담지 못함;
|
||
- keepalive >= max lifetime;
|
||
- transaction/statement/lock/deadline hierarchy 위반;
|
||
- replica policy 활성인데 replica datasource/lag probe 없음;
|
||
- primary endpoint가 read-only;
|
||
- replica endpoint가 writable primary;
|
||
- migration external mode인데 checksum/schema epoch/feature revision/object compatibility 중 하나가
|
||
불충족;
|
||
- selected card별 code descriptor와 registry의 schema stream/core epoch/feature revision,
|
||
applied migration evidence가 불일치;
|
||
- CDC selected인데 polling delivery revision이 광고되거나 external messaging manifest가
|
||
없고, polling selected인데 CDC manifest가 광고됨;
|
||
- selected outbox mode와 DB publication control의 active epoch/authority, immutable cutover
|
||
sentinel이 불일치하거나 target cutover 뒤 runtime role에 legacy table mutation 권한이 남음;
|
||
- tenant RLS mode인데 runtime role/tenant context 검증 실패;
|
||
- duplicate SQLState/constraint mapping;
|
||
- deprecated/canonical config 동시 설정;
|
||
- sample migration이 production artifact에 포함됨.
|
||
|
||
### 27.4 disabled zero-side-effect
|
||
|
||
현재 application artifact는 JPA primary persistence가 필수다.
|
||
`capabilities.persistence.provider=disabled`는 DB-free composition card와 composition test가
|
||
구현되기 전에는 “DB 없이 기동”이 아니라 startup failure다.
|
||
|
||
`read-routing=primary-only`, `inbox.provider=disabled`, `lock.provider=disabled`,
|
||
`outbox.dispatch-mode=disabled`처럼 optional sub-card가 disabled이면 그 sub-card는:
|
||
|
||
- 해당 replica/provider datasource/pool을 만들지 않는다.
|
||
- 해당 endpoint DNS/connection을 시도하지 않는다.
|
||
- scheduler/reaper를 등록하지 않는다.
|
||
- disabled card 전용 migration/worker를 실행하지 않는다. never-installed card는 전용
|
||
history/schema object도 만들지 않고, previously-installed card는 §23.3의
|
||
`INSTALLED_INACTIVE` schema를 파괴하지 않는다.
|
||
- disabled card의 repository scan/bean side effect를 만들지 않는다.
|
||
- required semantic port가 없으면 composition이 명시적으로 실패하거나 feature use case 자체가
|
||
비활성화된다.
|
||
|
||
미래 DB-free artifact가 추가되면 전체 persistence provider disabled에서 primary
|
||
datasource/Flyway/entity scan이 모두 zero-side-effect임을 별도 artifact/composition test로
|
||
증명한 뒤에만 그 보장을 descriptor에 추가한다.
|
||
|
||
### 27.5 environment registry
|
||
|
||
새 env key는 repository의 env registry와 `verifyEnvKeys`를 함께 갱신한다.
|
||
|
||
- canonical key;
|
||
- type/unit;
|
||
- safe default 또는 required;
|
||
- secret 여부;
|
||
- environment별 example;
|
||
- deprecated alias와 제거 release;
|
||
- owning settings class;
|
||
- validation rule
|
||
|
||
를 기록한다. secret의 실제 값은 example/fixture에 넣지 않는다.
|
||
|
||
## 28. Bootstrap, readiness와 health
|
||
|
||
### 28.1 startup sequence
|
||
|
||
`EXTERNAL_JOB` production:
|
||
|
||
```text
|
||
resolve secrets
|
||
-> validate typed settings
|
||
-> create primary pool
|
||
-> probe connectivity + writable role + TLS
|
||
-> validate Flyway checksum + schema epoch/range + feature revision + critical objects
|
||
-> optional replica pool/probe/lag capability
|
||
-> validate transaction/query/failure catalogs
|
||
-> expose readiness
|
||
-> accept traffic
|
||
```
|
||
|
||
`STARTUP` local:
|
||
|
||
```text
|
||
resolve/validate
|
||
-> create migration datasource
|
||
-> migrate
|
||
-> create/validate runtime
|
||
-> readiness
|
||
```
|
||
|
||
migration datasource와 runtime datasource가 같은 pool/credential이어야 한다고 가정하지 않는다.
|
||
|
||
### 28.2 liveness
|
||
|
||
liveness는 database availability에 의존하지 않는다. DB 장애가 pod restart storm을 만들지 않게
|
||
process/event-loop 상태만 본다.
|
||
|
||
### 28.3 readiness
|
||
|
||
write-serving readiness:
|
||
|
||
- primary pool initialized;
|
||
- writable primary role;
|
||
- required Flyway checksum/schema epoch/feature revision/object compatibility;
|
||
- critical settings/catalog validation;
|
||
- admission runtime active.
|
||
|
||
replica:
|
||
|
||
- optional query면 degraded indicator;
|
||
- required query profile이면 해당 route readiness;
|
||
- lag unknown/over-bound를 bounded-staleness ready로 보지 않는다.
|
||
|
||
readiness query는 request pool을 고갈시키지 않고 low-cost/bounded여야 한다.
|
||
|
||
### 28.4 startup failure와 retry
|
||
|
||
orchestrator가 restart/backoff를 소유하는 production profile에서는 application 내부 무한
|
||
startup retry를 하지 않는다. finite bootstrap budget 뒤 명확히 실패한다. transient secret/
|
||
DNS/database startup ordering이 필요한 local compose는 bounded retry를 별도 profile로 둔다.
|
||
|
||
### 28.5 shutdown/quiesce
|
||
|
||
```text
|
||
readiness false
|
||
-> 신규 request/maintenance claim 중단
|
||
-> in-flight request budget 안에서 drain
|
||
-> outbox/inbox/idempotency worker claim 중단
|
||
-> owner lease가 끝나거나 safe release
|
||
-> pools close
|
||
```
|
||
|
||
connection pool을 먼저 닫아 active transaction을 indeterminate로 만들지 않는다. drain timeout
|
||
초과 시 active transaction/claim 수를 기록하고 강제 종료의 중복 가능성을 runbook에 남긴다.
|
||
|
||
## 29. Observability
|
||
|
||
### 29.1 metric naming 원칙
|
||
|
||
기존 repository metric registry를 SSOT로 사용하고 새 metric은 registry와 instrumentation을
|
||
같이 추가한다. metric tag는 bounded enum만 사용한다.
|
||
|
||
기존 registry 이름을 같은 의미의 새 이름으로 복제하지 않는다.
|
||
|
||
| Metric | 처리 | 안전한 tags |
|
||
| --- | --- | --- |
|
||
| `db.query.duration` | 기존 이름 재사용 | existing bounded `operation`, `outcome` |
|
||
| `hikaricp.connections.acquire` | 기존 이름 재사용 | `pool`, `outcome` |
|
||
| `hikaricp.connections.usage` | 기존 이름 재사용 | `pool` |
|
||
| `hikaricp.connections.active` | 기존 이름 재사용 | `pool` |
|
||
| `outbox.pending.size` | 기존 이름/상태 migration | bounded `status` |
|
||
| `db.transaction.duration` | 신규 registry 후 사용 | `policy`, `outcome` |
|
||
| `db.transaction.retry` | 신규 registry 후 사용 | `policy`, `reason` |
|
||
| `db.transaction.indeterminate` | 신규 registry 후 사용 | `policy`, `phase` |
|
||
| `db.query.rows` | 신규 registry 후 사용 | bounded query catalog/class, `route` |
|
||
| `db.query.timeout` | 신규 registry 후 사용 | bounded query catalog/class, `route`, `cause` |
|
||
| `db.optimistic.conflict` | 신규 registry 후 사용 | bounded aggregate catalog ID |
|
||
| `db.lock.timeout` | 신규 registry 후 사용 | bounded operation catalog ID |
|
||
| `db.replica.fallback` | 신규 registry 후 사용 | bounded query catalog ID, `reason` |
|
||
| `db.replica.lag` | 신규 registry 후 사용 | configured replica logical ID |
|
||
| `db.migration.duration` | 신규 registry 후 사용 | `mode`, `outcome`, bounded schema epoch bucket |
|
||
| `db.idempotency.owner_mismatch` | 신규 registry 후 사용 | bounded operation catalog ID |
|
||
| `db.inbox.redelivery` | 신규 registry 후 사용 | bounded handler catalog ID |
|
||
|
||
새 metric/tag/allowed value는 `docs/registries/metrics.yaml`, instrumentation, cardinality contract,
|
||
alert mapping을 같은 change에서 추가한다. alias/deprecation migration 없이
|
||
`db.operation.duration`, `db.pool.*`, `db.outbox.pending` 같은 중복 이름을 만들지 않는다.
|
||
exact migration version은 계속 증가하므로 metric tag가 아니라 structured event/evidence
|
||
artifact에 둔다.
|
||
|
||
`operationCatalogId`/`queryCatalogId`는 startup에 등록된 작은 allowlist이고,
|
||
reconciliation용 per-request `operationInstanceId`/`OperationId`와 다른 타입과 이름을 사용한다.
|
||
instance ID는 metric tag에 절대 넣지 않는다.
|
||
|
||
금지 tag:
|
||
|
||
- SQL text;
|
||
- table/column/constraint raw name;
|
||
- entity ID;
|
||
- tenant/user/client ID;
|
||
- idempotency key;
|
||
- message/event ID;
|
||
- operation instance/reconciliation ID;
|
||
- JDBC URL/host.
|
||
|
||
### 29.2 trace
|
||
|
||
span 예:
|
||
|
||
```text
|
||
db.transaction
|
||
db.query
|
||
db.outbox.claim
|
||
db.outbox.complete
|
||
db.idempotency.claim
|
||
db.inbox.handle
|
||
db.migration.validate
|
||
```
|
||
|
||
attribute:
|
||
|
||
- bounded semantic operation/query catalog ID;
|
||
- policy;
|
||
- route;
|
||
- attempt;
|
||
- outcome;
|
||
- row-count bucket;
|
||
- timeout bucket.
|
||
|
||
OpenTelemetry의 database semantic convention을 사용하되 parameter와 full statement recording은
|
||
privacy 정책으로 제한한다.
|
||
|
||
### 29.3 structured log
|
||
|
||
주요 event:
|
||
|
||
- startup capability descriptor;
|
||
- schema incompatibility;
|
||
- role mismatch;
|
||
- pool exhaustion;
|
||
- commit indeterminate;
|
||
- retry exhausted;
|
||
- slow query threshold;
|
||
- owner mismatch;
|
||
- outbox DEAD/head blocked;
|
||
- replica lag/fallback;
|
||
- migration failure;
|
||
- shutdown drain timeout.
|
||
|
||
동일 장애의 stack trace/log storm을 sampling/rate limit한다. client-visible correlation ID로
|
||
server event를 찾을 수 있게 한다.
|
||
|
||
### 29.4 alert/SLO
|
||
|
||
alert 후보:
|
||
|
||
- pool pending/acquire p95/p99와 timeout;
|
||
- transaction/query error ratio;
|
||
- commit-indeterminate > 0;
|
||
- deadlock/serialization retry 증가;
|
||
- outbox oldest pending age/dead count;
|
||
- idempotency owner mismatch;
|
||
- replica lag over policy;
|
||
- schema readiness failure;
|
||
- migration duration/failure;
|
||
- disk/WAL/connection saturation은 platform DB alert와 연결.
|
||
|
||
경고 threshold는 load/capacity evidence에서 정한다. template 숫자를 production SLO로
|
||
고정하지 않는다.
|
||
|
||
## 30. Lifecycle, HA, backup과 disaster recovery
|
||
|
||
### 30.1 HA 의미
|
||
|
||
client가 reconnect했다는 사실은:
|
||
|
||
- 이전 transaction rollback;
|
||
- commit 여부;
|
||
- replica 최신성;
|
||
- prepared statement/session state 보존
|
||
|
||
을 뜻하지 않는다. failover 후 새 connection role/schema/timeout/tenant initialization을 다시
|
||
검증한다.
|
||
|
||
### 30.2 primary failover
|
||
|
||
검증 scenario:
|
||
|
||
- idle connection 중 failover;
|
||
- statement 실행 중;
|
||
- commit request 직전/중/직후;
|
||
- pool의 stale connection;
|
||
- DNS/endpoint 갱신;
|
||
- new primary role probe;
|
||
- replica route topology 변경;
|
||
- in-flight owner lease/outbox claim.
|
||
|
||
commit 중 failover는 `COMMIT_INDETERMINATE`를 만들 수 있어 stable operation reconciliation이
|
||
필수다.
|
||
|
||
새 primary가 writable하다는 사실만으로 이전 authority의 acknowledged write가 존재한다고
|
||
추정하지 않는다. provider RPO/durability와 authority timeline을 확인하고 RYW marker 및
|
||
operation ledger를 reconcile한다. 조건을 충족하지 못하면 write가 사라지지 않았다는 보장이나
|
||
RYW를 광고하지 않고 typed unavailable/indeterminate를 유지한다.
|
||
|
||
### 30.3 backup
|
||
|
||
R2 운영 문서는 다음을 명시한다.
|
||
|
||
- backup 방식과 schedule;
|
||
- RPO/RTO;
|
||
- encryption/key ownership;
|
||
- retention/legal deletion;
|
||
- WAL/PITR 여부;
|
||
- schema/migration artifact 보관;
|
||
- object-storage response reference 등 외부 payload와의 일관성;
|
||
- restore target PostgreSQL version.
|
||
|
||
backup job 성공만으로 복구 가능성을 주장하지 않는다.
|
||
|
||
### 30.4 restore rehearsal
|
||
|
||
R3 gate:
|
||
|
||
```text
|
||
restore isolated environment
|
||
-> role/search_path/extension 확인
|
||
-> Flyway validate
|
||
-> application N/N-1 compatibility smoke
|
||
-> integrity/reconciliation query
|
||
-> outbox/idempotency/inbox state 확인
|
||
-> representative query plan
|
||
-> measured RPO/RTO 기록
|
||
```
|
||
|
||
outbox terminal state와 external broker delivery, object-storage response reference는
|
||
cross-store reconciliation이 필요하다.
|
||
|
||
### 30.5 maintenance
|
||
|
||
- vacuum/analyze/autovacuum visibility;
|
||
- long transaction;
|
||
- idle-in-transaction;
|
||
- table/index bloat;
|
||
- unused/duplicate index;
|
||
- sequence/ID capacity;
|
||
- partition lifecycle가 있다면 attach/detach;
|
||
- transaction ID age;
|
||
- schema lock wait
|
||
|
||
를 platform DBA/managed-service observability와 연결한다. application이 DB maintenance engine을
|
||
재구현하지 않는다.
|
||
|
||
### 30.6 PgBouncer/proxy optional card
|
||
|
||
도입 시 별도 qualification:
|
||
|
||
- session/transaction pooling mode;
|
||
- prepared statement compatibility;
|
||
- SET LOCAL과 session state;
|
||
- server reset query;
|
||
- primary/replica endpoint;
|
||
- TLS 양 구간;
|
||
- auth/secret rotation;
|
||
- pool multiplication;
|
||
- failover;
|
||
- metrics/health.
|
||
|
||
proxy가 있다고 application pool/admission이 불필요한 것은 아니다.
|
||
|
||
## 31. Test, CI와 evidence design
|
||
|
||
### 31.1 unit test
|
||
|
||
application:
|
||
|
||
- repository/query fake로 use case transaction intent;
|
||
- policy selection;
|
||
- retry eligibility;
|
||
- commit-indeterminate reconciliation;
|
||
- cursor/filter fingerprint;
|
||
- idempotency/outbox/inbox state transition.
|
||
|
||
JPA leaf:
|
||
|
||
- mapper round-trip/invariant failure;
|
||
- SQLState/constraint mapping duplicate fail-fast;
|
||
- exception cause-chain/phase classification;
|
||
- timeout calculation/rounding;
|
||
- settings validation;
|
||
- route context/nested rule;
|
||
- claim/complete affected-row handling;
|
||
- sensitive log/metric tag guard.
|
||
|
||
### 31.2 JPA slice/integration
|
||
|
||
H2를 PostgreSQL correctness evidence로 사용하지 않는다. 빠른 mapper/repository wiring test에
|
||
쓸 수 있어도 다음은 real PostgreSQL에서만 검증한다.
|
||
|
||
- native SQL;
|
||
- UUID/json/time semantics;
|
||
- isolation/lock;
|
||
- constraint name/SQLState;
|
||
- `SKIP LOCKED`;
|
||
- concurrent index/migration;
|
||
- query plan.
|
||
|
||
### 31.3 real PostgreSQL task
|
||
|
||
owner leaf 또는 명시적인 qualification source set에 다음 canonical task를 만든다. 이 목록은
|
||
아래 card registry의 사람이 읽기 위한 projection이며 §34.1 표와 exact match해야 한다.
|
||
|
||
```text
|
||
:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest
|
||
:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest
|
||
:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest
|
||
:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest
|
||
:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest
|
||
:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest
|
||
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
|
||
```
|
||
|
||
`postgresqlConcurrencyTest`, `postgresqlQueryPlanTest` 같은 helper suite를 추가할 수 있지만,
|
||
그 suite는 위 canonical task의 `dependsOn`/test-result input으로 명시적으로 매핑한다. helper
|
||
이름 자체를 readiness manifest의 producer로 사용하지 않는다.
|
||
|
||
card/evidence SSOT는 구현 시 `src/config/jpa/readiness-cards.yaml`로 만들고 Gradle,
|
||
capability descriptor, evidence writer가 함께 읽는다. target registry shape는 다음과 같다.
|
||
|
||
```yaml
|
||
schema-version: 1
|
||
legacy-adoption:
|
||
state: transition-only
|
||
location: "db/migration/postgresql"
|
||
history-table: "flyway_schema_history"
|
||
immutable-applied-versions: [1, 3, 4, 5]
|
||
allowed-origin: LEGACY_ADOPTED
|
||
cards:
|
||
jpa-observability-lifecycle:
|
||
state: selected
|
||
schema-stream: none
|
||
prerequisites: []
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest"
|
||
required-evidence: [real-postgresql, lifecycle, observability, no-skip]
|
||
jpa-security-baseline:
|
||
state: selected
|
||
schema-stream: none
|
||
prerequisites: [jpa-observability-lifecycle]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest"
|
||
support-tasks:
|
||
- ":adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety"
|
||
- ":adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures"
|
||
required-evidence: [real-postgresql, tls, roles, namespace, redaction, no-skip]
|
||
jpa-flyway-migration:
|
||
state: selected
|
||
schema-stream: owned
|
||
prerequisites: [jpa-observability-lifecycle, jpa-security-baseline]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest"
|
||
required-evidence: [real-postgresql, migration, rolling-compatibility, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/core"
|
||
history-table: "flyway_jpa_core_history"
|
||
required-core-epoch: 0
|
||
feature-revision: 1
|
||
lifecycle-evidence: [fresh, legacy-adoption, interrupted-recovery]
|
||
jpa-transaction-runtime:
|
||
state: selected
|
||
schema-stream: none
|
||
prerequisites: [jpa-observability-lifecycle, jpa-security-baseline]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest"
|
||
required-evidence: [real-postgresql, concurrency, fault, no-skip]
|
||
jpa-aggregate-store:
|
||
state: selected
|
||
schema-stream: contributes-to-core
|
||
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest"
|
||
required-evidence: [real-postgresql, mapping, optimistic-conflict, no-skip]
|
||
jpa-query-model:
|
||
state: selected
|
||
schema-stream: contributes-to-core
|
||
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest"
|
||
required-evidence: [real-postgresql, query-contract, query-plan, no-skip]
|
||
jpa-primary-foundation:
|
||
state: selected
|
||
schema-stream: none
|
||
prerequisites:
|
||
- jpa-observability-lifecycle
|
||
- jpa-security-baseline
|
||
- jpa-flyway-migration
|
||
- jpa-transaction-runtime
|
||
- jpa-aggregate-store
|
||
- jpa-query-model
|
||
readiness-task: ":adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence"
|
||
support-tasks:
|
||
- ":adapter:outbound:persistence-jpa:test"
|
||
- ":app-bootstrap:test"
|
||
- ":verifyCleanArchitectureDependencies"
|
||
- ":verifyEnvKeys"
|
||
- ":verifyPublicPathSnapshot"
|
||
required-evidence: [architecture, configuration, base-card-manifests, no-skip]
|
||
jpa-idempotency-owner-safe-v2:
|
||
state: not-implemented
|
||
schema-stream: owned
|
||
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest"
|
||
required-evidence: [real-postgresql, concurrency, fault, migration, stream-lifecycle, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/idempotency"
|
||
history-table: "flyway_jpa_idempotency_history"
|
||
required-core-epoch: 1
|
||
feature-revision: 2
|
||
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
|
||
jpa-outbox-storage-v2:
|
||
state: not-implemented
|
||
schema-stream: owned
|
||
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest"
|
||
dispatch-modes: [polling, cdc]
|
||
required-evidence: [real-postgresql, same-resource, partition-uniqueness, publication-authority-fence, legacy-writer-rejection, migration, stream-lifecycle, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/outbox-storage"
|
||
history-table: "flyway_jpa_outbox_storage_history"
|
||
required-core-epoch: 1
|
||
feature-revision: 2
|
||
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
|
||
jpa-outbox-polling-delivery-v2:
|
||
state: not-implemented
|
||
schema-stream: owned
|
||
prerequisites:
|
||
- jpa-outbox-storage-v2
|
||
- jpa-transaction-runtime
|
||
- jpa-flyway-migration
|
||
- jpa-observability-lifecycle
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest"
|
||
dispatch-modes: [polling]
|
||
required-evidence: [real-postgresql, concurrency, publish-fault, ordering, migration, stream-lifecycle, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/outbox-polling"
|
||
history-table: "flyway_jpa_outbox_polling_history"
|
||
required-core-epoch: 1
|
||
feature-revision: 2
|
||
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
|
||
jpa-outbox-cdc-retention-v1:
|
||
state: not-implemented
|
||
schema-stream: none
|
||
prerequisites: [jpa-outbox-storage-v2, jpa-observability-lifecycle]
|
||
external-prerequisites:
|
||
- registry: "src/config/messaging/readiness-cards.yaml"
|
||
card-id: "messaging-cdc-dispatch.v1"
|
||
minimum-readiness: R2
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest"
|
||
dispatch-modes: [cdc]
|
||
required-evidence:
|
||
- real-postgresql
|
||
- connector-checkpoint-high-watermark
|
||
- outage-restart
|
||
- replay-retention
|
||
- delete-tombstone-filtering
|
||
- mode-transition
|
||
- no-skip
|
||
jpa-inbox-same-store-v1:
|
||
state: not-implemented
|
||
schema-stream: owned
|
||
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest"
|
||
required-evidence: [real-postgresql, redelivery, concurrency, migration, stream-lifecycle, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/inbox"
|
||
history-table: "flyway_jpa_inbox_history"
|
||
required-core-epoch: 1
|
||
feature-revision: 1
|
||
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
|
||
jpa-primary-replica:
|
||
state: not-implemented
|
||
schema-stream: none
|
||
prerequisites:
|
||
- jpa-transaction-runtime
|
||
- jpa-query-model
|
||
- jpa-flyway-migration
|
||
- jpa-observability-lifecycle
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest"
|
||
required-evidence: [real-postgresql, replica, lag, failover, no-skip]
|
||
jpa-tenant-discriminator-rls:
|
||
state: not-implemented
|
||
schema-stream: owned
|
||
prerequisites: [jpa-primary-foundation]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest"
|
||
required-evidence: [real-postgresql, tenant-isolation, rls, migration, stream-lifecycle, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/tenant"
|
||
history-table: "flyway_jpa_tenant_history"
|
||
required-core-epoch: 1
|
||
feature-revision: 1
|
||
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
|
||
jpa-jdbc-efficiency-coordination:
|
||
state: not-implemented
|
||
schema-stream: owned
|
||
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
|
||
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest"
|
||
required-evidence: [real-postgresql, contention, owner-safety, migration, stream-lifecycle, no-skip]
|
||
migration:
|
||
location: "db/migration/jpa/coordination"
|
||
history-table: "flyway_jpa_coordination_history"
|
||
required-core-epoch: 1
|
||
feature-revision: 2
|
||
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
|
||
```
|
||
|
||
`state`는 `selected | implemented-candidate | not-implemented`만 허용한다. registry loader는
|
||
unknown/missing ID, alias, duplicate task, cycle, selected card의 non-selected prerequisite,
|
||
존재하지 않는 selected readiness/support task를 fail-closed한다. schema-bearing card의
|
||
`migration` 누락, duplicate location/history table, invalid core epoch/revision, lifecycle
|
||
evidence 누락도 실패한다. Flyway orchestrator/startup validator/evidence writer는 같은
|
||
`migration` node를 읽는다. `schema-stream=owned`는 migration node가 필수,
|
||
`none`은 금지, `contributes-to-core`는 `jpa-flyway-migration` core manifest에 exact
|
||
resource/checksum contribution이 필수다. namespaced external prerequisite는 지정 registry의 exact card ID,
|
||
minimum readiness와 immutable manifest ID를 composition/release 시 검증하며 내부 card로
|
||
조용히 대체하지 않는다. 이것은 bootstrap/release evidence edge이지 JPA leaf에서 messaging
|
||
leaf로 향하는 Gradle/project dependency가 아니다. readiness task는 선언된 support task와
|
||
required evidence producer를 `dependsOn`으로 연결한다. registry key, §34 heading, descriptor
|
||
`cardReadiness` key, test `card-<id>` tag와 evidence `cardId`는 byte-for-byte 같아야 한다.
|
||
release에서 CLI property로 selection/DAG를 덮어쓰지 않는다. §4.2와 §34 표는 이 registry의
|
||
사람이 읽기 위한 projection이며 독립 SSOT가 아니다.
|
||
|
||
outbox selection compiler는 `dispatch-mode=disabled`면 세 outbox card를 모두 non-selected,
|
||
`polling`이면 storage+polling만 selected, `cdc`면 storage+CDC만 selected로 만든다. CDC는
|
||
namespaced messaging prerequisite가 없거나 R2 미만이면 fail-closed한다. polling과 CDC card를
|
||
동시에 selected로 만든 registry/release assertion은 거절한다.
|
||
|
||
여기서 `selected`는 target release가 반드시 검증해야 한다는 뜻이지 R2를 미리 부여한다는
|
||
뜻이 아니다. registry/task가 아직 없는 현재 repository readiness는 §3의 R0/R1 판정을
|
||
유지한다. implementation이 registry를 추가한 순간 selected task가 없거나 실패하면 release가
|
||
fail-closed해야 한다.
|
||
|
||
CI R2 lane에서는 Docker/Testcontainers/service가 없으면 skip하지 않고 실패한다. developer
|
||
local focused test는 환경 사유로 별도 task를 선택할 수 있지만 결과를 R2로 오인하지 않는다.
|
||
|
||
### 31.4 transaction/concurrency matrix
|
||
|
||
최소 scenario:
|
||
|
||
- two writers optimistic conflict;
|
||
- unique check race;
|
||
- foreign/check/not-null constraint mapping;
|
||
- `READ_COMMITTED` non-repeatable observation documented;
|
||
- `REPEATABLE_READ` snapshot;
|
||
- `SERIALIZABLE` `40001` whole retry;
|
||
- deterministic deadlock and `40P01`;
|
||
- pessimistic lock timeout;
|
||
- statement timeout, explicit cancel, idle transaction timeout, `40003` unknown 분류;
|
||
- connection acquire exhaustion;
|
||
- `CallBudget`/Hikari/Spring timeout `999/1000/1001ms` boundary;
|
||
- Hikari가 `connectionTimeout` 가까이 기다린 뒤에도 first statement/total budget이
|
||
overshoot되지 않는 real-DB test;
|
||
- nested REQUIRED route/timeout;
|
||
- 모든 outer가 inner를 요구하는 bounded `REQUIRES_NEW` reserve/deadlock barrier;
|
||
- rollback-only propagation;
|
||
- flush failure와 confirmed rollback;
|
||
- commit request 뒤 ACK 유실;
|
||
- legacy root write의 operation-ID 없는 indeterminate가 자동 replay되지 않고
|
||
`INVOCATION_UNCORRELATED`로 관측됨;
|
||
- commit ACK 뒤 `afterCommit`/cleanup failure;
|
||
- `afterCompletion(ROLLED_BACK/COMMITTED/UNKNOWN)` outcome.
|
||
|
||
### 31.5 idempotency/outbox/inbox concurrency
|
||
|
||
- same scope concurrent claim one owner;
|
||
- expired `CLAIMED` takeover와 expired `EXECUTING` recovery-required;
|
||
- stale owner renew/complete/release no mutation;
|
||
- owner/attempt/operation/state-revision conflict;
|
||
- lease가 A business transaction 안에서 만료되는 동안 B takeover barrier와 business row
|
||
exactly-once mutation;
|
||
- request mismatch;
|
||
- bounded inline response compatibility; object-reference profile은 별도 card 전까지 R2 제외;
|
||
- crash before/after business commit;
|
||
- outbox multi-worker disjoint claim;
|
||
- stale claim reaper;
|
||
- late old owner completion;
|
||
- same aggregate concurrent writer와 `(aggregate_version, event_ordinal)` ordering;
|
||
- adjacent range partition에 같은 event ID 또는 aggregate tuple을 넣는 race와 identity guard
|
||
global uniqueness;
|
||
- 한 transition의 multiple event ID/ordinal/digest가 retry 간 stable;
|
||
- freeze 직전/도중 V1 append를 멈춘 barrier에서 legacy trigger의 `FOR SHARE` 때문에 cutover
|
||
`FOR UPDATE`가 추월하지 못하고, V1 commit 뒤 pending 재검증으로 cutover가 중단됨;
|
||
- cutover 후 paused/reconnected old writer의 V1 insert/status mutation이 ACL/trigger에서
|
||
거절되고 aggregate business transaction 전체가 rollback됨;
|
||
- V2 writer의 stale/future publication epoch 또는 authority mismatch가 business mutation과
|
||
함께 rollback되고, control update/legacy revoke/sentinel insert 중간 실패도 원자적으로
|
||
이전 authority를 보존함;
|
||
- fresh polling/CDC initialization이 epoch 1 control과 matching `GENESIS_FRESH` sentinel을
|
||
같은 transaction에 만들고, control-only/sentinel-only/epoch·authority·manifest mismatch
|
||
상태에서 startup과 dispatcher를 거절함;
|
||
- polling/CDC mutual exclusion과 N/N+1 claim;
|
||
- CDC ordered destination의 stable `partition_key`/broker key mapping;
|
||
- CDC closed range partition의 connector checkpoint/high-watermark/replay-retention cleanup;
|
||
- connector outage/restart/snapshot cutover 동안 cleanup fail-closed와 delete/tombstone filtering;
|
||
- polling↔CDC mode 전환 backlog/offset/rollback runbook;
|
||
- DEAD head operator policy;
|
||
- publish success/DB ack loss duplicate;
|
||
- inbox redelivery before/after commit;
|
||
- polling delivery/inbox retention은 terminal timestamp, CDC retention은 checkpoint proof를 사용.
|
||
|
||
### 31.6 query test
|
||
|
||
- projection mapping;
|
||
- empty/max filter;
|
||
- max page/IN bound;
|
||
- stable keyset tie;
|
||
- cursor tamper/mismatch/version;
|
||
- N+1 statement upper bound;
|
||
- collection fetch/paging guard;
|
||
- count correctness;
|
||
- representative plan invariant;
|
||
- row/byte/time budget;
|
||
- slow-query recorder redaction.
|
||
|
||
### 31.7 migration compatibility
|
||
|
||
matrix:
|
||
|
||
```text
|
||
schema S + application N-1
|
||
schema S+1 + application N-1
|
||
schema S+1 + application N
|
||
schema S+2 + application N during contract gate
|
||
```
|
||
|
||
scenario:
|
||
|
||
- fresh migrate;
|
||
- migrate from every supported production baseline;
|
||
- checksum validate;
|
||
- accepted schema epoch/feature revision matrix와 startup negative cell;
|
||
- checksum, epoch, required object failure의 typed 구분;
|
||
- repeatable migration if any;
|
||
- concurrent startup/external job;
|
||
- failed nontransactional index and recovery;
|
||
- old application DML 중 DDL lock contention, finite timeout, forward recovery;
|
||
- transactional/nontransactional timeout 적용과 session reset;
|
||
- bridge/backfill restart;
|
||
- exact current V1/V3/V4/V5 history/object snapshot의 controlled adoption과 checksum/shape drift
|
||
rejection;
|
||
- 실제 V3 일반 `outbox_event` snapshot을 보존한 상태에서 별도
|
||
`outbox_event_identity_v2`/`outbox_event_log_v2` 생성, V1-only drain,
|
||
matched `LEGACY_SHADOW` reconciliation, DB publication control/legacy mutation guard,
|
||
pre-cutover rollback과 forward-only cutover;
|
||
- legacy/target history dual-authority 금지, explicit version-0 baseline audit와 old migrator
|
||
retirement;
|
||
- independent optional Flyway stream의 fresh-disabled/first-enable/disable/re-enable lifecycle;
|
||
- outbox fresh polling/CDC stream의 epoch 1 control + origin별 genesis sentinel atomicity와
|
||
control/sentinel partial state rejection;
|
||
- optional stream history/checksum/core-epoch prerequisite와 interrupted migration recovery;
|
||
- old/new enum/column writes;
|
||
- downgrade binary within declared window;
|
||
- contract after old usage zero.
|
||
|
||
### 31.8 failure/HA
|
||
|
||
- PostgreSQL unavailable before begin;
|
||
- server terminates connection during statement;
|
||
- proxy drops response during commit;
|
||
- delayed original commit와 same operation ledger replay arbitration;
|
||
- primary failover 전/중/후 authority timeline, RPO, reconciliation;
|
||
- replica unavailable/lag/role change와 qualification generation invalidation;
|
||
- multi-replica에서 qualified backend와 borrowed backend mismatch;
|
||
- bounded lag TTL 경계, query 직전 expiry, multi-statement 요청 거절;
|
||
- connect/begin failure, mid-query disconnect, 일부 row 소비 뒤 fallback 금지;
|
||
- RYW marker와 acknowledged-write loss 가능 failover;
|
||
- secret/certificate rotation;
|
||
- pool close during quiesce;
|
||
- application kill after claim/publish/commit points.
|
||
|
||
fault injection이 실제 commit timing을 완전히 결정하지 못하면 evidence 한계를 기록하고
|
||
operation-ID reconciliation outcome을 검증한다.
|
||
|
||
### 31.9 security
|
||
|
||
`postgresqlSecurityBaselineIntegrationTest`:
|
||
|
||
- TLS hostname/certificate failure;
|
||
- wrong runtime role;
|
||
- schema/search_path/function/table shadow spoof;
|
||
- SQL identifier allowlist;
|
||
- log/trace/metric parameter redaction;
|
||
- secret absence/rotation;
|
||
- lower-environment fixture에 production PII 없음.
|
||
|
||
이 readiness task는 `verifyJpaSqlConstructionSafety`와 `verifyJpaSecurityFixtures`를
|
||
`dependsOn`한다. 첫 task는 native SQL concatenation, identifier/function/schema qualification,
|
||
bind/allowlist architecture rule을 검사한다. 둘째 task는 fixture provenance/PII denylist와
|
||
sensitive-output negative corpus를 검사한다. test/task count 0, skipped/aborted, 결과 파일 누락은
|
||
security manifest 생성 실패다.
|
||
|
||
optional `postgresqlTenantRlsIntegrationTest`:
|
||
|
||
- cross-tenant query/native/bulk/maintenance;
|
||
- RLS missing context/owner/superuser/`BYPASSRLS` bypass;
|
||
- pool reuse 뒤 tenant context 누출;
|
||
- tenant-scoped backup/restore/export/reaper.
|
||
|
||
tenant/RLS test는 optional profile이 꺼진 base task에서 skip하지 않고 독립 card task에서만
|
||
실행한다.
|
||
|
||
### 31.10 performance/capacity
|
||
|
||
R2 performance evidence:
|
||
|
||
- target-like row cardinality와 data skew;
|
||
- pool sizes와 concurrent workload;
|
||
- p50/p95/p99 acquire/query/transaction latency;
|
||
- throughput;
|
||
- connection saturation;
|
||
- batch/fetch sizes;
|
||
- index/storage/write amplification;
|
||
- long transaction/vacuum impact;
|
||
- replica lag under write load;
|
||
- outbox backlog catch-up;
|
||
- memory/persistence-context bound.
|
||
|
||
benchmark는 correctness test를 대체하지 않는다. CI threshold는 hardware 변동을 고려해 regression
|
||
budget으로 관리하고 production SLO는 별도 environment evidence를 사용한다.
|
||
|
||
### 31.11 architecture/Gradle gate
|
||
|
||
필수:
|
||
|
||
```bash
|
||
cd src
|
||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
|
||
./gradlew \
|
||
:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest \
|
||
:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest \
|
||
:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest \
|
||
:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest \
|
||
:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest \
|
||
:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest \
|
||
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \
|
||
--console=plain
|
||
./gradlew test --console=plain
|
||
./gradlew check --console=plain
|
||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||
./gradlew verifyPublicPathSnapshot --console=plain
|
||
./gradlew verifyEnvKeys --console=plain
|
||
```
|
||
|
||
새 real-service task가 구현되기 전에는 존재하지 않는 명령을 현재 통과 증거로 쓰지 않는다.
|
||
R2 implementation plan에서 task를 추가하고 `check` 또는 명시적 CI production lane에
|
||
연결한다.
|
||
|
||
### 31.12 evidence grade
|
||
|
||
| Grade | 증거 |
|
||
| --- | --- |
|
||
| E0 | 문서/정적 추론 |
|
||
| E1 | unit/fake test |
|
||
| E2 | real single PostgreSQL integration |
|
||
| E3 | concurrency/fault/migration/plan matrix |
|
||
| E4 | target topology failover/restore/load rehearsal |
|
||
|
||
R1은 E1 이상, R2는 해당 card의 E2/E3, R3는 E4가 필요하다. 다른 module/sample의 우연한
|
||
test가 owner card의 evidence manifest를 대신하지 않는다.
|
||
|
||
manifest는 card별로 분리하고 최소 `cardId`, prerequisite card/version/manifest ID, source
|
||
revision, canonical producer Gradle task/CI job, executed test count, skipped/aborted count,
|
||
no-skip sentinel 결과, PostgreSQL image digest/managed engine version,
|
||
pgjdbc/Hibernate/Flyway version, date, topology와 artifact location을 기록한다.
|
||
|
||
schema-bearing card는 migration location/history table/core epoch/feature revision/stream lifecycle
|
||
evidence ID도 기록한다. outbox card는 `dispatchMode`를 필수로 기록하고 CDC면 connector/plugin
|
||
version, source/destination topology, external `messaging-cdc-dispatch.v1` manifest ID,
|
||
checkpoint/high-watermark와 cleanup evidence ID를 추가한다. polling evidence를 CDC로, storage
|
||
evidence를 delivery로 재사용하지 않는다. idempotency/outbox/inbox/replica evidence를 하나의
|
||
“JPA integration passed” 행으로 합치지 않는다. `cardReadiness` descriptor는 이 immutable
|
||
manifest ID를 가리킬 때만 R2를 노출한다.
|
||
|
||
## 32. Gradle, dependency와 split trigger
|
||
|
||
### 32.1 registry
|
||
|
||
`src/config/architecture/modules.json`이 source path, Gradle path, production dependency edge의
|
||
SSOT다. 설계 구현 중 project edge가 필요해 보이면 먼저 registry와 architecture 의미를
|
||
검토한다.
|
||
|
||
### 32.2 production dependency
|
||
|
||
JPA leaf가 소유할 수 있는 dependency:
|
||
|
||
- Spring Data JPA/Hibernate;
|
||
- Spring JDBC/transaction integration;
|
||
- Hikari runtime integration;
|
||
- Flyway core와 PostgreSQL database support;
|
||
- pgjdbc;
|
||
- PostgreSQL-specific test tooling;
|
||
- application/shared/domain project edge는 registry 허용 범위 안.
|
||
|
||
금지:
|
||
|
||
- inbound-web;
|
||
- app-bootstrap;
|
||
- sample-portfolio;
|
||
- messaging/object-storage/fileserver adapter;
|
||
- Redis/Mongo provider SDK;
|
||
- domain/application에 대한 역방향 framework leakage.
|
||
|
||
idempotency response object storage seam은 application/provider-neutral port로 호출하되 JPA leaf가
|
||
object-storage adapter를 직접 의존하지 않는다.
|
||
|
||
### 32.3 Testcontainers
|
||
|
||
Testcontainers/PostgreSQL container dependency는 test scope에 둔다. image version/digest와
|
||
reuse/parallelism을 CI 문서에 고정한다. Docker가 없을 때 assumption skip하는 task와 R2
|
||
required task를 분리한다.
|
||
|
||
### 32.4 leaf split trigger
|
||
|
||
다음 중 실제 요구가 생기면 `persistence-jpa`와 `persistence-postgresql` split을 검토한다.
|
||
|
||
- 두 번째 RDBMS를 같은 template에서 first-class 지원;
|
||
- PostgreSQL SDK/Flyway release와 vendor-neutral JPA release 독립 필요;
|
||
- 보안/라이선스/deployment boundary;
|
||
- vendor code가 공통 code보다 커져 review/ownership이 분리;
|
||
- application artifact가 JPA만 포함하고 PostgreSQL을 배제해야 함.
|
||
|
||
split 전:
|
||
|
||
- registry leaf 수와 edge;
|
||
- migration resource ownership;
|
||
- entity/repository scan;
|
||
- transaction manager/datasource composition;
|
||
- test fixture;
|
||
- bootstrap artifact
|
||
|
||
를 설계한다. package 분리가 선행 seam이며 지금은 physical split하지 않는다.
|
||
|
||
## 33. 단계별 migration
|
||
|
||
### Phase 0 — Truthful baseline과 contract freeze
|
||
|
||
- 현재 capability card/R1 evidence manifest 작성;
|
||
- production failure translator 미연결을 명시;
|
||
- SQLState mapping duplicate fail-fast;
|
||
- OSIV false와 production `ddl-auto` guard;
|
||
- Duration parser silent skip 제거 설계/test;
|
||
- existing API와 schema V1 compatibility freeze;
|
||
- real PostgreSQL task의 non-skippable lane 정의;
|
||
- outdated module/runbook name 정리;
|
||
- metrics registry와 실제 instrumentation drift 목록화.
|
||
|
||
승격: 전체 R2가 아니라 truthful R1 baseline.
|
||
|
||
### Phase 1 — Transaction/failure/deadline foundation
|
||
|
||
- named transaction policy와 additive `TransactionPort`;
|
||
- `CallBudget` intersection;
|
||
- transaction-local statement/lock timeout;
|
||
- phase-aware transaction outcome;
|
||
- commit-indeterminate/reconciliation contract;
|
||
- common repository/query translation boundary;
|
||
- constraint allowlist;
|
||
- retry disposition와 whole-transaction executor;
|
||
- pool typed settings/admission/capacity validation;
|
||
- transaction/concurrency real PostgreSQL test.
|
||
|
||
승격 상태: `jpa-transaction-runtime` R2 candidate. 이 phase의 기능 test만으로는 R2가 아니다.
|
||
§31.3 registry의 `jpa-observability-lifecycle`, `jpa-security-baseline` prerequisite와 자기
|
||
evidence gate까지 통과한 뒤에만 manifest가 R2를 선언한다.
|
||
|
||
### Phase 2 — Entity/query discipline
|
||
|
||
- entity/mapping baseline 적용;
|
||
- aggregate repository와 query port 분리;
|
||
- projection/fetch plan;
|
||
- paging bound와 keyset cursor;
|
||
- query ID catalog;
|
||
- N+1 statement budget;
|
||
- representative PostgreSQL plan tests;
|
||
- batch/persistence-context bound;
|
||
- slow query recorder/redaction.
|
||
|
||
승격 상태: `jpa-aggregate-store`, `jpa-query-model` R2 candidate. 두 card 모두
|
||
`jpa-transaction-runtime`, `jpa-flyway-migration` prerequisite와 §34.1의 자기 task가
|
||
통과하기 전에는 R2를 선언하지 않는다.
|
||
|
||
### Phase 3 — Migration/operation hardening
|
||
|
||
- external migration job production profile;
|
||
- schema compatibility validator;
|
||
- expand-contract/N/N-1 matrix;
|
||
- nontransactional index procedure;
|
||
- checkpointed backfill framework;
|
||
- migration/restore runbook;
|
||
- exact PostgreSQL/driver/ORM/Flyway version matrix;
|
||
- startup/readiness/shutdown lifecycle.
|
||
|
||
승격 상태: `jpa-observability-lifecycle`, `jpa-security-baseline`,
|
||
`jpa-flyway-migration`의 prerequisite를 포함한
|
||
base-card gate를 닫는 단계다. Phase 1–2 candidate도 §31.3 registry의 독립 manifest와
|
||
`jpa-security-baseline` gate가 모두 통과한 것만 R2로 승격한다. `jpa-primary-foundation`은 여섯 base
|
||
card가 모두 R2인 뒤에만 R2가 된다.
|
||
|
||
### Phase 4 — Owner-safe same-store reliability
|
||
|
||
- JPA idempotency V2 owner token/CAS;
|
||
- bounded inline response와 cross-store response-reference 비보장 경계;
|
||
- outbox immutable identity/partitioned storage V2 + polling delivery V2;
|
||
- aggregate version/ordinal ordering과 DEAD-head operator policy;
|
||
- same-store inbox;
|
||
- maintenance command와 owner-safe reaper;
|
||
- V1 bridge/drain/contract migrations;
|
||
- publish/commit/ack failure injection tests.
|
||
- card별 task/CI/no-skip/image digest evidence manifest.
|
||
|
||
승격 대상: `jpa-idempotency-owner-safe-v2`, `jpa-outbox-storage-v2`,
|
||
`jpa-outbox-polling-delivery-v2`, `jpa-inbox-same-store-v1` 중 §31.3 registry에서 selected이고
|
||
자기 task/manifest가 통과한 card만 R2. CDC를 선택한 release는 polling card 대신
|
||
`jpa-outbox-cdc-retention-v1`과 external `messaging-cdc-dispatch.v1`을 독립적으로 통과해야
|
||
한다.
|
||
|
||
### Phase 5 — Optional primary/replica
|
||
|
||
- separate primary/replica pools;
|
||
- explicit consistency API;
|
||
- pre-transaction route context;
|
||
- endpoint-bound lag qualification/fallback policy; conservative oracle가 없으면 bounded profile
|
||
비활성화;
|
||
- role/read-only probes;
|
||
- read-your-writes primary implementation;
|
||
- failover/lag/load tests;
|
||
- topology health/runbook.
|
||
|
||
승격 대상: `jpa-primary-replica` card R2. 이 phase 전에도 `jpa-primary-foundation` R2는
|
||
가능하다.
|
||
|
||
### Phase 6 — Optional tenant/RLS와 coordination
|
||
|
||
- tenant discriminator schema/query/unique/FK;
|
||
- cross-tenant architecture/integration tests;
|
||
- optional FORCE RLS/runtime role/context;
|
||
- JDBC lock owner-safe release와 efficiency evidence;
|
||
- fencing이 필요하면 별도 contract/provider 설계;
|
||
- retention/privacy/backup alignment.
|
||
|
||
승격 대상: `jpa-tenant-discriminator-rls`, `jpa-jdbc-efficiency-coordination` 중 활성화하고
|
||
자기 task/manifest를 통과한 card만 R2.
|
||
|
||
### Phase 7 — R3 rehearsal
|
||
|
||
- target-like load/capacity;
|
||
- primary failover;
|
||
- rolling migration;
|
||
- secret/certificate rotation;
|
||
- backup restore/PITR;
|
||
- outbox/inbox/idempotency reconciliation;
|
||
- measured RPO/RTO와 SLO;
|
||
- operator game day evidence.
|
||
|
||
## 34. 완료 기준
|
||
|
||
### 34.1 JPA primary foundation canonical gate
|
||
|
||
base card의 ID, dependency와 구현 시 추가할 non-skippable Gradle task는 다음 표와 같다.
|
||
machine-readable 정본은 §31.3의 `src/config/jpa/readiness-cards.yaml`이다.
|
||
아래 task는 현재 존재하는 통과 증거가 아니라 R2 implementation plan이 생성하고 production
|
||
CI lane에 연결해야 할 target이다.
|
||
|
||
| Card ID | Direct prerequisite | Required non-skippable task |
|
||
| --- | --- | --- |
|
||
| `jpa-observability-lifecycle` | 없음 | `:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest` |
|
||
| `jpa-security-baseline` | `jpa-observability-lifecycle` | `:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest` |
|
||
| `jpa-flyway-migration` | `jpa-observability-lifecycle`, `jpa-security-baseline` | `:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest` |
|
||
| `jpa-transaction-runtime` | `jpa-observability-lifecycle`, `jpa-security-baseline` | `:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest` |
|
||
| `jpa-aggregate-store` | `jpa-transaction-runtime`, `jpa-flyway-migration` | `:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest` |
|
||
| `jpa-query-model` | `jpa-transaction-runtime`, `jpa-flyway-migration` | `:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest` |
|
||
| `jpa-primary-foundation` | `jpa-observability-lifecycle`, `jpa-security-baseline`, `jpa-flyway-migration`, `jpa-transaction-runtime`, `jpa-aggregate-store`, `jpa-query-model` | `:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence` |
|
||
|
||
card readiness는 prerequisite보다 높을 수 없다. 각 required task는 executed test가 0이거나
|
||
skipped/aborted가 하나라도 있으면 실패해야 한다. `jpa-primary-foundation` task는 여섯
|
||
immutable base manifest와 아래 common architecture manifest를 검증하는
|
||
aggregation gate이지, 하위 test를 한 개의 불투명한 “JPA passed” 행으로 합치는 대체 증거가
|
||
아니다.
|
||
|
||
#### 34.1.1 `jpa-observability-lifecycle` R2
|
||
|
||
- [ ] typed settings가 잘못된 Duration/capacity 조합을 silent skip하지 않고 fail-fast한다.
|
||
- [ ] fixed Hikari acquisition timeout, pool capacity equation과 admission이 target
|
||
deployment에 맞게 검증된다.
|
||
- [ ] startup/readiness/shutdown과 pool drain이 real PostgreSQL에서 검증된다.
|
||
- [ ] metrics/traces/log가 cardinality-bounded이며 SQL value, credential, endpoint를
|
||
redaction한다.
|
||
- [ ] lifecycle/alert/runbook과 immutable evidence manifest가 있다.
|
||
- [ ] `postgresqlLifecycleIntegrationTest`가 zero-skip로 통과한다.
|
||
|
||
#### 34.1.2 `jpa-security-baseline` R2
|
||
|
||
- [ ] `postgresqlSecurityBaselineIntegrationTest`가 TLS hostname mismatch,
|
||
expired/untrusted certificate와 revoked credential을 real PostgreSQL에서 거절한다.
|
||
- [ ] production은 pgjdbc `sslmode=verify-full` 또는 hostname과 trust chain을 동등하게
|
||
검증하는 deployment control을 사용하고 secret/certificate rotation을 검증한다.
|
||
- [ ] migration/runtime role이 분리되고 runtime role은 least privilege이며
|
||
owner/superuser/`BYPASSRLS`가 아니다.
|
||
- [ ] runtime `search_path`는 trusted schema로 고정하고 untrusted schema와 `public`의
|
||
`CREATE`를 revoke하며 startup catalog probe와 shadow-spoof negative test가 실제 값을
|
||
검증한다.
|
||
- [ ] PostgreSQL의 default `PUBLIC TEMPORARY` privilege를 runtime에서 revoke한다. 임시
|
||
relation이 필요한 별도 profile은 모든 relation schema qualification과 `pg_temp`
|
||
shadow negative evidence 없이는 활성화하지 않는다.
|
||
- [ ] value는 bind하고 identifier/sort/function은 allowlist/schema qualification을 사용하며
|
||
native SQL string concatenation architecture test가 통과한다.
|
||
- [ ] client error, log/trace/metric/evidence artifact가 SQL value, constraint/raw server
|
||
detail, password/token/certificate/JDBC URL secret, host/database/user를 노출하지 않는다.
|
||
- [ ] lower-environment fixture에 production PII가 없고 redaction negative corpus가 통과한다.
|
||
- [ ] zero-skip task 결과와 immutable security manifest가 있다.
|
||
|
||
#### 34.1.3 `jpa-flyway-migration` R2
|
||
|
||
- [ ] production OSIV false와 Hibernate schema update 금지가 fail-fast한다.
|
||
- [ ] Flyway external/startup mode, checksum, schema epoch와 feature compatibility가
|
||
명확하다.
|
||
- [ ] expand-contract, N/N-1 rolling matrix와 finite lock/statement timeout이 검증된다.
|
||
- [ ] 현재 V1/V3/V4/V5 history/object에서 controlled adoption, explicit baseline audit와
|
||
legacy/target dual-authority rejection이 검증된다.
|
||
- [ ] nontransactional DDL과 checkpointed backfill의 recovery procedure가 있다.
|
||
- [ ] restore/forward-recovery runbook과 immutable evidence manifest가 있다.
|
||
- [ ] `postgresqlMigrationIntegrationTest`가 zero-skip로 통과한다.
|
||
|
||
#### 34.1.4 `jpa-transaction-runtime` R2
|
||
|
||
- [ ] application-owned named transaction policy와 policy/consistency admission이 있다.
|
||
- [ ] fixed Hikari acquisition timeout과 dynamic `CallBudget` pre-gate를 포함해
|
||
deadline/transaction/statement/lock timeout이 finite하고 검증된다.
|
||
- [ ] phase-aware failure translation이 모든 production persistence path에 연결된다.
|
||
- [ ] physical owner와 participating `REQUIRED` outcome이 구분되고 commit-indeterminate가
|
||
blind retry되지 않는다.
|
||
- [ ] named write policy는 stable operation ID를 요구하고 legacy facade는 별도
|
||
non-replayable/uncorrelated risk와 migration count를 노출한다.
|
||
- [ ] optimistic conflict와 allowlisted constraint가 typed 결과다.
|
||
- [ ] isolation/deadlock/timeout/pool exhaustion/commit uncertainty real-DB test가 있다.
|
||
- [ ] `postgresqlTransactionIntegrationTest`가 zero-skip로 통과하고 immutable manifest를
|
||
남긴다.
|
||
|
||
#### 34.1.5 `jpa-aggregate-store` R2
|
||
|
||
- [ ] domain aggregate와 persistence entity가 분리되고 mapper에 business policy가 없다.
|
||
- [ ] aggregate root/version/child ownership과 optimistic conflict contract가 검증된다.
|
||
- [ ] write transaction의 constraint/flush/commit failure가 공통 translator를 통과한다.
|
||
- [ ] batch와 persistence-context size가 bounded다.
|
||
- [ ] `postgresqlAggregateIntegrationTest`가 zero-skip로 통과하고 immutable manifest를
|
||
남긴다.
|
||
|
||
#### 34.1.6 `jpa-query-model` R2
|
||
|
||
- [ ] aggregate repository와 purpose-built query projection port가 분리된다.
|
||
- [ ] fetch plan, N+1 statement budget, page limit와 keyset cursor가 bounded다.
|
||
- [ ] query ID catalog와 representative PostgreSQL plan regression test가 있다.
|
||
- [ ] consistency/source marker가 실제 transaction route와 일치한다.
|
||
- [ ] `postgresqlQueryIntegrationTest`가 zero-skip로 통과하고 immutable manifest를 남긴다.
|
||
|
||
#### 34.1.7 `jpa-primary-foundation` aggregation R2
|
||
|
||
- [ ] module registry, Gradle dependency verification과 architecture test가 통과한다.
|
||
- [ ] core에 JPA/Spring/transport type이 없고 controller가 persistence type/repository를
|
||
직접 사용하지 않는다.
|
||
- [ ] `jpa-observability-lifecycle`, `jpa-security-baseline`, `jpa-flyway-migration`,
|
||
`jpa-transaction-runtime`, `jpa-aggregate-store`, `jpa-query-model`이 각각 자기 immutable
|
||
manifest로 R2이며 registry prerequisite DAG가 닫혔다.
|
||
- [ ] `verifyJpaPrimaryFoundationEvidence`가 base manifest ID, zero-skip sentinel,
|
||
`:adapter:outbound:persistence-jpa:test`, `:app-bootstrap:test`,
|
||
`:verifyCleanArchitectureDependencies`, `:verifyEnvKeys`, `:verifyPublicPathSnapshot` 결과를
|
||
검증하고 immutable bundle manifest를 남긴다.
|
||
|
||
### 34.2 `jpa-idempotency-owner-safe-v2` R2
|
||
|
||
- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`,
|
||
`jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] Redis/JPA 공통 V2 state/result contract와
|
||
owner/attempt/operation/state-revision CAS를 구현한다.
|
||
- [ ] stale owner transition이 no-op typed mismatch다.
|
||
- [ ] expired `EXECUTING`을 blind takeover하지 않고 inspect/reconcile한다.
|
||
- [ ] same-store business commit과 idempotency state의 transaction choreography가 검증된다.
|
||
- [ ] preclaim lease expiry/takeover barrier에서 business row가 정확히 한 번만 변경된다.
|
||
- [ ] DB transaction 안 object-storage I/O가 없고 R2 response는 bounded inline이다.
|
||
- [ ] fresh-disabled/enable/disable/re-enable/interrupted stream과 V1 migration/drain/contract가
|
||
rolling-safe하다.
|
||
- [ ] `postgresqlIdempotencyIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable
|
||
manifest를 남긴다.
|
||
|
||
### 34.3 `jpa-outbox-storage-v2` R2
|
||
|
||
- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`,
|
||
`jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] compact identity guard와 range-partitioned immutable event envelope가 분리된다.
|
||
- [ ] 기존 V3 `outbox_event`와 target `outbox_publication_control_v2`/
|
||
`outbox_publication_cutover_v2`/`outbox_event_identity_v2`/
|
||
`outbox_event_log_v2`의 물리 이름과 schema authority가 충돌하지 않는다.
|
||
- [ ] DB publication control이 정확히 한 active epoch/authority를 가지며 V2 append는 같은
|
||
business transaction에서 이를 `FOR SHARE`로 잠그고 row epoch/authority를 검증한다.
|
||
- [ ] fresh polling/CDC 설치가 epoch 1 control과 exact origin/schema/external manifest를 가진
|
||
`GENESIS_FRESH` sentinel을 한 migration transaction에서 만들고 partial/mismatch state를
|
||
startup에서 거절한다.
|
||
- [ ] identity guard가 global event ID와 aggregate version/ordinal uniqueness를 보장하고
|
||
partitioned event PK/FK는 partition key를 포함한다.
|
||
- [ ] adjacent partition의 duplicate event/aggregate tuple concurrency가 정확히 한 건만
|
||
성공한다.
|
||
- [ ] sequence authority가 aggregate version + deterministic ordinal이며 `MAX+1`을 쓰지 않는다.
|
||
- [ ] append는 active same-resource primary write transaction이 없으면 fail-fast한다.
|
||
- [ ] identity/event/optional delivery insert가 aggregate write와 같은 transaction이다.
|
||
- [ ] identity guard lifetime/capacity/backup/privacy와 payload partition lifecycle이 분리된다.
|
||
- [ ] V1-only row에 없는 ordering semantics를 조작해 backfill하지 않고, V1 drain,
|
||
matched-shadow reconciliation, pre-cutover rollback과 post-cutover forward recovery가
|
||
실제 V3 snapshot에서 검증된다.
|
||
- [ ] cutover의 control `FOR UPDATE`, legacy DML revoke, target authority update와 immutable
|
||
sentinel insert가 한 transaction이며 중간 실패는 이전 authority를 보존한다.
|
||
- [ ] freeze 직전/도중 paused V1 writer를 cutover가 추월하지 않고, cutover 후
|
||
paused/reconnected old writer와 stale V2 epoch가 거절되며 business row도 commit되지
|
||
않는다.
|
||
- [ ] storage V2 adoption/contract와 optional stream lifecycle이 rolling-safe하다.
|
||
- [ ] `postgresqlOutboxStorageIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable
|
||
manifest를 남긴다.
|
||
|
||
### 34.4 `jpa-outbox-polling-delivery-v2` R2
|
||
|
||
- [ ] `jpa-outbox-storage-v2`, `jpa-transaction-runtime`, `jpa-flyway-migration`,
|
||
`jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] immutable event와 mutable destination delivery가 분리된다.
|
||
- [ ] delivery의 composite PK/FK가 event retention/partition key를 포함한다.
|
||
- [ ] polling relay는 active `POLLING_V2` control/sentinel과 같은 epoch의 delivery만 claim한다.
|
||
- [ ] fresh polling genesis sentinel 누락/epoch·authority·manifest mismatch negative test가
|
||
zero-skip로 실행된다.
|
||
- [ ] outbox completion이 owner/token/status를 검증한다.
|
||
- [ ] aggregate strict-order gate와 DEAD-head operator policy가 있다.
|
||
- [ ] broker ack loss와 duplicate publish test가 있다.
|
||
- [ ] polling/CDC activation이 상호 배타적이고 destination ordering descriptor가 truthful하다.
|
||
- [ ] polling retention만 `published_at`/`dead_at` terminal timestamp를 사용하고 identity
|
||
guard를 제거하지 않는다.
|
||
- [ ] polling delivery stream의 fresh-disabled/enable/disable/re-enable/interrupted migration과
|
||
V1 drain/contract가 rolling-safe하다.
|
||
- [ ] `postgresqlOutboxPollingIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable
|
||
manifest를 남긴다.
|
||
|
||
### 34.5 `jpa-outbox-cdc-retention-v1` R2
|
||
|
||
- [ ] `jpa-outbox-storage-v2`, `jpa-observability-lifecycle`과 external
|
||
`messaging-cdc-dispatch.v1` R2 immutable manifest가 exact prerequisite다.
|
||
- [ ] CDC mode에서 delivery row/polling scheduler가 없고 DB epoch/dispatch authority가
|
||
polling과 상호 배타적이다.
|
||
- [ ] connector predicate가 active `CDC` control/sentinel epoch만 route하고
|
||
`LEGACY_SHADOW`와 stale/future epoch를 제외한다.
|
||
- [ ] fresh CDC genesis sentinel 누락/epoch·authority·external manifest mismatch negative
|
||
test가 zero-skip로 실행된다.
|
||
- [ ] ordered destination의 non-null stable `partition_key`가 connector broker key와 같다.
|
||
- [ ] closed partition의 모든 destination에 대해 connector checkpoint/high-watermark coverage,
|
||
replay retention, incident/legal hold를 검증하고 evidence가 stale/unknown이면 cleanup을
|
||
거절한다.
|
||
- [ ] cleanup delete/detach/drop이 connector event/tombstone으로 route되지 않고 identity guard는
|
||
유지된다.
|
||
- [ ] connector outage/restart, snapshot cutover, partition growth와 polling↔CDC 전환 rehearsal가
|
||
있다.
|
||
- [ ] manifest가 `dispatchMode=cdc`, connector/plugin version/topology, external manifest ID와
|
||
cleanup evidence ID를 기록한다.
|
||
- [ ] `postgresqlOutboxCdcCleanupIntegrationTest`가 zero-skip로 통과하고 exact card ID의
|
||
immutable manifest를 남긴다.
|
||
|
||
### 34.6 `jpa-inbox-same-store-v1` R2
|
||
|
||
- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`,
|
||
`jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] inbox claim/business write/completion이 같은 transaction이다.
|
||
- [ ] leased preclaim이면 business mutation 전 row lock/owner CAS를 하고 commit까지 유지한다.
|
||
- [ ] broker redelivery/commit uncertainty/takeover barrier가 business row exactly-once mutation을
|
||
검증한다.
|
||
- [ ] retention이 terminal timestamp를 사용한다.
|
||
- [ ] fresh-disabled/enable/disable/re-enable/interrupted stream과 V1 migration/drain/contract가
|
||
rolling-safe하다.
|
||
- [ ] `postgresqlInboxIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable
|
||
manifest를 남긴다.
|
||
|
||
### 34.7 `jpa-primary-replica` R2
|
||
|
||
- [ ] `jpa-transaction-runtime`, `jpa-query-model`, `jpa-flyway-migration`,
|
||
`jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] separate pool/role validation이 있다.
|
||
- [ ] application이 explicit `ReadConsistency`를 선택한다.
|
||
- [ ] existing transaction route가 downgrade되지 않는다.
|
||
- [ ] bounded staleness qualification이 endpoint/pool generation/role epoch와 실제 borrowed
|
||
backend에 결속되며, oracle이 없으면 비활성화된다.
|
||
- [ ] observed lag + monotonic elapsed + error margin upper bound와 single-statement 제한이
|
||
검증된다.
|
||
- [ ] fallback primary가 pre-transaction 또는 no-result replay-safe read로 제한되고
|
||
explicit/observable하다.
|
||
- [ ] RYW가 authority timeline/RPO와 분리되어 failover 때 reconcile/fail-closed한다.
|
||
- [ ] failover/lag/load/secret rotation test가 있다.
|
||
- [ ] replica 장애와 application readiness 의미가 profile별로 정해졌다.
|
||
- [ ] `postgresqlReplicaIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable
|
||
manifest를 남긴다.
|
||
|
||
### 34.8 `jpa-tenant-discriminator-rls` R2
|
||
|
||
- [ ] `jpa-primary-foundation` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] 모든 tenant-owned schema/query/index/unique/FK에 tenant scope가 있다.
|
||
- [ ] cross-tenant native/bulk/maintenance test가 있다.
|
||
- [ ] RLS runtime role이 owner/superuser/`BYPASSRLS`가 아니다.
|
||
- [ ] missing tenant context가 fail-closed다.
|
||
- [ ] pool session state 누출이 없다.
|
||
- [ ] backup/restore/export/reaper에도 tenant isolation이 유지된다.
|
||
- [ ] tenant stream의 fresh-disabled/enable/disable/re-enable/interrupted migration이
|
||
rolling-safe하다.
|
||
- [ ] `postgresqlTenantRlsIntegrationTest`가 zero-skip로 통과하고 exact card ID의 immutable
|
||
manifest를 남긴다.
|
||
|
||
### 34.9 `jpa-jdbc-efficiency-coordination` R2 efficiency card
|
||
|
||
- [ ] `jpa-transaction-runtime`, `jpa-flyway-migration`,
|
||
`jpa-observability-lifecycle` prerequisite가 R2이고 독립 evidence manifest가 있다.
|
||
- [ ] descriptor가 `EFFICIENCY_ONLY`이며 correctness/fencing을 주장하지 않는다.
|
||
- [ ] acquire/renew/release가 owner/lease를 검증하고 stale release가 no-op이다.
|
||
- [ ] database predicate가 scheduler/reaper correctness를 독립적으로 보장한다.
|
||
- [ ] timeout, owner crash, lease loss, multi-instance contention test와 runbook이 있다.
|
||
- [ ] correctness lock이 필요한 consumer는 fenced contract/provider 없이는 composition이
|
||
실패한다.
|
||
- [ ] coordination stream의 checksum/core epoch와
|
||
fresh-disabled/enable/disable/re-enable/interrupted migration이 검증된다.
|
||
- [ ] `postgresqlJdbcCoordinationIntegrationTest`가 zero-skip로 통과하고 exact card ID의
|
||
immutable manifest를 남긴다.
|
||
|
||
### 34.10 R3
|
||
|
||
- [ ] production-like failover/restore rehearsal가 있다.
|
||
- [ ] rolling application/schema upgrade와 rollback window가 검증된다.
|
||
- [ ] capacity/load와 SLO evidence가 있다.
|
||
- [ ] operator가 commit-indeterminate/outbox DEAD/migration failure를 실제 절차로 해결했다.
|
||
- [ ] evidence artifact의 date/version/topology가 추적된다.
|
||
|
||
## 35. 금지된 주장
|
||
|
||
다음 문구는 해당 증거가 없으면 사용하지 않는다.
|
||
|
||
- “JPA를 사용하므로 transaction-safe다.”
|
||
- “`@Transactional`이 exactly-once를 보장한다.”
|
||
- “connection error이므로 commit되지 않았다.”
|
||
- “retry했으므로 안전하다.”
|
||
- “read-only이므로 replica를 사용한다.”
|
||
- “replica가 거의 실시간이라 strong consistency다.”
|
||
- “optimistic lock이 모든 race를 막는다.”
|
||
- “`SKIP LOCKED`이 순서를 보장한다.”
|
||
- “JDBC lock이 distributed correctness lock이다.”
|
||
- “Flyway가 있으므로 zero-downtime migration이다.”
|
||
- “`ddl-auto=validate`가 rolling compatibility를 보장한다.”
|
||
- “Hikari 기본값이면 production pool sizing이 끝났다.”
|
||
- “virtual thread라 connection pool이 필요 없다.”
|
||
- “N+1은 lazy loading으로 해결된다.”
|
||
- “index가 있으므로 query가 빠르다.”
|
||
- “Testcontainers test가 skip되었지만 통과했다.”
|
||
- “outbox라서 메시지는 정확히 한 번 전달된다.”
|
||
- “idempotency key가 있으므로 command는 한 번만 실행된다.”
|
||
- “RLS를 켰으므로 tenant isolation이 완성됐다.”
|
||
- “backup이 있으므로 복구 가능하다.”
|
||
- “JPA leaf가 R2라 모든 capability card가 R2다.”
|
||
|
||
## 36. 운영 runbook 요구
|
||
|
||
최소 문서:
|
||
|
||
1. `db-startup-schema-incompatible`
|
||
- migration mode, schema version, checksum, role, safe forward fix.
|
||
2. `db-pool-exhaustion`
|
||
- active/pending/acquire latency, long transaction, capacity/admission, scale 주의.
|
||
3. `db-query-timeout`
|
||
- query ID, plan/statistics, lock vs statement, safe cancel.
|
||
4. `db-deadlock-serialization`
|
||
- SQLState, transaction policy, retry eligibility, lock order.
|
||
5. `db-commit-indeterminate`
|
||
- operation ID primary reconciliation, 절대 blind retry 금지.
|
||
6. `db-primary-failover`
|
||
- endpoint/role/pool refresh, indeterminate transaction, readiness.
|
||
7. `db-replica-lag`
|
||
- bound, fallback, traffic shedding, catch-up.
|
||
8. `db-migration-failure`
|
||
- transactional/nontransactional 구분, invalid index, forward fix.
|
||
9. `db-backfill-pause-resume`
|
||
- checkpoint, throttle, validation, contract gate.
|
||
10. `db-outbox-backlog-dead`
|
||
- oldest age, DEAD head, requeue/skip audit, duplicate risk.
|
||
11. `db-idempotency-stuck-owner`
|
||
- lease, owner mismatch, reconcile, response reference.
|
||
12. `db-inbox-redelivery`
|
||
- broker ack, DB transaction, message scope, DEAD.
|
||
13. `db-secret-certificate-rotation`
|
||
- new pool probe, drain, rollback.
|
||
14. `db-backup-restore`
|
||
- PITR target, application/schema validation, cross-store reconcile.
|
||
15. `db-jdbc-lock-timeout`
|
||
- 실제 table 이름/owner/lease와 efficiency-only 한계.
|
||
|
||
runbook의 SQL은 read-only diagnostic을 기본으로 하고 destructive mutation/requeue/repair는
|
||
precondition, expected affected rows, audit, recovery를 명시한다. 과거 migration을 수정하거나
|
||
무조건 Flyway repair하는 절차를 제공하지 않는다.
|
||
|
||
## 37. 알려진 위험과 구현 전 확인 사항
|
||
|
||
| 위험/질문 | 현재 판단 | 구현 전 필요한 증거 |
|
||
| --- | --- | --- |
|
||
| 선택한 phase-aware decorator/sentinel가 Spring lifecycle을 안정적으로 식별하는가 | §15.2 관측 지점을 정본으로 선택 | Spring transaction integration/fault test |
|
||
| `SET LOCAL` 적용이 JPA 첫 statement보다 항상 앞서는가 | 설계상 필수 | connection/transaction hook real DB test |
|
||
| Hikari와 application admission의 최적 크기 | deployment별 | target-like load/capacity test |
|
||
| replica lag source와 failover semantics | provider별 | managed service/topology contract |
|
||
| owner-safe idempotency UPSERT의 race | PostgreSQL native SQL 필요 가능 | concurrent takeover test |
|
||
| outbox aggregate strict ordering 비용 | destination별 선택 | backlog/head-of-line load test |
|
||
| sample migration location composition | customizer가 교체할 수 있음 | production/sample artifact test |
|
||
| query metric instrumentation | registry만 있고 recorder 불명확 | actual meter emission test |
|
||
| production slow query logging redaction | deferred | synthetic sensitive parameter test |
|
||
| JPA/Hibernate 7.1 upgrade plan drift | version-sensitive | ORM migration guide + full suite |
|
||
| exact PostgreSQL 16 minor/image | floating local tag | immutable CI/production version matrix |
|
||
| RLS와 connection pool state | optional, high risk | FORCE RLS/role/reset/failover test |
|
||
| PgBouncer prepared statement/SET LOCAL | topology-specific | proxy mode integration test |
|
||
|
||
이 표는 설계 결정을 다시 열어 둔 목록이 아니라 선택한 계약을 R2로 승격하기 전 확인할
|
||
implementation evidence다. 증거가 실패하면 문서의 보장을 낮추거나 별도 설계 변경을 승인받아야
|
||
하며, 구현자가 임의 대안을 선택하지 않는다. 구현 계획은 각 항목을 task와 acceptance test로
|
||
변환해야 한다.
|
||
|
||
## 38. 구현 계획 작성 시 작업 분할
|
||
|
||
실제 구현은 한 PR/commit 범위로 몰지 않는다. 권장 독립 작업:
|
||
|
||
1. baseline guard와 drift 수정;
|
||
2. transaction policy/deadline;
|
||
3. phase-aware failure translation;
|
||
4. pool typed settings/admission;
|
||
5. real PostgreSQL test source set;
|
||
6. query catalog/N+1/plan;
|
||
7. Flyway external job, legacy adoption과 optional stream compatibility;
|
||
8. idempotency V2;
|
||
9. outbox identity/partitioned storage V2;
|
||
10. polling delivery V2;
|
||
11. CDC retention과 external messaging evidence composition;
|
||
12. inbox;
|
||
13. replica;
|
||
14. tenant/RLS;
|
||
15. HA/restore evidence.
|
||
|
||
각 작업은 owner leaf의 closest `CLAUDE.md`, registry path, focused test를 다시 확인하고
|
||
test-first로 진행한다. architecture, runtime, data migration 경계가 바뀌면 별도 review를
|
||
요청한다.
|
||
|
||
## 39. Primary references
|
||
|
||
### Spring
|
||
|
||
- [Spring Framework — Programmatic Transaction Management](https://docs.spring.io/spring-framework/reference/data-access/transaction/programmatic.html)
|
||
- [Spring Data JPA 4.0 — Locking](https://docs.spring.io/spring-data/data-jpa/reference/4.0/jpa/locking.html)
|
||
- [Spring Data JPA 4.0 — Projections](https://docs.spring.io/spring-data/data-jpa/reference/4.0/repositories/projections.html)
|
||
- [Spring Data JPA — Query Methods and Scrolling](https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html)
|
||
- [Spring Data — Query Method Details](https://docs.spring.io/spring-data/data-jpa/reference/4.0/repositories/query-methods-details.html)
|
||
- [Spring Boot 4.0 — Data Access](https://docs.spring.io/spring-boot/4.0/how-to/data-access.html)
|
||
|
||
### Hibernate ORM
|
||
|
||
- [Hibernate ORM 7.1 User Guide](https://docs.hibernate.org/orm/7.1/userguide/html_single/)
|
||
- [Hibernate ORM 7.1 Migration Guide](https://docs.jboss.org/hibernate/orm/7.1/migration-guide/migration-guide.html)
|
||
|
||
### PostgreSQL
|
||
|
||
- [PostgreSQL 16 — Transaction Isolation](https://www.postgresql.org/docs/16/transaction-iso.html)
|
||
- [PostgreSQL 16 — Serialization Failure Handling](https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html)
|
||
- [PostgreSQL 16 — Explicit Locking](https://www.postgresql.org/docs/16/explicit-locking.html)
|
||
- [PostgreSQL 16 — SELECT and `SKIP LOCKED`](https://www.postgresql.org/docs/16/sql-select.html)
|
||
- [PostgreSQL 16 — Client Connection Defaults and Timeouts](https://www.postgresql.org/docs/16/runtime-config-client.html)
|
||
- [PostgreSQL 16 — Error Codes](https://www.postgresql.org/docs/16/errcodes-appendix.html)
|
||
- [PostgreSQL 16 — SET](https://www.postgresql.org/docs/16/sql-set.html)
|
||
- [PostgreSQL 16 — Hot Standby](https://www.postgresql.org/docs/16/hot-standby.html)
|
||
- [PostgreSQL 16 — High Availability, Load Balancing, and Replication](https://www.postgresql.org/docs/16/high-availability.html)
|
||
- [PostgreSQL 16 — EXPLAIN](https://www.postgresql.org/docs/16/sql-explain.html)
|
||
- [PostgreSQL 16 — Indexes](https://www.postgresql.org/docs/16/indexes.html)
|
||
- [PostgreSQL 16 — Table Partitioning](https://www.postgresql.org/docs/16/ddl-partitioning.html)
|
||
- [PostgreSQL 16 — Privileges](https://www.postgresql.org/docs/16/ddl-priv.html)
|
||
- [PostgreSQL 16 — ALTER TABLE](https://www.postgresql.org/docs/16/sql-altertable.html)
|
||
- [PostgreSQL 16 — CREATE INDEX](https://www.postgresql.org/docs/16/sql-createindex.html)
|
||
- [PostgreSQL 16 — Row Security Policies](https://www.postgresql.org/docs/16/ddl-rowsecurity.html)
|
||
- [PostgreSQL Versioning Policy](https://www.postgresql.org/support/versioning/)
|
||
|
||
### PostgreSQL JDBC
|
||
|
||
- [pgJDBC — Using the Driver, Failover and `targetServerType`](https://jdbc.postgresql.org/documentation/use/)
|
||
- [pgJDBC — SSL/TLS](https://jdbc.postgresql.org/documentation/ssl/)
|
||
|
||
### Pool
|
||
|
||
- [HikariCP 7.0.2 — Configuration](https://github.com/brettwooldridge/HikariCP/tree/HikariCP-7.0.2)
|
||
- [HikariCP — About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)
|
||
|
||
### Flyway
|
||
|
||
- [Flyway — Validate](https://documentation.red-gate.com/flyway/reference/commands/validate)
|
||
- [Flyway — Baselines](https://documentation.red-gate.com/flyway/flyway-concepts/baselines)
|
||
- [Flyway — Migrations](https://documentation.red-gate.com/fd/migrations-271585107.html)
|
||
- [Flyway — Migration Transaction Handling](https://documentation.red-gate.com/fd/migration-transaction-handling-273973399.html)
|
||
- [Flyway — `executeInTransaction`](https://documentation.red-gate.com/fd/flyway-execute-in-transaction-setting-277578997.html)
|
||
- [Flyway — PostgreSQL Database Support](https://documentation.red-gate.com/flyway/reference/database-driver-reference/postgresql-database)
|