5737 lines
228 KiB
Markdown
5737 lines
228 KiB
Markdown
# Messaging Production Capability Deep Design
|
||
|
||
- 작성일: 2026-07-28
|
||
- 상태: 상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수
|
||
- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture
|
||
- 현재 outbound leaf: `adapter-outbound-messaging`
|
||
- 미래 inbound leaf: `adapter-inbound-messaging-kafka`
|
||
- 상위 문서:
|
||
[Production Capability Platform Design](2026-07-26-production-capability-platform-design.md)
|
||
|
||
## 0. 문서 상태와 구현 상태
|
||
|
||
이 문서는 messaging 전체 수명주기를 한 번에 설계하되 구현은 단계적으로 진행하기 위한 정본이다.
|
||
여기서 messaging 전체 수명주기는 다음을 뜻한다.
|
||
|
||
```text
|
||
domain event
|
||
-> integration event
|
||
-> transactional outbox
|
||
-> polling 또는 CDC dispatch
|
||
-> acknowledgement-aware Kafka producer
|
||
-> Kafka consumer
|
||
-> inbox + application side effect
|
||
-> DLT / replay / reconciliation
|
||
```
|
||
|
||
이 문서가 검토되었다는 사실은 위 기능이 구현되었거나 production-ready라는 뜻이 아니다.
|
||
구현 상태와 향후 추가 가능 범위를 혼동하지 않도록 세 종류의 표현만 사용한다.
|
||
|
||
| 표현 | 의미 |
|
||
| --- | --- |
|
||
| `현재 구현` | 2026-07-28 repository에서 코드와 테스트로 직접 확인한 범위 |
|
||
| `최초 R2 구축 대상` | 첫 실행 계획에서 실제로 구현하고 real-service 증거를 만들 범위 |
|
||
| `후속 설계 완료 / 미구현` | 경계와 보장은 이 문서에서 결정했지만 코드·설정·증거는 아직 없는 범위 |
|
||
|
||
### 0.1 현재 구현
|
||
|
||
현재 repository에는 다음 기반이 있다.
|
||
|
||
- `application-core`의 framework-free transactional outbox append/store/publish port;
|
||
- 비즈니스 쓰기와 같은 `TransactionPort.inWrite(...)` 안에서 outbox event를 append하는 계약;
|
||
- PostgreSQL `SKIP LOCKED` 기반 claim, `PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD` 상태,
|
||
retry/backoff, timestamp 기반 aggregate FIFO gate;
|
||
- broker publish를 DB transaction 밖에서 수행하고 결과 상태만 짧은 transaction으로 갱신하는
|
||
relay use case;
|
||
- `app.messaging.broker`로 단일 `MessageBroker`를 선택하는 outbound composition;
|
||
- `KafkaSender`라는 project-supplied seam과 fake 기반 unit test;
|
||
- 비활성 시 fail-fast하는 `DisabledMessagePublisher`와
|
||
`DisabledOutboxMessagePublisher`;
|
||
- 일반 publisher의 fail-open과 durable outbox publisher의 fail-closed 구분;
|
||
- 확인된 FAILED/DEAD 전이 뒤에만 기록되는 typed `OutboxRelayFailureReport`;
|
||
- outbox backlog/lag/outcome metric과 stub runbook;
|
||
- `sample-portfolio`의 WorkLog/Poster integration-event 예시.
|
||
|
||
이 기반이 증명하지 않는 것은 다음과 같다.
|
||
|
||
- 실제 Kafka client가 존재한다는 것;
|
||
- `KafkaSender.send()` 반환이 broker acknowledgement를 뜻한다는 것;
|
||
- 현재 `PUBLISHED` 상태가 실제 broker ACK 뒤에만 기록된다는 것;
|
||
- 현재 hand-written JSON envelope가 schema-valid 또는 rolling-compatible하다는 것;
|
||
- consumer가 존재하거나 중복을 inbox로 흡수한다는 것;
|
||
- TLS/SASL, ACL, topic topology, resource bound, graceful drain이 준비되었다는 것;
|
||
- CDC mode나 polling/CDC 전환이 가능하다는 것;
|
||
- real Kafka/PostgreSQL/Kafka Connect 장애 시험을 통과했다는 것.
|
||
|
||
### 0.2 최초 R2 구축 대상
|
||
|
||
첫 R2 reference tuple은 다음 하나다.
|
||
|
||
```text
|
||
producer-provider = kafka-spring
|
||
producer-semantics = acknowledged-idempotent-v1
|
||
outbox-dispatch = postgresql-polling-v2
|
||
claim-strategy = postgresql-per-record-jit-claim-v1
|
||
wire-format = json-schema-envelope-v1
|
||
topic-management = externally-provisioned-and-validated-v1
|
||
security = sasl-ssl-scram-sha-512-v1
|
||
compression = none-v1
|
||
ordering = per-key-normal-path-sequence-detectable-v1
|
||
transaction-resource = same-postgresql-transaction-resource-v1
|
||
operator-control = authenticated-internal-web-disposition-v1
|
||
consumer = disabled
|
||
cdc = disabled
|
||
```
|
||
|
||
첫 구현은 다음 순서로 하나의 실제 경로를 만든다.
|
||
|
||
1. logical destination과 versioned event contract catalog;
|
||
2. UTF-8 JSON envelope v1과 checked-in JSON Schema;
|
||
3. immutable `outbox_event`, polling-only `outbox_delivery`, append-only attempt journal;
|
||
4. publication epoch, per-record JIT claim, claim token/valid lease, aggregate sequence,
|
||
bounded retry/attempt budget;
|
||
5. `Spring Kafka`의 `KafkaTemplate`/`ProducerFactory`를 직접 소유하는 outbound provider;
|
||
6. broker ACK를 기다리는 typed outcome;
|
||
7. TLS/SASL_SSL production profile, finite queue/timeout, readiness와 graceful shutdown;
|
||
8. application disposition use case + authenticated internal web operator control;
|
||
9. PostgreSQL + real Kafka 통합·장애·보안 evidence.
|
||
|
||
첫 R2 구축에는 inbound Kafka consumer, inbox, retry topic, DLT replay, Debezium/Kafka Connect가
|
||
들어가지 않는다. 다만 최초 wire/outbox 구조가 그 후속 기능을 갈아 끼우거나 추가할 수 있도록
|
||
설계한다.
|
||
|
||
### 0.3 후속 설계 완료 / 미구현
|
||
|
||
| Capability | 문서상 결정 | 현재 구현 |
|
||
| --- | --- | --- |
|
||
| inbound Kafka | 별도 `adapter:inbound:messaging-kafka` leaf | 없음 |
|
||
| consumer acknowledgement | application commit 뒤 `MANUAL_IMMEDIATE` | 없음 |
|
||
| inbox | `(consumerId, eventId)` unique + business write와 같은 DB transaction | 없음 |
|
||
| consumer retry | 짧고 bounded한 blocking retry가 기본 | 없음 |
|
||
| retry topic | ordering을 잃는 opt-in card | 없음 |
|
||
| DLT | DLT publish ACK 뒤 원본 offset 진행 | 없음 |
|
||
| replay | 별도 group/job, 범위·승인·audit 필수 | 없음 |
|
||
| CDC | 외부 Kafka Connect/Debezium, insert-only event source | 없음 |
|
||
| polling/CDC 전환 | 같은 production destination에서 상호 배타, 별도 cutover runbook | 없음 |
|
||
| Avro/Protobuf | schema-registry와 함께 optional serialization card | 없음 |
|
||
| Kafka transaction/EOS | DB-free Kafka consume-process-produce에만 optional | 없음 |
|
||
| 대체 broker | 동일 semantic guarantee/evidence를 만족하는 provider card로만 추가 | 없음 |
|
||
|
||
### 0.4 상태 ledger
|
||
|
||
이 표는 구현 진척의 human-readable SSOT다. 이후 구현 작업은 이 표만 갱신하고 완료 표현을
|
||
본문 여러 곳에 복제하지 않는다.
|
||
|
||
| Phase | 산출물 | 2026-07-28 상태 | 허용 표현 |
|
||
| --- | --- | --- | --- |
|
||
| P0 | current truth, design, characterization | `IMPLEMENTATION_PLAN_READY` | 설계·실행 계획 완료, 구현 미착수 |
|
||
| P1 | event contract/catalog/envelope/schema | `NOT_STARTED` | 미구현 |
|
||
| P2 | immutable event + polling delivery v2 | `NOT_STARTED` | legacy polling만 존재 |
|
||
| P3 | Spring Kafka ACK-aware producer | `NOT_STARTED` | Kafka seam R0 |
|
||
| P4 | security/observability/fault/real-service R2 evidence | `NOT_STARTED` | R2 주장 금지 |
|
||
| P5 | inbound Kafka leaf + inbox + DLT/replay | `DESIGNED_NOT_IMPLEMENTED` | 후속 설계 |
|
||
| P6 | PostgreSQL Debezium CDC + cutover | `DESIGNED_NOT_IMPLEMENTED` | 후속 설계 |
|
||
| P7 | Avro/Protobuf/EOS/대체 provider cards | `OPTIONAL_BACKLOG` | 후보 |
|
||
|
||
Phase 진행도와 capability readiness는 별도 축이다. 예를 들어 P3 코드가 존재해도 exact selected
|
||
profile이 real Kafka, 보안, fault, shutdown evidence를 통과하지 않으면 R2가 아니다.
|
||
|
||
## 1. 설계 판정
|
||
|
||
이번 설계는 provider-neutral semantic contract와 Kafka reference implementation을 분리한다.
|
||
provider-neutral이라는 말은 모든 broker의 최저 공통분모를 가진 범용
|
||
`send(topic, key, payload)` API를 만들겠다는 뜻이 아니다.
|
||
|
||
선택한 구조는 다음과 같다.
|
||
|
||
1. Application은 integration event의 의미, logical destination, identity, ordering intent,
|
||
transactional append와 consumption policy를 소유한다.
|
||
2. `adapter:outbound:messaging`은 wire contract compilation, JSON envelope, Kafka producer,
|
||
ACK outcome, producer lifecycle과 provider telemetry를 소유한다.
|
||
3. `adapter:outbound:persistence-jpa`는 same-store outbox/inbox persistence와 claim CAS를
|
||
소유한다.
|
||
4. 첫 reference provider는 outbound leaf 내부의 Spring Kafka
|
||
`DefaultKafkaProducerFactory` + `KafkaTemplate`이다.
|
||
5. 현재 `KafkaSender`는 legacy/test migration seam으로만 남길 수 있고 R2 provider로
|
||
표시하지 않는다.
|
||
6. durable publication의 성공은 broker ACK metadata가 확인된 경우에만 선언한다.
|
||
7. ACK를 기다리다 deadline/cancellation/connection loss가 발생하면 성공이나 확정 실패가 아니라
|
||
`INDETERMINATE`다.
|
||
8. 첫 serialization은 versioned UTF-8 JSON envelope와 checked-in JSON Schema다.
|
||
9. event type을 physical Kafka topic으로 직접 사용하지 않는다. logical destination을
|
||
deployment binding이 physical topic으로 컴파일한다.
|
||
10. polling outbox는 immutable event와 mutable delivery control을 분리한다.
|
||
11. CDC는 첫 R2에 포함하지 않고 같은 immutable event source를 사용하는 후속 dispatch card다.
|
||
12. consumer를 구현하기 전 inbound Kafka 전용 leaf를 registry에 추가한다.
|
||
13. 자동 publication은 bounded하므로 무조건적인 eventual delivery를 주장하지 않는다.
|
||
consumer/inbox까지 구현된 범위만 duplicate-possible delivery와 idempotent effect로 표현한다.
|
||
14. Kafka producer idempotence나 transaction을 DB와 Kafka 사이의 generic exactly-once로
|
||
표현하지 않는다.
|
||
15. 사용하지 않는 producer/consumer/CDC profile은 connection, thread, scheduler, AdminClient,
|
||
connector, schema가 0개여야 한다.
|
||
|
||
이 결정의 핵심은 “Kafka로 먼저 하나를 만든다”와 “나중에 교체 가능하게 한다”를 동시에 만족하는
|
||
것이다. 교체 가능성은 빈 SPI 하나가 아니라 stable semantic contract, explicit capability card,
|
||
exact profile validation, provider별 evidence로 확보한다.
|
||
|
||
## 2. 상위 설계와 기존 심화 설계의 관계
|
||
|
||
상위 Production Capability Platform Design은 다음을 이미 결정했다.
|
||
|
||
- transactional outbox append는 source-of-truth transaction과 함께한다;
|
||
- dispatch는 `disabled | polling | cdc`로 선택한다;
|
||
- immutable `outbox_event`와 polling-only `outbox_delivery`를 분리한다;
|
||
- physical topic은 adapter 설정이고 application event type이 아니다;
|
||
- real Kafka producer는 ACK, bounded delivery timeout, idempotence, security를 가져야 한다;
|
||
- consumer는 별도 inbound leaf, manual acknowledgement, inbox, DLT/replay를 사용한다;
|
||
- DB와 broker를 아우르는 generic exactly-once를 주장하지 않는다.
|
||
|
||
이번 문서는 그 방향을 구현자가 다시 추론하지 않도록 다음을 추가로 고정한다.
|
||
|
||
- 첫 R2 exact tuple;
|
||
- stable contract와 replaceable capability card의 경계;
|
||
- integration event identity와 versioned wire envelope;
|
||
- logical destination catalog와 physical topic binding;
|
||
- producer ACK/REJECTED/INDETERMINATE state machine;
|
||
- Kafka client retry와 relay retry의 combined amplification budget;
|
||
- immutable event/polling delivery schema와 claim token;
|
||
- polling ACK-to-DB gap, dead resolution, replay와 retention;
|
||
- future consumer/inbox/DLT/replay의 정확한 transaction과 acknowledgement 순서;
|
||
- future CDC connector, slot/offset/WAL, shadow/cutover/rollback 조건;
|
||
- configuration expected-state, readiness card와 evidence fingerprint;
|
||
- 보안, privacy, observability, test/CI/no-skip와 runbook 요구.
|
||
|
||
Redis/FileServer/HTTPClient deep design에서 재사용하는 공통 패턴은 다음이다.
|
||
|
||
- 현재 truth와 목표를 문서 앞에서 분리한다;
|
||
- semantic contract와 provider runtime을 분리한다;
|
||
- logical ID와 physical endpoint/topic을 분리한다;
|
||
- exact-one provider selection과 disabled resource-0를 사용한다;
|
||
- typed outcome으로 definite/indeterminate를 구분한다;
|
||
- finite deadline, queue, body/message size와 graceful shutdown을 계약으로 둔다;
|
||
- readiness를 capability card와 real-service evidence로 제한한다;
|
||
- optional provider/config를 구현되기 전에 존재하는 것처럼 노출하지 않는다.
|
||
|
||
그 문서에서 그대로 복사하지 않는 부분은 다음이다.
|
||
|
||
- HTTP mutation의 unknown outcome과 Kafka duplicate 가능성은 비슷하지만 동일하지 않다;
|
||
- Redis fail-open cache 정책은 durable messaging에 적용하지 않는다;
|
||
- Fileserver의 atomic rename/manifest가 Kafka acknowledgement를 대체하지 않는다;
|
||
- Kafka partition ordering을 global FIFO나 distributed lock으로 표현하지 않는다;
|
||
- Kafka transaction은 DB outbox transaction을 대체하지 않는다.
|
||
|
||
세부 내용이 상위 문서의 messaging 요약과 다르면 이 문서가 messaging 범위의 정본이다.
|
||
다른 capability 결정은 변경하지 않는다.
|
||
|
||
### 2.1 Normative decision ledger
|
||
|
||
| 결정 | 정본 절 |
|
||
| --- | --- |
|
||
| 현재 구현/후속 상태 | §0 |
|
||
| 첫 R2 exact tuple | §0.2, §10 |
|
||
| guarantee와 readiness 용어 | §7 |
|
||
| architecture와 module ownership | §8–§9 |
|
||
| capability card와 교체 조건 | §10 |
|
||
| identity와 ordering vocabulary | §11 |
|
||
| event transformation | §12 |
|
||
| contract/destination/topic catalog | §13 |
|
||
| JSON envelope와 schema evolution | §14 |
|
||
| application outcome contract | §15 |
|
||
| ACK-aware Kafka producer | §16–§17 |
|
||
| polling outbox state/data | §18–§19 |
|
||
| best-effort 경계 | §20 |
|
||
| activation/configuration | §21 |
|
||
| security/topic governance | §22 |
|
||
| observability/readiness | §23 |
|
||
| consumer/inbox/DLT/replay | §24–§25 |
|
||
| CDC와 mode cutover | §26–§27 |
|
||
| test/CI/evidence | §28–§29 |
|
||
| migration/status update | §30 |
|
||
| 완료/후속 card | §31 |
|
||
| runbook | §32 |
|
||
|
||
예시 YAML, Java pseudocode, migration alias, README, runbook은 이 ledger의 정본 절보다 우선하지
|
||
않는다. 두 activation source가 충돌하면 임의 precedence나 fallback을 적용하지 않고 startup을
|
||
실패시킨다.
|
||
|
||
## 3. 증거 기반 현재 상태
|
||
|
||
### 3.1 실제 Kafka production dependency가 없다
|
||
|
||
`adapter:outbound:messaging/build.gradle`의 production dependency는 현재 다음뿐이다.
|
||
|
||
```text
|
||
application-core
|
||
shared-contract
|
||
adapter:outbound:support
|
||
spring-boot-autoconfigure
|
||
slf4j-api
|
||
```
|
||
|
||
`spring-kafka`와 `kafka-clients`가 없으므로 production `KafkaProducer`,
|
||
`ProducerFactory`, `KafkaTemplate`, `AdminClient`도 없다. `app-bootstrap`의
|
||
`testCompileOnly kafka-clients`는 architecture test classpath를 위한 것이며 실제 provider가
|
||
아니다.
|
||
|
||
따라서 현재 `app.messaging.broker=kafka`는 Kafka capability 활성화가 아니라 fork project가
|
||
`KafkaSender` bean을 별도로 제공했을 때 seam을 선택한다는 의미다.
|
||
|
||
### 3.2 `void KafkaSender.send()`는 broker ACK를 표현하지 못한다
|
||
|
||
현재 contract는 다음과 같다.
|
||
|
||
```java
|
||
void send(OutboundMessage message) throws Exception;
|
||
```
|
||
|
||
Kafka `send()`는 일반적으로 local buffer에 record를 넣고 future를 즉시 반환한다. 외부 seam이
|
||
future를 기다리는지, 어떤 `acks`를 쓰는지, delivery timeout이 유한한지 repository는 알 수 없다.
|
||
그런데 application의 `OutboxEventStatus.PUBLISHED` 문서는 broker acknowledgement를 뜻한다고
|
||
설명한다.
|
||
|
||
현재 seam 구현자가 callback/future 완료 전에 정상 반환하면 relay는 ACK가 없는 record를
|
||
`PUBLISHED`로 기록한다. 이는 단순한 구현 누락이 아니라 현재 상태 이름과 실제 evidence의
|
||
불일치다.
|
||
|
||
### 3.3 event type이 physical topic으로 사용된다
|
||
|
||
현재 `OutboxMessagePublishAdapter`는 다음 매핑을 한다.
|
||
|
||
```text
|
||
topic = event.eventType()
|
||
key = event.aggregateId()
|
||
payload = hand-written envelope
|
||
```
|
||
|
||
이 구조는 application event naming이 Kafka topic naming, ACL, retention, partition count,
|
||
replication, environment naming과 결합되게 한다. event type을 동적으로 만들 수 있으면 arbitrary
|
||
topic publication과 metric cardinality도 열린다.
|
||
|
||
목표에서는 `contractId -> logicalDestination -> physicalTopicBinding`의 닫힌 두 단계 mapping을
|
||
사용한다.
|
||
|
||
### 3.4 envelope가 schema-bound document가 아니다
|
||
|
||
`OutboxEnvelopeJson`은 문자열을 직접 이어 붙인다. payload는 “이미 올바른 JSON”이라는 주석
|
||
계약만 있고 parser/schema validation 없이 verbatim 삽입된다.
|
||
|
||
현재 envelope에는 다음도 없다.
|
||
|
||
- envelope spec version;
|
||
- payload schema version;
|
||
- contract ID;
|
||
- aggregate type와 aggregate sequence;
|
||
- causation ID;
|
||
- content type;
|
||
- logical destination;
|
||
- schema hash/compatibility evidence;
|
||
- maximum depth/string/array/record bytes;
|
||
- rolling producer/consumer compatibility fixture.
|
||
|
||
따라서 현재 JSON은 example wire shape이지 versioned integration contract가 아니다.
|
||
|
||
### 3.5 immutable event와 mutable delivery state가 한 행에 섞여 있다
|
||
|
||
현재 `outbox_event`에는 event metadata와 다음 polling state가 함께 있다.
|
||
|
||
```text
|
||
status
|
||
attempt_count
|
||
next_attempt_at
|
||
```
|
||
|
||
relay는 같은 row를 `PENDING -> IN_FLIGHT -> PUBLISHED/FAILED/DEAD`로 UPDATE한다.
|
||
Debezium Outbox Event Router는 outbox table을 INSERT-only queue로 기대하고 UPDATE를 비정상
|
||
operation으로 분류한다. 현재 table에 connector flag만 켜는 방식으로 CDC를 추가할 수 없다.
|
||
|
||
### 3.6 현재 FIFO는 동일 timestamp와 긴 batch에서 불완전하다
|
||
|
||
현재 claim과 defensive sort는 `occurred_at` 중심이다. 같은 aggregate에서 같은 timestamp를 가진
|
||
두 event의 완전한 tie-breaker나 domain aggregate sequence가 없다. 따라서 strict ordering을
|
||
증명할 수 없다.
|
||
|
||
또한 batch row는 같은 시점에 `now + inFlightTimeout`으로 claim되고 순차 publish된다. batch의
|
||
최악 처리 시간이 in-flight timeout을 넘으면 뒤쪽 row를 첫 worker가 처리 중일 때 다른 worker가
|
||
재claim할 수 있다.
|
||
|
||
목표에서는 aggregate sequence, claim token, per-row remaining-lease validation, attempt budget과
|
||
claim lease의 관계를 고정한다.
|
||
|
||
### 3.7 polling ACK-to-DB gap은 이미 duplicate를 허용한다
|
||
|
||
현재 순서는 다음과 같다.
|
||
|
||
```text
|
||
publishPort.publish(event)
|
||
-> 별도 DB transaction에서 markPublished(eventId)
|
||
```
|
||
|
||
broker가 record를 수락한 뒤 process가 종료되거나 `markPublished`가 실패하면 row는
|
||
`IN_FLIGHT`에 남고 timeout 뒤 재claim된다. 이는 올바른 transactional outbox에서 피할 수 없는
|
||
ACK-to-state duplicate gap이며 consumer dedupe가 필요하다.
|
||
|
||
현재 repository에는 inbound consumer와 inbox가 없으므로 “consumer dedupe가 안전하게 흡수한다”는
|
||
runbook 표현은 목표 계약이지 현재 보장이 아니다.
|
||
|
||
### 3.8 retry 분류가 모든 exception을 같은 경로로 보낸다
|
||
|
||
현재 publish exception은 attempt count가 남았으면 FAILED, 소진됐으면 DEAD가 된다. 다음을
|
||
구분하지 않는다.
|
||
|
||
- local schema/size violation처럼 절대 broker에 도달하지 않은 permanent rejection;
|
||
- authorization/topic-not-found처럼 configuration/operator 조치가 필요한 failure;
|
||
- retriable broker/network failure;
|
||
- broker가 수락했을 수도 있는 timeout/cancel/connection loss;
|
||
- programming defect;
|
||
- stale claim owner가 수행한 결과.
|
||
|
||
poison event도 max attempt까지 재시도하므로 불필요한 amplification과 aggregate head blocking을
|
||
만든다.
|
||
|
||
### 3.9 configuration은 topology와 guarantee를 표현하지 못한다
|
||
|
||
현재 typed settings는 사실상 다음뿐이다.
|
||
|
||
```text
|
||
app.messaging.broker
|
||
app.messaging.kafka.brokers
|
||
```
|
||
|
||
다음이 없다.
|
||
|
||
- expected state;
|
||
- producer semantic profile;
|
||
- logical destination binding;
|
||
- contract/schema catalog;
|
||
- dispatch mode;
|
||
- acknowledgement deadline;
|
||
- delivery/request/max-block timeout;
|
||
- buffer/in-flight/batch/record size;
|
||
- security protocol, TLS, SASL, secret reference;
|
||
- topic partitions/replication/min ISR/retention expectation;
|
||
- readiness requirement;
|
||
- shutdown drain;
|
||
- selected capability/evidence digest.
|
||
|
||
host:port regex도 IPv6, duplicate endpoint, port range, blank-normalization과 secret/source policy를
|
||
충분히 검증하지 않는다.
|
||
|
||
### 3.10 best-effort와 durable success vocabulary가 섞일 수 있다
|
||
|
||
일반 `OutboundMessagePublisher`는 exception을 삼키는 fail-open이고 outbox publisher는
|
||
exception을 전파하는 fail-closed다. 이 구분 자체는 유용하다.
|
||
|
||
그러나 둘 다 같은 `MessageBroker.send(void)`를 호출하므로 다음을 구분하지 못한다.
|
||
|
||
- local admission;
|
||
- producer buffer enqueue;
|
||
- broker acknowledgement;
|
||
- definite rejection;
|
||
- indeterminate result.
|
||
|
||
일반 publisher의 `logSuccess`도 broker ACK가 아니라 seam의 정상 반환만 의미할 수 있다.
|
||
|
||
### 3.11 consumer/inbox/CDC runtime이 없다
|
||
|
||
현재 registry에는 19개 leaf만 있고 inbound Kafka leaf가 없다. production source 검색 기준으로
|
||
다음도 없다.
|
||
|
||
- `@KafkaListener` 또는 listener container;
|
||
- consumer group/offset/ack policy;
|
||
- deserializer allowlist;
|
||
- inbox table/port/executor;
|
||
- retry/DLT/replay;
|
||
- rebalance/pause/resume/drain;
|
||
- Debezium connector/Kafka Connect deployment;
|
||
- replication slot/offset/WAL monitoring.
|
||
|
||
이 기능은 package를 outbound leaf에 추가하지 않고 각각 §24–§27의 단계에서 도입한다.
|
||
|
||
### 3.12 현재 test와 runbook이 증명하는 범위
|
||
|
||
현재 focused messaging test는 fake sender/broker와 settings/composition/report rendering을
|
||
검증한다. PostgreSQL outbox integration test는 same-transaction append, row lifecycle,
|
||
normal-path two-worker `SKIP LOCKED` row partitioning과 일부 claim 동작을 검증한다.
|
||
|
||
현재 test가 증명하지 않는 것은 다음이다.
|
||
|
||
- real broker ACK metadata;
|
||
- leader loss/min ISR/timeout/duplicate;
|
||
- real buffer saturation;
|
||
- TLS/SASL/ACL;
|
||
- topic drift;
|
||
- producer close/drain;
|
||
- schema compatibility;
|
||
- consumer rebalance/inbox;
|
||
- CDC restart/slot/offset.
|
||
|
||
`outbox-publish-failed` runbook은 이미 제거된 `APP_MESSAGING_KAFKA_ENABLED`와 존재하지 않는
|
||
`KafkaOutboxMessagePublishAdapter`를 참조한다. `outbox-dead-letter` runbook은 raw SQL로 상태를
|
||
직접 수정하며 operator identity, reason, CAS, audit generation이 없다. 두 문서는 R2 구현과 함께
|
||
도구 기반 절차로 교체해야 한다.
|
||
|
||
## 4. 범위와 명시적 비범위
|
||
|
||
### 4.1 전체 설계 범위
|
||
|
||
이 문서는 다음을 설계한다.
|
||
|
||
- domain event와 integration event의 분리;
|
||
- framework-free event metadata와 application outbox contract;
|
||
- logical destination, contract, payload schema, topic binding catalog;
|
||
- JSON envelope v1, schema evolution와 compatibility evidence;
|
||
- ACK-aware Kafka producer와 typed certainty;
|
||
- producer retry, ordering, batching, compression, resource bound와 lifecycle;
|
||
- immutable event + polling delivery state;
|
||
- claim token, retry/dead/replay/retention;
|
||
- best-effort와 durable publication 구분;
|
||
- typed activation, expected state와 capability cards;
|
||
- TLS/SASL, ACL, topic governance, secret rotation;
|
||
- metrics/tracing/log/readiness;
|
||
- future inbound Kafka leaf;
|
||
- consumer manual ack, inbox, retry, DLT, replay;
|
||
- future PostgreSQL Debezium CDC;
|
||
- polling/CDC shadow, cutover, rollback;
|
||
- real-service/fault/security/compatibility CI.
|
||
|
||
### 4.2 최초 R2 baseline
|
||
|
||
최초 R2는 다음 common subset만 구현한다.
|
||
|
||
- Kafka 한 provider;
|
||
- polling outbox 한 dispatch mode;
|
||
- JSON Schema 한 serialization profile;
|
||
- external topic provisioning + startup validation;
|
||
- acknowledged idempotent producer;
|
||
- production TLS/SASL_SSL와 explicit local plaintext profile;
|
||
- single-cluster, single-region producer;
|
||
- bounded record, buffer, batch, retry, deadline와 shutdown;
|
||
- real PostgreSQL + Kafka qualification.
|
||
|
||
consumer와 CDC는 설계에 포함되지만 최초 R2 implementation acceptance에는 포함되지 않는다.
|
||
|
||
### 4.3 후속 optional capability
|
||
|
||
다음은 stable boundary 뒤에 추가할 수 있다.
|
||
|
||
- Kafka manual-ack consumer + PostgreSQL inbox;
|
||
- retry-topic/delayed retry;
|
||
- Debezium PostgreSQL CDC;
|
||
- Avro + schema registry;
|
||
- Protobuf + schema registry;
|
||
- JSON Schema registry;
|
||
- Kafka transactional consume-process-produce;
|
||
- Kafka Streams;
|
||
- alternative partitioner proven by ordering vectors;
|
||
- multi-cluster replication/failover;
|
||
- broker/provider 대체;
|
||
- contract-specific compaction;
|
||
- large-message claim-check pattern with object storage;
|
||
- module split 또는 external capability artifact.
|
||
|
||
optional card는 이름만 등록하지 않는다. code, typed settings, tests, runbook, evidence가 같은
|
||
변경에서 존재할 때 registry에 추가한다.
|
||
|
||
### 4.4 비범위
|
||
|
||
다음은 이번 설계의 목표가 아니다.
|
||
|
||
- arbitrary topic/key/header를 application에 노출하는 Kafka facade;
|
||
- 모든 broker를 lowest-common-denominator API로 감싸기;
|
||
- DB와 Kafka를 XA/distributed transaction으로 묶기;
|
||
- generic exactly-once delivery 주장;
|
||
- Kafka partition ownership을 distributed lock으로 사용하기;
|
||
- Kafka를 source-of-truth database로 일반화하기;
|
||
- unbounded event sourcing platform;
|
||
- dynamic user input으로 topic 생성하기;
|
||
- payload에 임의 Java class/type header를 넣기;
|
||
- active-active multi-region ordering을 보장하기;
|
||
- 대용량 binary를 Kafka record에 직접 싣기;
|
||
- sample-portfolio business contract를 production leaf에 넣기.
|
||
|
||
## 5. HARD invariants
|
||
|
||
다음 중 하나라도 위반하면 동작하는 코드라도 messaging 설계 구현 완료가 아니다.
|
||
|
||
1. `domain-core`는 Kafka, Spring, JSON library, database, transport 타입을 알지 않는다.
|
||
2. `application-core`는 `KafkaTemplate`, `ProducerRecord`, `ConsumerRecord`, offset,
|
||
partition SDK 타입을 알지 않는다.
|
||
3. business/best-effort/outbox publication producer와 inbound consumer는 같은 leaf에 두지
|
||
않는다. inbound processing lifecycle에만 쓰이는 closed retry/DLT publisher는
|
||
`adapter:inbound:messaging-kafka`가 소유할 수 있는 유일한 명시적 예외이며 application publish,
|
||
outbox relay 또는 arbitrary destination 전송에 재사용하지 않는다.
|
||
4. inbound Kafka leaf는 persistence 또는 outbound messaging leaf에 직접 의존하지 않는다.
|
||
5. business write와 durable event append는 같은 source-of-truth transaction에 참여한다.
|
||
6. event type을 physical topic으로 암묵 변환하지 않는다.
|
||
7. application은 arbitrary topic/header/security property를 전달하지 않는다.
|
||
8. 모든 publish는 closed contract catalog와 destination binding을 통과한다.
|
||
9. wire envelope와 payload는 append 전에 versioned schema와 size limit을 통과한다.
|
||
10. broker ACK metadata 전에는 durable publish 성공을 선언하지 않는다.
|
||
11. future 반환/local enqueue를 broker ACK라고 부르지 않는다.
|
||
12. timeout/cancel/late callback race를 definite failure로 축소하지 않는다.
|
||
13. `INDETERMINATE`는 broker acceptance 가능성을 보존한다.
|
||
14. producer idempotence를 process restart나 relay retry 전체의 dedupe로 표현하지 않는다.
|
||
15. Kafka transaction을 DB + Kafka atomic commit으로 표현하지 않는다.
|
||
16. end-to-end는 bounded source/retention 조건 안의 duplicate-possible delivery와
|
||
inbox-covered idempotent effect로만 표현한다.
|
||
17. `outbox_event`는 CDC qualification 전에 insert-only immutable source가 된다.
|
||
18. polling mutable state는 `outbox_delivery`에만 둔다.
|
||
19. polling과 CDC는 같은 production destination에서 동시에 발행하지 않는다.
|
||
20. shadow CDC는 격리된 topic과 격리된 consumer group만 사용한다.
|
||
21. strict aggregate ordering은 timestamp가 아니라 explicit aggregate sequence를 요구한다.
|
||
22. active worker가 소유한 renew/outcome transition은 current claim token/owner와 unexpired
|
||
DB-time lease를 CAS 조건으로 검증한다. initial claim, expired reclaim, operator disposition과
|
||
generation authority handoff는 §18.6의 각기 다른 fenced predicate를 사용한다.
|
||
23. Kafka client retry와 relay retry는 하나의 finite amplification budget으로 검증한다.
|
||
24. timeout, queue, buffer, in-flight, batch, payload, header, retry는 모두 finite다.
|
||
25. automatic provider fallback과 automatic topic creation은 production에서 금지한다.
|
||
26. disabled profile은 client, AdminClient, listener, scheduler, connector, refresh thread가 0개다.
|
||
27. production plaintext와 literal secret은 startup fail-closed다.
|
||
28. event payload, partition key, tenant, credential, raw headers는 log/metric tag에 넣지 않는다.
|
||
29. consumer offset은 application transaction commit 뒤에만 진행한다.
|
||
30. `APPLIED`와 verified `DUPLICATE`만 바로 ACK할 수 있다.
|
||
31. poison record를 DLT로 보낼 때 DLT publish ACK 전에는 원본 offset을 진행하지 않는다.
|
||
32. retry topic이 ordering을 잃는다는 사실을 숨기지 않는다.
|
||
33. replay는 별도 audited operation이며 운영 group offset을 임의 rewind하지 않는다.
|
||
34. R2는 exact selected profile의 real-service/security/fault/shutdown evidence가 있어야 한다.
|
||
35. required qualification lane은 Docker나 broker 부재를 이유로 silent skip하지 않는다.
|
||
36. 현재 구현되지 않은 consumer/CDC/schema-registry setting을 live config처럼 추가하지 않는다.
|
||
37. production leaf는 `sample-portfolio` contract나 fixture에 의존하지 않는다.
|
||
38. 모든 dependency edge는 `src/config/architecture/modules.json` 변경과 검증을 통과한다.
|
||
39. nullable tenant scope를 unique/dedupe/order key에 사용하지 않는다.
|
||
40. legacy/v2/polling/CDC relay authority는 같은 destination에서 항상 정확히 하나다.
|
||
41. CDC commit order를 aggregate sequence order라고 표현하지 않는다.
|
||
42. plain unique-violation catch 뒤 rollback-only PostgreSQL/JPA transaction을 계속 사용하지 않는다.
|
||
43. consumer의 bounded retry가 소진되면 durable HOLD와 partition/container stop 중 하나로
|
||
automation을 끝낸다. recoverer 실패로 같은 retry cycle을 무기한 다시 시작하지 않는다.
|
||
|
||
## 6. 대안 검토
|
||
|
||
### 6.1 현재 `KafkaSender` seam만 확장
|
||
|
||
장점:
|
||
|
||
- broker SDK가 leaf에 없으므로 가볍다;
|
||
- fake unit test가 쉽다;
|
||
- fork project가 client를 자유롭게 선택할 수 있다.
|
||
|
||
문제:
|
||
|
||
- actual producer config와 lifecycle evidence를 template이 소유하지 못한다;
|
||
- ACK, timeout, buffer saturation, TLS/SASL, metrics를 검증할 수 없다;
|
||
- fork마다 guarantee가 달라져 같은 capability label을 사용할 수 없다.
|
||
|
||
판정: legacy/test seam으로만 유지한다. `void/throws` shape는 R2 선택 불가다.
|
||
|
||
### 6.2 Apache Kafka client 직접 사용
|
||
|
||
장점:
|
||
|
||
- `KafkaProducer` lifecycle, callback, metrics, transaction을 가장 직접 통제한다;
|
||
- Spring abstraction 없이 Kafka API 의미를 그대로 사용할 수 있다.
|
||
|
||
문제:
|
||
|
||
- producer factory, generation rotation, close/drain, observation, transaction cache,
|
||
Spring lifecycle integration을 모두 직접 소유해야 한다;
|
||
- 현재 Spring Boot composition과 중복이 커진다.
|
||
|
||
판정: future provider candidate다. Spring Kafka가 보장을 막는 구체적 evidence가 있을 때만
|
||
추가한다.
|
||
|
||
### 6.3 Spring Kafka `KafkaTemplate` + explicit producer factory
|
||
|
||
장점:
|
||
|
||
- 실제 Kafka producer guarantee를 사용하면서 Spring lifecycle/composition과 정렬된다;
|
||
- future/ACK metadata, observation, test support를 사용할 수 있다;
|
||
- provider config를 outbound leaf가 직접 검증할 수 있다.
|
||
|
||
주의:
|
||
|
||
- Boot의 broad auto-configuration/default에 activation을 맡기지 않는다;
|
||
- `KafkaTemplate.send()` 반환 자체가 ACK가 아니므로 future 완료를 기다려야 한다;
|
||
- shared producer에서 per-message `flush()`를 사용하지 않는다;
|
||
- provider settings를 Kafka raw map으로 무제한 노출하지 않는다.
|
||
|
||
판정: 첫 reference provider로 선택한다.
|
||
|
||
### 6.4 Spring Cloud Stream/binder를 baseline으로 사용
|
||
|
||
장점:
|
||
|
||
- binder 교체와 functional pipeline이 편리하다;
|
||
- broker-neutral developer experience를 제공할 수 있다.
|
||
|
||
문제:
|
||
|
||
- 현재 요구는 exact producer acknowledgement, outbox state, topic/security drift,
|
||
client retry와 lifecycle을 직접 증명하는 것이다;
|
||
- binder abstraction이 provider-specific guarantee와 evidence ownership을 흐릴 수 있다;
|
||
- dependency와 activation 범위가 첫 reference path보다 크다.
|
||
|
||
판정: 첫 baseline으로 선택하지 않는다. 동일 semantic card와 exact evidence를 만족하는
|
||
future provider로 검토할 수 있다.
|
||
|
||
### 6.5 consumer를 outbound messaging leaf에 추가
|
||
|
||
장점:
|
||
|
||
- Kafka dependency와 설정을 한 곳에서 공유한다.
|
||
|
||
문제:
|
||
|
||
- producer는 driven adapter이고 consumer는 driving adapter다;
|
||
- outbound leaf가 use case invocation, offset lifecycle, rebalance를 소유하게 된다;
|
||
- adapter-to-adapter/persistence 직접 의존 유혹이 생긴다.
|
||
|
||
판정: 금지한다. consumer 구현 전 별도 inbound leaf를 추가한다.
|
||
|
||
### 6.6 CDC를 첫 dispatch로 구현
|
||
|
||
장점:
|
||
|
||
- Java polling scheduler와 ACK-to-mark gap을 제거한다;
|
||
- database log 기반 확장성이 좋을 수 있다.
|
||
|
||
문제:
|
||
|
||
- 현재 mutable table과 호환되지 않는다;
|
||
- Kafka Connect, Debezium, replication slot, WAL, offset topic, snapshot과 운영 범위가 함께
|
||
필요하다;
|
||
- 첫 실제 producer/contract도 없는 상태에서 장애 면적이 너무 크다.
|
||
|
||
판정: 전체 설계에는 포함하되 첫 구현은 polling이다.
|
||
|
||
### 6.7 polling만 설계하고 CDC는 나중에 처음부터 다시 설계
|
||
|
||
문제:
|
||
|
||
- mutable row/envelope가 굳으면 CDC 전환 비용이 커진다;
|
||
- topic/identity/wire parity가 dispatch 구현마다 갈라진다.
|
||
|
||
판정: 거부한다. 처음부터 immutable event와 replaceable dispatch card를 둔다.
|
||
|
||
### 6.8 JSON Schema, Avro, Protobuf
|
||
|
||
JSON Schema 장점:
|
||
|
||
- 현재 JSON 예제와 이행 거리가 짧다;
|
||
- schema 파일과 golden vectors를 repository에서 바로 review할 수 있다;
|
||
- registry 없이 첫 contract를 세울 수 있다.
|
||
|
||
JSON Schema 한계:
|
||
|
||
- compatibility를 schema diff heuristic만으로 완전히 증명할 수 없다;
|
||
- binary 효율과 generated type safety는 Avro/Protobuf보다 약할 수 있다.
|
||
|
||
Avro/Protobuf 장점:
|
||
|
||
- generated type과 schema registry ecosystem이 강하다;
|
||
- compact binary wire를 제공한다.
|
||
|
||
Avro/Protobuf 비용:
|
||
|
||
- registry availability/security/compatibility mode와 build generation을 함께 설계해야 한다;
|
||
- 현재 skeleton의 첫 R2 경로를 넓힌다.
|
||
|
||
판정: JSON Schema v1을 먼저 구축하고 Avro/Protobuf는 별도 evidence card로 추가한다.
|
||
|
||
### 6.9 Kafka transaction을 모든 durable publish에 사용
|
||
|
||
Kafka transaction은 Kafka 안의 여러 record와 offset을 원자화할 수 있다. 그러나 PostgreSQL
|
||
business commit과 Kafka transaction을 하나의 atomic commit으로 만들지 않는다. Spring의 DB/Kafka
|
||
transaction synchronization도 순차 commit이며 두 번째 commit 실패 가능성이 남는다.
|
||
|
||
판정: polling outbox producer baseline에서는 사용하지 않는다. DB-free
|
||
Kafka consume-process-produce에만 optional card로 둔다.
|
||
|
||
## 7. Capability readiness와 guarantee vocabulary
|
||
|
||
### 7.1 Readiness level
|
||
|
||
| Level | 의미 |
|
||
| --- | --- |
|
||
| R0 | interface/seam/example만 존재; 실제 service guarantee 없음 |
|
||
| R1 | deterministic unit/contract/local composition은 검증; production topology/fault 증거 없음 |
|
||
| R2 | exact selected profile이 real service, security, fault, lifecycle, compatibility gate 통과 |
|
||
| R3 | HA/failover/upgrade/DR/capacity와 운영 rehearsal까지 통과 |
|
||
|
||
`KafkaSender`는 R0다. 현재 PostgreSQL polling control plane은 일부 real-DB test가 있으므로 R1
|
||
skeleton으로 설명할 수 있지만 end-to-end Kafka publication은 R0다.
|
||
|
||
### 7.2 Publication vocabulary
|
||
|
||
| 용어 | 정확한 의미 |
|
||
| --- | --- |
|
||
| `COMPILED` | contract + destination + provider profile이 startup에 검증됨 |
|
||
| `ADMITTED` | local bounded admission을 통과함 |
|
||
| `ENQUEUED` | producer local buffer가 record를 받음 |
|
||
| `ACKNOWLEDGED` | configured ACK 조건을 만족한 broker metadata를 local process가 관찰함 |
|
||
| `ACKNOWLEDGED_MISMATCH` | ACK metadata가 compiled destination과 달라 misroute incident가 됨 |
|
||
| `REJECTED` | provider가 record 비수락을 확정할 수 있음 |
|
||
| `INDETERMINATE` | broker가 수락했을 수도, 아닐 수도 있음 |
|
||
| `DELIVERY_RECORDED` | ACK 뒤 polling delivery row가 terminal success로 commit됨 |
|
||
| `CONSUMED` | consumer가 record를 읽음; business side effect 완료와 다름 |
|
||
| `APPLIED` | inbox + business effect transaction이 commit됨 |
|
||
| `OFFSET_COMMITTED` | APPLIED/DUPLICATE 뒤 Kafka offset이 진행됨 |
|
||
|
||
`ACKNOWLEDGED`와 `DELIVERY_RECORDED` 사이 crash는 duplicate를 만든다.
|
||
`APPLIED`와 `OFFSET_COMMITTED` 사이 crash도 duplicate delivery를 만든다. 둘 다 event ID와 inbox가
|
||
흡수해야 하는 duplicate-possible 경계다. `ACKNOWLEDGED_MISMATCH`는 retry 가능한 일반 실패가
|
||
아니며 producer admission과 relay scope를 멈추는 fatal misroute incident다.
|
||
|
||
### 7.3 허용 guarantee
|
||
|
||
최초 R2가 주장할 수 있는 표현:
|
||
|
||
```text
|
||
same-store transactional outbox append
|
||
+ bounded automatic publish attempts
|
||
+ broker ACK 또는 명시적 unresolved/operator disposition
|
||
+ 같은 producer generation의 정상 경로에서 stable key별 Kafka order
|
||
+ aggregate sequence를 통한 gap/regression 탐지 가능성
|
||
+ idempotent Kafka producer within its supported producer session
|
||
+ explicit duplicate handling requirement
|
||
```
|
||
|
||
이는 무조건적인 eventual delivery 또는 failure-path strict FIFO가 아니다. finite budget이 끝난
|
||
`EXHAUSTED`, 승인된 `SKIPPED/COMPENSATED`, 영구 hold가 존재할 수 있다. 따라서 첫 R2 card의
|
||
정확한 표현은 `durable acknowledged-or-explicit-disposition publication`이다.
|
||
|
||
future consumer/inbox까지 구현한 뒤 주장할 수 있는 표현:
|
||
|
||
```text
|
||
declared source/retention/disposition horizon 안의 duplicate-possible delivery
|
||
+ idempotent application effect for inbox-covered handlers
|
||
```
|
||
|
||
허용하지 않는 표현:
|
||
|
||
- exactly-once DB-to-Kafka;
|
||
- exactly-once end-to-end;
|
||
- global ordering;
|
||
- no duplicates;
|
||
- no loss across an unqualified CDC slot failover;
|
||
- DLT가 곧 성공 처리 또는 데이터 복구라는 표현.
|
||
|
||
### 7.4 Evidence identity
|
||
|
||
R2 evidence는 단순 test 이름이 아니라 다음 fingerprint에 묶인다.
|
||
|
||
```text
|
||
semantic-card-version
|
||
provider-card-version
|
||
Kafka client version
|
||
Spring Kafka version
|
||
broker image/version
|
||
JDK version
|
||
security profile
|
||
topic profile
|
||
contract catalog hash
|
||
schema set hash
|
||
settings digest
|
||
test scenario version
|
||
```
|
||
|
||
다른 version/profile에 이전 evidence를 자동 승계하지 않는다.
|
||
|
||
## 8. 목표 아키텍처
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
DOMAIN[Domain event] --> MAP[Application integration-event mapping]
|
||
MAP --> APPEND[OutboxAppendPort]
|
||
APPEND --> DBTX[(Business DB transaction)]
|
||
DBTX --> EVENT[(immutable outbox_event)]
|
||
DBTX --> DELIVERY[(polling outbox_delivery)]
|
||
|
||
DELIVERY --> RELAY[Application polling relay]
|
||
RELAY --> PUBPORT[OutboxMessagePublishPort]
|
||
PUBPORT --> CATALOG[Contract + destination compiler]
|
||
CATALOG --> KAFKA[Spring Kafka ACK-aware provider]
|
||
KAFKA --> TOPIC[(Kafka topic)]
|
||
|
||
EVENT -. future CDC .-> DBZ[Debezium / Kafka Connect]
|
||
DBZ -. same wire contract .-> TOPIC
|
||
|
||
TOPIC -. future consume .-> INBOUND[adapter:inbound:messaging-kafka]
|
||
INBOUND --> CUSE[Application consume use case]
|
||
CUSE --> INBOXTX[(Inbox + business + optional outbox transaction)]
|
||
INBOXTX --> ACK[Kafka offset ACK]
|
||
|
||
BOOT[app-bootstrap] -. compile exact profiles .-> CATALOG
|
||
BOOT -. compose .-> RELAY
|
||
BOOT -. future compose .-> INBOUND
|
||
OBS[Metrics / trace / readiness] -. bounded observation .-> KAFKA
|
||
OBS -. bounded observation .-> INBOUND
|
||
```
|
||
|
||
### 8.1 Stable plane
|
||
|
||
provider/dispatch가 바뀌어도 다음은 유지한다.
|
||
|
||
- event ID와 contract ID;
|
||
- schema version과 envelope version;
|
||
- logical destination;
|
||
- aggregate identity/sequence와 partition-key intent;
|
||
- publication certainty taxonomy;
|
||
- inbox dedupe identity;
|
||
- application transaction meaning;
|
||
- capability/evidence descriptor shape.
|
||
|
||
### 8.2 Replaceable plane
|
||
|
||
다음은 capability card로 교체할 수 있다.
|
||
|
||
- Kafka Spring provider / direct Kafka provider / future broker provider;
|
||
- polling / CDC;
|
||
- JSON Schema / Avro / Protobuf;
|
||
- blocking retry / retry topic;
|
||
- PostgreSQL inbox / other same-store inbox;
|
||
- local plaintext dev / TLS / SASL_SSL security;
|
||
- externally provisioned topic validation / future managed provisioning;
|
||
- non-transactional idempotent producer / Kafka transactional workflow.
|
||
|
||
### 8.3 교체가 아닌 것
|
||
|
||
다음은 silent fallback이며 금지한다.
|
||
|
||
- Kafka outage 때 다른 broker로 자동 전송;
|
||
- schema validation 실패 때 raw JSON으로 전송;
|
||
- CDC 장애 때 polling을 자동으로 동시에 켬;
|
||
- TLS secret 실패 때 plaintext로 연결;
|
||
- DLT publish 실패 때 원본 offset을 ACK;
|
||
- required consumer failure 때 record를 best-effort로 폐기.
|
||
|
||
## 9. 모듈과 계층 소유권
|
||
|
||
### 9.1 `domain-core`
|
||
|
||
소유:
|
||
|
||
- 순수 domain event와 aggregate invariant;
|
||
- aggregate version/sequence가 domain 의미일 때 그 증가 규칙.
|
||
|
||
금지:
|
||
|
||
- integration topic/destination;
|
||
- JSON/schema;
|
||
- outbox/inbox;
|
||
- Kafka header/partition;
|
||
- retry/DLT.
|
||
|
||
Domain event는 같은 bounded context 내부의 사실이다. Integration event는 외부 consumer와의
|
||
versioned contract이므로 자동으로 동일한 타입을 직렬화하지 않는다.
|
||
|
||
### 9.2 `application-core`
|
||
|
||
소유:
|
||
|
||
- integration-event draft의 framework-free metadata;
|
||
- `OutboxAppendPort`;
|
||
- polling relay orchestration;
|
||
- provider-neutral publication outcome/certainty;
|
||
- provider-neutral late-publication observation drain과 attempt observation port;
|
||
- producer generation 교체 시 durable `INDETERMINATE/HOLD`, admission 재개와 generation barrier를
|
||
조정하는 provider-neutral rotation use case;
|
||
- outbox requeue/hold/skip/compensate command use case와 authorization/audit policy;
|
||
- future `InboxStorePort`와 `MessageConsumptionExecutor`;
|
||
- feature consume use case와 transaction policy;
|
||
- retry/dead decision의 application/operational policy.
|
||
|
||
금지:
|
||
|
||
- physical topic;
|
||
- `KafkaTemplate`, record metadata SDK 타입;
|
||
- connector/replication slot;
|
||
- JPA entity;
|
||
- Micrometer/SLF4J.
|
||
|
||
### 9.3 `adapter:outbound:persistence-jpa`
|
||
|
||
소유:
|
||
|
||
- `outbox_event`, `outbox_delivery`, future `inbox_consumption` migration/entity/repository;
|
||
- PostgreSQL claim query와 claim-token + unexpired DB-time lease CAS;
|
||
- same-store transaction participation;
|
||
- attempt observation append와 audited disposition/authority-handoff persistence;
|
||
- polling retention query와 operator transition persistence;
|
||
- future inbox unique constraint.
|
||
|
||
금지:
|
||
|
||
- Kafka producer;
|
||
- topic routing;
|
||
- event business mapping;
|
||
- use-case retry policy.
|
||
|
||
### 9.4 `adapter:outbound:messaging`
|
||
|
||
소유:
|
||
|
||
- destination binding compiler;
|
||
- envelope/payload schema validation runtime;
|
||
- provider-private publish gateway;
|
||
- Spring Kafka producer factory/template/AdminClient;
|
||
- ACK mapping, finite deadline, buffer/admission;
|
||
- late completion을 payload-free bounded queue로 노출하는 application port 구현;
|
||
- producer security와 provider-private generation 생성/drain/close/attestation primitive 및
|
||
payload-free lifecycle fact;
|
||
- adapter-local best-effort publisher;
|
||
- structured outbox diagnostics.
|
||
|
||
금지:
|
||
|
||
- consumer listener;
|
||
- inbox repository;
|
||
- JPA entity;
|
||
- business route/event policy;
|
||
- sample contract.
|
||
|
||
초기 package shape:
|
||
|
||
```text
|
||
dev.caskeleton.adapter.outbound.messaging
|
||
config/
|
||
contract/
|
||
destination/
|
||
envelope/
|
||
publication/
|
||
kafka/
|
||
lifecycle/
|
||
observation/
|
||
outbox/
|
||
```
|
||
|
||
package 이름은 예시이고 책임 분리가 정본이다.
|
||
|
||
### 9.5 미래 `adapter:inbound:messaging-kafka`
|
||
|
||
consumer 구현 전 registry migration으로 새 leaf를 추가한다.
|
||
|
||
```text
|
||
module id: adapter-inbound-messaging-kafka
|
||
gradle path: :adapter:inbound:messaging-kafka
|
||
source path: src/adapter/inbound/messaging-kafka
|
||
allowed production dependencies:
|
||
- application-core
|
||
- domain-core
|
||
- shared-contract
|
||
```
|
||
|
||
정확한 allowed edge는 그 시점의 `modules.json` review로 확정한다. persistence/outbound messaging
|
||
adapter edge는 추가하지 않는다. `app-bootstrap`만 inbound listener, application use case,
|
||
transaction/inbox provider를 조립한다.
|
||
|
||
소유:
|
||
|
||
- Kafka listener container;
|
||
- record/header/envelope decode와 application command mapping;
|
||
- ack/seek/pause/resume/rebalance;
|
||
- consumer-local retry/DLT publisher. §5의 HARD invariant 3에 둔 유일한 예외이며 closed
|
||
retry/DLT binding 외
|
||
destination과 application/outbox publication에는 사용할 수 없음;
|
||
- consumer lifecycle/metrics/security.
|
||
|
||
금지:
|
||
|
||
- repository 직접 호출;
|
||
- JPA entity;
|
||
- producer outbox implementation;
|
||
- business effect.
|
||
|
||
### 9.6 `shared-contract`
|
||
|
||
소유 가능:
|
||
|
||
- skeleton-wide generic envelope JSON Schema;
|
||
- framework-free bounded operational descriptor vocabulary;
|
||
- error/metric contract에서 truly shared인 값.
|
||
|
||
금지:
|
||
|
||
- WorkLog/Poster 등 business event schema;
|
||
- Kafka SDK;
|
||
- provider setting;
|
||
- feature-specific topic.
|
||
|
||
현재 `shared-contract/CLAUDE.md`에는 messaging schema resource가 아직 책임으로 등록되어 있지
|
||
않다. P1에서 공통 envelope schema를 추가하는 변경은 같은 commit scope에서 해당
|
||
`CLAUDE.md`의 Responsibility를 갱신하고 Java-stdlib-only 규칙과 business-free 검증을
|
||
추가해야 한다. 이 정책 갱신 없이 resource만 넣지 않는다.
|
||
|
||
### 9.7 `app-bootstrap`
|
||
|
||
소유:
|
||
|
||
- leaf가 bind/validate한 typed settings의 cross-leaf aggregation;
|
||
- exact capability tuple selection;
|
||
- cross-field/expected-state validation;
|
||
- provider, relay, future listener와 health composition;
|
||
- required capability readiness aggregation;
|
||
- secret reference resolution.
|
||
|
||
금지:
|
||
|
||
- event mapping;
|
||
- retry/DLT business policy;
|
||
- repository/Kafka implementation;
|
||
- schema compatibility rule 자체.
|
||
|
||
broker namespace의 typed settings/value validation은 현재 local SSOT와 같이
|
||
`adapter:outbound:messaging`이 소유한다. `app-bootstrap`은 raw Kafka map을 다시 bind하지 않고
|
||
leaf의 compiled descriptor를 transaction resource, persistence dispatch와 합성한다.
|
||
|
||
### 9.8 `sample-portfolio`
|
||
|
||
소유:
|
||
|
||
- sample domain event -> sample integration event mapping;
|
||
- sample payload schema/golden vectors;
|
||
- sample contract catalog contribution;
|
||
- sample consumer fixture가 생길 경우 application consume use case.
|
||
|
||
Production leaf는 sample module에 의존하지 않는다. 새 프로젝트는 sample contract를 제거하고 자기
|
||
feature contract를 같은 확장점에 등록한다.
|
||
|
||
합법적인 조립 경로는 다음으로 고정한다.
|
||
|
||
1. framework-free `IntegrationEventContractContribution` SPI는 `application-core`에 둔다;
|
||
2. feature/sample module은 typed payload record, schema resource와 contribution bean을 제공한다;
|
||
3. outbound messaging compiler는 application SPI의 bean 목록만 주입받으며 sample class를 import,
|
||
scan 또는 `Class.forName`하지 않는다;
|
||
4. production `app-bootstrap`은 sample에 의존하지 않는다. base skeleton은 messaging
|
||
`DISABLED`이고 empty catalog가 정상이다;
|
||
5. provider qualification test는 test-source fixture contribution을 사용한다;
|
||
6. standalone sample이 ACTIVE example을 실행하는 phase에서만
|
||
`sample-portfolio -> adapter-outbound-messaging` runtime edge를 `modules.json`과
|
||
`sample-portfolio/build.gradle`에 함께 추가한다. 이 edge는 fixture consumer 방향이며 반대
|
||
edge는 금지한다.
|
||
|
||
Spring bean discovery는 조립 수단일 뿐 contract SSOT가 아니다. 동일 contribution 목록으로
|
||
build-time checksum manifest와 runtime compiler를 검증한다.
|
||
|
||
application SPI의 최소 shape는 다음처럼 closed type token을 포함한다.
|
||
|
||
```java
|
||
interface IntegrationPayload {}
|
||
|
||
interface IntegrationEventContractContribution<P extends IntegrationPayload> {
|
||
ContractId contractId();
|
||
int payloadVersion();
|
||
Class<P> exactPayloadRecordType();
|
||
List<String> canonicalRecordComponentOrder();
|
||
SchemaResourceId payloadSchemaResource();
|
||
Sha256 payloadSchemaHash();
|
||
ContractDescriptor descriptor();
|
||
}
|
||
```
|
||
|
||
이는 API 이름을 고정하는 코드가 아니라 경계를 고정하는 pseudocode다. outbound compiler는
|
||
startup에 `exactPayloadRecordType()`이 final Java record이고 descriptor가 허용한 scalar,
|
||
collection, nested-record component만 갖는지 검증한다. runtime payload는 exact class equality로
|
||
closed catalog를 찾으며 assignable-type scan, `Class.forName`, default typing, feature-provided
|
||
Jackson serializer를 허용하지 않는다. contribution은 type token/order/schema/hash만 제공하고
|
||
JSON mapper, deterministic writer, parser와 schema validator는 계속 outbound messaging leaf가
|
||
소유한다. 따라서 feature mapper가 JSON string/tree를 만들거나 messaging leaf가 sample class를
|
||
compile-time import할 필요가 없다.
|
||
|
||
### 9.9 외부 deployment asset
|
||
|
||
다음은 application Java leaf가 아니라 deployment/integration-test asset이다.
|
||
|
||
- Kafka cluster/topic/ACL provisioning;
|
||
- Kafka Connect worker;
|
||
- Debezium connector JSON;
|
||
- PostgreSQL publication/replication-slot procedure;
|
||
- connector image/plugin digest;
|
||
- dashboard/alert/runbook.
|
||
|
||
## 10. Capability card와 exact selection
|
||
|
||
### 10.1 두 층의 card
|
||
|
||
Semantic card는 application이 요구하는 의미를 나타낸다.
|
||
|
||
| Card | 의미 |
|
||
| --- | --- |
|
||
| `messaging-best-effort-publish.v1` | persistence/replay 보장 없는 bounded attempt |
|
||
| `messaging-outbox-publish.v1` | transactional append 뒤 ACK 또는 explicit disposition까지 추적 |
|
||
| `messaging-inbox-consume.v1` | future inbox-covered idempotent application effect |
|
||
| `messaging-cdc-dispatch.v1` | future insert-only source log dispatch |
|
||
|
||
Provider/profile card는 그 의미를 실제로 제공하는 조합이다.
|
||
|
||
| Card | 초기 상태 |
|
||
| --- | --- |
|
||
| `external-kafka-sender-legacy.v1` | R0, R2 selection 금지 |
|
||
| `kafka-spring-acknowledged-idempotent.v1` | 최초 R2 목표 |
|
||
| `postgresql-polling-outbox.v2` | 최초 R2 목표 |
|
||
| `json-schema-envelope.v1` | 최초 R2 목표 |
|
||
| `external-topic-validated.v1` | 최초 R2 목표 |
|
||
| `postgresql-per-record-jit-claim.v1` | 최초 R2 목표 |
|
||
| `kafka-sasl-ssl-scram-sha-512.v1` | 최초 production R2 목표 |
|
||
| `kafka-compression-none.v1` | 최초 R2 목표 |
|
||
| `per-key-normal-path-sequence-detectable.v1` | 최초 R2 목표 |
|
||
| `same-postgresql-transaction-resource.v1` | 최초 R2 목표 |
|
||
| `authenticated-internal-web-disposition.v1` | 최초 R2 목표 |
|
||
|
||
미래 consumer/CDC/EOS/Avro/Protobuf semantic/provider 이름은 §31.5의 **설계 extension
|
||
ledger**일 뿐 machine
|
||
registry row가 아니다. code, exact settings, test와 evidence가 생기는 변경에서만 machine
|
||
registry에 추가한다.
|
||
|
||
### 10.2 Card 필드
|
||
|
||
각 executable card는 최소 다음을 가진다.
|
||
|
||
```text
|
||
cardId
|
||
cardVersion
|
||
semanticContractIds
|
||
providerId
|
||
providerVersion
|
||
maturity
|
||
guarantees
|
||
explicitNonGuarantees
|
||
outcomeTaxonomyVersion
|
||
orderingProfile
|
||
resourceBounds
|
||
automaticPublicationAge
|
||
sameEventRequeueHorizon
|
||
securityProfile
|
||
topologyProfile
|
||
lifecycleProfile
|
||
operatorControlProfile
|
||
schemaSetHash
|
||
settingsDigest
|
||
evidenceFingerprint
|
||
evidenceTasks
|
||
requiredScenarios
|
||
runbookIds
|
||
owner
|
||
```
|
||
|
||
`maturity`는 정확히 다음 하나다.
|
||
|
||
```text
|
||
not-implemented
|
||
implemented-candidate
|
||
release-eligible
|
||
```
|
||
|
||
별도 boolean `releaseEligible`이나 중복 maturity/readiness 필드를 두지 않는다. R0–R3는
|
||
evidence 설명용 level이고 machine selection state를 대신하지 않는다.
|
||
|
||
### 10.3 Selection 규칙
|
||
|
||
```text
|
||
required semantic contract/version 일치
|
||
AND required guarantees ⊆ provider achieved guarantees
|
||
AND outcome/failure policy compatible
|
||
AND exact dispatch/serialization/security/topic/operator-control profiles compatible
|
||
AND automatic publication/requeue/dedupe horizons compatible
|
||
AND profile.maturity = release-eligible
|
||
AND current evidence fingerprint = PASS
|
||
```
|
||
|
||
하나라도 불충족하면 production ACTIVE startup 또는 release gate를 실패시킨다.
|
||
|
||
### 10.4 First R2 selected tuple
|
||
|
||
```text
|
||
messaging-outbox-publish.v1
|
||
+ kafka-spring-acknowledged-idempotent.v1
|
||
+ postgresql-polling-outbox.v2
|
||
+ postgresql-per-record-jit-claim.v1
|
||
+ json-schema-envelope.v1
|
||
+ external-topic-validated.v1
|
||
+ kafka-sasl-ssl-scram-sha-512.v1
|
||
+ kafka-compression-none.v1
|
||
+ per-key-normal-path-sequence-detectable.v1
|
||
+ same-postgresql-transaction-resource.v1
|
||
+ authenticated-internal-web-disposition.v1
|
||
```
|
||
|
||
local/development는 별도 `local-plaintext.v1` evidence를 가질 수 있지만 production tuple에
|
||
승격되지 않는다.
|
||
|
||
### 10.5 Configuration과 registry의 역할
|
||
|
||
- `src/config/messaging/readiness-cards.yaml`: 구현된 card의 maturity와 base scenario;
|
||
- `src/config/messaging/profile-compatibility.yaml`: wildcard 없는 exact tuple과 interaction
|
||
scenario;
|
||
- `src/config/messaging/release-profile-assertions.yaml`: 실제 release configuration digest와
|
||
expected selected profile assertion;
|
||
- deployment binding: 이 deployment가 어떤 exact card와 logical destination을 요구하는지 선언;
|
||
- compiled descriptor: 두 입력을 합성한 실제 runtime truth;
|
||
- §0 status ledger: 구현 phase 진행의 human truth.
|
||
|
||
설정에 `provider=avro` 같은 값을 추가하는 것만으로 optional card가 생기지 않는다. registry에
|
||
없는 값은 unknown configuration으로 startup 실패다.
|
||
|
||
candidate 승격 deadlock을 피하기 위해 isolated test harness에만 `QUALIFICATION_ONLY`를 둔다.
|
||
production과 같은 binder/resolver/resource composition을 사용하되
|
||
`implemented-candidate`를 허용하고, `ACTIVE_READY`, release assertion 또는 production
|
||
descriptor는 절대 만들지 않는다. machine registry 파일은 P1–P4 구현과 함께 생성하며, 현재
|
||
설계 extension 이름만 미리 row로 만들지 않는다.
|
||
|
||
## 11. Identity, ordering과 vocabulary
|
||
|
||
### 11.1 Event ID
|
||
|
||
`eventId`는 integration event의 canonical identity다.
|
||
|
||
- globally unique하고 immutable하다;
|
||
- first card의 wire/storage grammar는 1–96자의 canonical US-ASCII
|
||
`[A-Za-z0-9][A-Za-z0-9._:-]*`이며 DB에는 `VARCHAR(96)` + CHECK로 저장한다;
|
||
- outbox retry, producer restart, polling/CDC mode가 바뀌어도 동일하다;
|
||
- Kafka producer attempt ID나 database row ID와 다르다;
|
||
- consumer inbox dedupe의 기본 identity다;
|
||
- payload와 함께 생성된 뒤 다시 계산하지 않는다.
|
||
|
||
현재 `idempotencyKey`는 권장값이 `eventId`이고 별도 의미가 불명확하다. 목표 contract에서는
|
||
consumer dedupe는 `eventId` 하나를 사용한다. 원본 command의 idempotency identity가 필요하면
|
||
`sourceOperationId`처럼 의미가 다른 이름으로 보존하며 consumer dedupe key로 자동 대체하지 않는다.
|
||
|
||
같은 `eventId`와 다른 exact envelope document hash가 관찰되면 정상 duplicate가 아니라 identity collision 또는
|
||
contract violation이다. consumer는 이를 `DUPLICATE`로 ACK하지 않고 quarantine/operator path로
|
||
보낸다.
|
||
|
||
### 11.2 Contract ID와 event name
|
||
|
||
`contractId`는 version과 분리된 안정적인 semantic name이다.
|
||
|
||
예:
|
||
|
||
```text
|
||
portfolio.worklog.reserved
|
||
portfolio.poster.published
|
||
```
|
||
|
||
규칙:
|
||
|
||
- closed code/manifest catalog에 등록한다;
|
||
- user/tenant/request 입력으로 동적 생성하지 않는다;
|
||
- Java class name과 자동 결합하지 않는다;
|
||
- physical topic을 포함하지 않는다;
|
||
- metric tag로 사용할 때 catalog cardinality budget을 통과해야 한다.
|
||
|
||
`eventType` legacy field는 migration 동안 contract ID alias로 읽을 수 있지만 새 event에는
|
||
`contractId`를 사용한다.
|
||
|
||
### 11.3 Envelope version과 payload version
|
||
|
||
두 version을 분리한다.
|
||
|
||
```text
|
||
envelopeVersion = messaging 공통 metadata shape version
|
||
payloadVersion = contractId별 business payload schema version
|
||
```
|
||
|
||
단일 `schemaVersion`으로 두 의미를 합치지 않는다.
|
||
|
||
- envelope version 변경은 모든 producer/consumer/CDC mapping에 영향을 준다;
|
||
- payload version 변경은 특정 contract에만 영향을 준다;
|
||
- schema file은 version별 immutable하다;
|
||
- 같은 version file의 checksum 변경은 CI 실패다.
|
||
|
||
### 11.4 Logical destination과 physical topic
|
||
|
||
`logicalDestinationId`는 application/contract가 요구하는 delivery class를 나타낸다.
|
||
`physicalTopic`은 deployment binding이다.
|
||
|
||
```text
|
||
contractId
|
||
-> logicalDestinationId
|
||
-> environment-specific physicalTopic
|
||
```
|
||
|
||
logical destination은 retention class, ordering class, maximum record size, sensitivity,
|
||
replay horizon과 같은 semantic/operational intent를 묶는다. topic 이름, cluster bootstrap server,
|
||
ACL principal은 포함하지 않는다.
|
||
|
||
### 11.5 Aggregate identity와 total order
|
||
|
||
ordering을 요구하는 contract는 다음을 가진다.
|
||
|
||
```text
|
||
aggregateType
|
||
aggregateId
|
||
aggregateOrder = (aggregateSequence, eventIndex)
|
||
```
|
||
|
||
- `aggregateSequence`는 domain aggregate version 또는 같은 transaction에서 allocation한
|
||
monotonically increasing sequence다;
|
||
- 하나의 aggregate version에서 여러 integration event가 나오면 `eventIndex`로 total order를
|
||
완성한다;
|
||
- 더 단순한 구현이 event마다 고유 단조 sequence를 할당하면 `eventIndex=0`으로 고정할 수 있다;
|
||
- `(tenant?, logicalDestinationId, aggregateType, aggregateId,
|
||
aggregateSequence, eventIndex)`는 unique constraint로 보호한다;
|
||
- sequence를 제공할 수 없는 event는 strict aggregate ordering card를 선택할 수 없다.
|
||
|
||
timestamp와 random event ID는 strict total order의 대체물이 아니다.
|
||
|
||
tenant scope는 nullable uniqueness에 맡기지 않는다.
|
||
|
||
- tenant mode ACTIVE: canonical `tenant_scope`는 `NOT NULL`이고 unique key에 포함한다;
|
||
- tenant mode DISABLED: canonical non-null system scope를 저장하거나 tenant column을 제외한 별도
|
||
constraint를 사용한다;
|
||
- 일반 PostgreSQL `UNIQUE`의 NULL-distinct 동작에 dedupe/order correctness를 의존하지 않는다;
|
||
- `NULLS NOT DISTINCT`를 선택하면 adopted PostgreSQL version과 migration test에 명시한다.
|
||
|
||
### 11.6 Partition key
|
||
|
||
partition key는 catalog가 정한 deterministic mapping이다.
|
||
|
||
기본 ordered event:
|
||
|
||
```text
|
||
partitionKeyText =
|
||
lowerHex(
|
||
SHA-256(
|
||
UTF8("ca-skeleton.messaging.partition-key.v1") || 0x00
|
||
|| u32be(len(UTF8(tenantScope))) || UTF8(tenantScope)
|
||
|| u32be(len(UTF8(logicalDestinationId))) || UTF8(logicalDestinationId)
|
||
|| u32be(len(UTF8(aggregateType))) || UTF8(aggregateType)
|
||
|| u32be(len(UTF8(aggregateId))) || UTF8(aggregateId)
|
||
)
|
||
)
|
||
partitionKeyBytes = US_ASCII(partitionKeyText)
|
||
```
|
||
|
||
규칙:
|
||
|
||
- `tenantScope`는 §11.5의 canonical non-null scope다;
|
||
- `u32be`는 뒤따르는 UTF-8 byte length의 unsigned 32-bit big-endian 표현이다;
|
||
- 결과는 정확히 64자의 lowercase hexadecimal text이고 DB에는
|
||
`VARCHAR(64) NOT NULL` + lowercase-hex CHECK로 저장한다;
|
||
- 같은 ordering scope는 동일 text/bytes를 만든다;
|
||
- raw PII/tenant/user ID를 metric/log에 노출하지 않는다;
|
||
- null/blank key는 ordering-required contract에서 startup/runtime rejection이다;
|
||
- polling producer는 저장된 text의 US-ASCII bytes를 `ByteArraySerializer`로 보내고, CDC는 같은
|
||
PostgreSQL `VARCHAR`를 Kafka Connect `StringConverter`로 보내 같은 bytes를 만든다;
|
||
- producer와 CDC가 domain-separated length-prefix golden vector를 공유한다;
|
||
- custom partitioner가 key를 무시하면 해당 ordering card는 invalid다.
|
||
|
||
### 11.7 Attempt, claim과 generation
|
||
|
||
다음 identity는 event ID와 다르다.
|
||
|
||
| Identity | 용도 |
|
||
| --- | --- |
|
||
| `claimToken` | polling row의 현재 owner를 fence하는 opaque token |
|
||
| `deliveryGeneration` | operator replay/requeue가 만든 새 delivery lifecycle |
|
||
| `publicationAttemptId` | 한 application-level send attempt 진단 |
|
||
| `producerGeneration` | credential/settings rotation으로 생성된 producer runtime |
|
||
| `consumerId` | inbox effect identity |
|
||
| `replayOperationId` | audited replay 요청 |
|
||
|
||
attempt/generation을 consumer dedupe event ID로 사용하지 않는다.
|
||
|
||
### 11.8 Consumer identity
|
||
|
||
future inbox의 `consumerId`는 최소 다음을 compile한다.
|
||
|
||
```text
|
||
logical subscription
|
||
+ handler name
|
||
+ effect contract version
|
||
+ tenant dimension when storage is tenant-isolated
|
||
```
|
||
|
||
Kafka group ID가 배포 편의 때문에 바뀌어도 의도하지 않은 business effect 재적용이 일어나지
|
||
않도록 logical identity를 명시한다. group ID를 consumer identity에 포함해야 하는 deployment는
|
||
그 관계를 descriptor에 고정한다. intentional reprocessing은 새 `replayGeneration`과 승인을
|
||
요구한다.
|
||
|
||
### 11.9 Clock authority
|
||
|
||
- `occurredAt`: event가 일어난 application/domain wall-clock fact;
|
||
- `createdAt`: database insert time;
|
||
- claim lease, retry due, retention cutoff: database time authority;
|
||
- producer deadline: monotonic process clock;
|
||
- broker record timestamp: event timestamp policy 또는 broker append time descriptor.
|
||
|
||
여러 pod의 wall-clock으로 claim lease를 판정하지 않는다. database time을 사용하지 못하면 허용
|
||
clock-skew bound와 failure policy를 card에 포함한다.
|
||
|
||
## 12. Integration event pipeline
|
||
|
||
### 12.1 Domain event와 integration event
|
||
|
||
Domain event를 그대로 JSON으로 직렬화하지 않는다.
|
||
|
||
```text
|
||
DomainEvent
|
||
-> feature application mapper
|
||
-> IntegrationEventDraft
|
||
-> bounded local contract compiler/encoder
|
||
-> ValidatedIntegrationEvent
|
||
-> immutable outbox_event
|
||
-> WireEnvelope v1
|
||
```
|
||
|
||
feature application mapper가 소유하는 것은 외부에 공개할 semantic field 선택이다. encoder가
|
||
소유하는 것은 UTF-8 JSON encoding, schema validation, byte bound와 checksum이다. mapper에
|
||
retry/topic/security 정책을 넣지 않고 encoder에 business rule을 넣지 않는다.
|
||
|
||
### 12.2 Draft
|
||
|
||
개념적인 draft shape는 다음과 같다.
|
||
|
||
```java
|
||
record IntegrationEventDraft<P extends IntegrationPayload>(
|
||
EventId eventId,
|
||
ContractId contractId,
|
||
int payloadVersion,
|
||
LogicalDestinationId destinationId,
|
||
AggregateIdentity aggregate,
|
||
AggregateOrder order,
|
||
Instant occurredAt,
|
||
CorrelationId correlationId,
|
||
Optional<CausationId> causationId,
|
||
Optional<TenantId> tenantId,
|
||
P featurePayload) {}
|
||
```
|
||
|
||
이는 구현 이름을 강제하는 Java API가 아니라 ownership을 보여주는 pseudocode다.
|
||
`featurePayload`는 §9.8 contribution의 exact type token으로 등록된 typed immutable Java
|
||
record다. JSON tree, Jackson node, raw map/string, Kafka record가 application contract가 되지
|
||
않는다.
|
||
|
||
### 12.3 Local contract compiler/encoder
|
||
|
||
Application은 framework-free port를 통해 deterministic local encoder를 사용할 수 있다. 실제
|
||
JSON/schema library는 outbound messaging adapter가 소유한다.
|
||
|
||
encoder는:
|
||
|
||
- startup에 schema/catalog를 precompile한다;
|
||
- runtime remote schema fetch를 하지 않는다;
|
||
- bounded CPU/memory 안에서 typed payload를 JSON으로 encode한다;
|
||
- envelope/payload schema, duplicate key, depth와 exact UTF-8 bytes를 검증한다;
|
||
- immutable serialized document와 schema/catalog digest를 반환한다.
|
||
|
||
first encoder는 같은 logical event가 같은 exact UTF-8 document를 만들도록 deterministic field
|
||
order와 scalar rendering을 고정한다. 저장·재발행·CDC의 authority는 이 exact byte document이며
|
||
JSONB 재직렬화 결과가 아니다.
|
||
|
||
business transaction 안에서 호출될 경우 local computation만 수행하고 network, broker,
|
||
filesystem, secret refresh를 하지 않는다. encoding 비용이 transaction budget을 넘는 event는
|
||
transaction 전에 immutable input을 준비하거나 별도 staged workflow를 사용한다.
|
||
|
||
### 12.4 Transaction sequence
|
||
|
||
durable application command의 기본 순서는 다음이다.
|
||
|
||
```text
|
||
1. command/idempotency/authorization validation
|
||
2. tx.inWrite begin
|
||
3. domain aggregate load + invariant check + mutation
|
||
4. domain event -> integration-event draft mapping
|
||
5. precompiled local encoder validation
|
||
6. business state save
|
||
7. outbox_event INSERT
|
||
8. polling mode이면 outbox_delivery INSERT
|
||
9. commit
|
||
```
|
||
|
||
4–8 중 하나라도 실패하면 business write도 rollback한다. broker send는 이 transaction 안에서
|
||
수행하지 않는다.
|
||
|
||
same-store는 이름뿐인 가정이 아니다. compiled card는 `transactionResourceId`를 갖고 business
|
||
repository, `TransactionPort`, `OutboxAppendPort`, outbox migration이 같은 resolved
|
||
`DataSource`/`EntityManagerFactory`/`PlatformTransactionManager` resource에 bind됐는지 startup에
|
||
검증한다. multi-datasource deployment는 contract별 resource binding을 명시한다. 다른 resource면
|
||
ACTIVE를 거부한다. real rollback test가 이 identity assertion을 보완한다.
|
||
|
||
dispatch mode 판단은 feature mapper가 하지 않는다. persistence append adapter가 같은
|
||
transaction에서 §27의 active publication epoch를 읽고 event에 epoch/authority를 기록한 뒤,
|
||
`POLLING_V2`일 때만 delivery row를 함께 만든다.
|
||
|
||
### 12.5 Validated event와 stored event
|
||
|
||
`ValidatedIntegrationEvent`는 최소 다음을 가진다.
|
||
|
||
```text
|
||
all stable identities
|
||
envelopeVersion
|
||
payloadVersion
|
||
logicalDestinationId
|
||
partitionKeyText and its exact US-ASCII bytes
|
||
validated envelope JSON bytes/document
|
||
contentType
|
||
schemaSetHash
|
||
envelopeSha256
|
||
envelopeSchemaHash
|
||
payloadSchemaHash
|
||
contractCatalogRevision
|
||
destinationBindingRevision
|
||
validated traceparent/tracestate allowlist
|
||
```
|
||
|
||
`publicationEpoch`, `dispatchAuthority`, `transactionResourceId`, DB-authoritative `createdAt`은
|
||
encoder 결과가 아니다. `OutboxAppendPort`의 persistence 구현이 caller의 write transaction
|
||
안에서 ACTIVE epoch를 읽고 same-store resource identity를 확인한 뒤 이 네 값을 더해
|
||
`StoredOutboxEvent`를 구성한다. 따라서 transaction 전에 만들어 둔 validated bytes가 stale
|
||
application setting의 authority를 내장하거나 application clock을 DB creation time으로 가장하지
|
||
않는다.
|
||
|
||
retry 때 payload를 다시 business object에서 직렬화하지 않는다. polling attempt는 저장된 같은
|
||
identity와 validated document를 사용한다.
|
||
|
||
`envelopeSha256`은 다음 exact input으로 계산한다.
|
||
|
||
```text
|
||
SHA-256(
|
||
UTF8("ca-skeleton.messaging.envelope.v1") || 0x00
|
||
|| u32be(len(exactEnvelopeBytes))
|
||
|| exactEnvelopeBytes
|
||
)
|
||
```
|
||
|
||
여기서 `u32be`는 §11.6과 같은 unsigned 32-bit big-endian byte length다.
|
||
이는 integrity/collision diagnosis용이지 confidentiality control이 아니다. DB/API/log/metric에
|
||
노출하지 않고 payload와 같은 access control/retention을 적용한다. 같은 event ID에서 다른
|
||
envelope hash는 duplicate가 아니라 collision/quarantine이다. semantic JSON을 JSONB로 round-trip한
|
||
뒤 다시 hash하지 않는다.
|
||
|
||
### 12.6 Polling/CDC wire parity
|
||
|
||
polling과 CDC는 같은 logical `WireEnvelope v1`을 emit한다.
|
||
|
||
- field와 semantic value가 같아야 한다;
|
||
- event ID, contract ID, versions, key가 같아야 한다;
|
||
- JSON object member byte ordering 차이를 허용할지 card가 명시한다;
|
||
- first baseline은 polling retry에서 exact stored UTF-8 bytes 재사용을 요구한다;
|
||
- CDC는 golden semantic equality와 consumer decode equality를 통과한다;
|
||
- “같은 contract”를 단순히 비슷한 JSON이라고 표현하지 않는다.
|
||
|
||
## 13. Contract catalog, destination binding과 topic
|
||
|
||
### 13.1 두 catalog
|
||
|
||
Contract catalog는 code/repository artifact다.
|
||
|
||
```text
|
||
contractId
|
||
payload versions
|
||
owner module
|
||
logical destination
|
||
payload schema resource/hash
|
||
serializer id
|
||
ordering requirement
|
||
partition-key policy
|
||
maximum payload/envelope bytes
|
||
sensitivity classification
|
||
supported producer/consumer version matrix
|
||
same-event requeue horizon
|
||
```
|
||
|
||
Destination binding은 deployment configuration다.
|
||
|
||
```text
|
||
logical destination
|
||
Kafka cluster binding
|
||
physical topic
|
||
expected partitions
|
||
minimum replication factor
|
||
minimum in-sync replicas
|
||
cleanup policy
|
||
retention expectation
|
||
maximum record bytes
|
||
security profile
|
||
required readiness
|
||
```
|
||
|
||
contract가 infrastructure topology를 소유하지 않고 configuration이 business schema를
|
||
재정의하지 않는다.
|
||
|
||
### 13.2 Compile
|
||
|
||
startup compiler는 다음을 합성한다.
|
||
|
||
```text
|
||
contract descriptor
|
||
+ destination descriptor
|
||
+ producer provider descriptor
|
||
+ serialization descriptor
|
||
+ security descriptor
|
||
+ evidence card
|
||
= CompiledPublicationBinding
|
||
```
|
||
|
||
검증:
|
||
|
||
- contract/destination ID unique;
|
||
- 모든 active contract에 정확히 한 destination binding;
|
||
- unknown destination/topic 금지;
|
||
- ordering-required contract에 nonblank stable key;
|
||
- contract maximum bytes <= destination/provider/topic bounds;
|
||
- schema/catalog hash가 evidence와 일치;
|
||
- production profile과 security profile 호환;
|
||
- dispatch mode와 provider 요구 일치;
|
||
- required binding은 release-eligible evidence 보유.
|
||
|
||
### 13.3 Configuration override 제한
|
||
|
||
설정은 code contract를 약화하지 못한다.
|
||
|
||
- code maximum record bytes보다 크게 override할 수 없다;
|
||
- ordering-required를 `NONE`으로 낮출 수 없다;
|
||
- schema validation을 끌 수 없다;
|
||
- production TLS 요구를 plaintext로 바꿀 수 없다;
|
||
- required destination을 optional로 바꿀 수 없다;
|
||
- unknown compatibility mode를 선택할 수 없다.
|
||
|
||
더 엄격한 deployment bound는 허용한다.
|
||
|
||
### 13.4 Topic naming
|
||
|
||
physical topic은 operator-owned static value다.
|
||
|
||
- request/event/tenant 값을 문자열 보간하지 않는다;
|
||
- environment prefix/suffix는 binding compiler가 allowlist pattern으로 검증한다;
|
||
- producer principal은 production에서 Create/Delete/Alter 권한을 갖지 않는다;
|
||
- auto-create를 끈다;
|
||
- topic rename은 새 binding/revision과 migration runbook을 요구한다.
|
||
|
||
### 13.5 Topic topology attestation
|
||
|
||
ACTIVE startup 또는 pre-deploy gate는 최소 다음을 확인한다.
|
||
|
||
- topic 존재;
|
||
- expected partition count;
|
||
- replication factor가 minimum 이상;
|
||
- `min.insync.replicas`가 policy minimum 이상;
|
||
- cleanup policy;
|
||
- retention/replay horizon;
|
||
- topic maximum message bytes;
|
||
- unclean leader election 관련 cluster/topic policy가 deployment 요구와 호환;
|
||
- producer principal의 최소 Describe/Write 동작;
|
||
- consumer/DLT profile이 있을 때 대응 Read/Write ACL.
|
||
|
||
`acks=all`만 확인하고 replication/min ISR를 보지 않은 상태를 durable topology로 표시하지 않는다.
|
||
|
||
first tuple은 verification source를 항목별로 고정한다.
|
||
|
||
| 항목 | Runtime source | Release/provisioning source |
|
||
| --- | --- | --- |
|
||
| cluster identity, topic existence, partition/leader/ISR/RF | producer principal의 bounded AdminClient `Describe` | IaC expected resource identity |
|
||
| topic cleanup/retention/max bytes/min ISR/topic override | exact topic 범위 read-only `DescribeConfigs` | IaC rendered config/digest |
|
||
| broker-level unclean election/default/max bounds/auto-create | runtime에서 과도한 cluster config 권한을 요구하지 않음 | signed/provenance-attested broker policy |
|
||
| exact-topic Write와 denied Create/Alter/Delete/other-topic Write | startup에 임의 canary를 만들지 않음 | security release lane의 positive/negative probe |
|
||
| ACL/quota owner와 rollback | runtime ACL enumeration 금지 | IaC/security evidence |
|
||
|
||
release/provisioning evidence는 environment/cluster alias, topic resource identity, rendered
|
||
config/ACL policy digest, issuer/provenance, generated-at, expires-at와 release assertion digest를
|
||
가진다. missing, stale, wrong-cluster, signature/provenance failure 또는 runtime-observed 값과의
|
||
mismatch는 production ACTIVE를 fail-closed한다. runtime에서 확인할 수 없는 값을 “검증됨”으로
|
||
표시하지 않고 descriptor에 source와 freshness를 함께 노출한다.
|
||
|
||
### 13.6 Partition expansion
|
||
|
||
Kafka default key partitioning에서 partition 수가 바뀌면 같은 key가 다른 partition으로 이동할 수
|
||
있다. rolling producer/consumer 기간에는 old/new partition의 event order가 섞일 수 있다.
|
||
|
||
ordering-required topic은 in-place partition expansion을 일반적인 무중단 변경으로 취급하지
|
||
않는다. 기본 절차는 새 topic/binding generation, write cutover watermark, consumer dual-read
|
||
또는 drain, order reconciliation과 rollback이다.
|
||
|
||
event append 시 `destinationBindingRevision`을 immutable capture한다. retry는 같은 revision을
|
||
resolve하며 current config의 새 topic으로 조용히 reroute하지 않는다. backlog를 새 binding으로
|
||
옮기려면 audited delivery generation/explicit migration을 사용한다.
|
||
|
||
### 13.7 Compaction
|
||
|
||
first baseline topic은 delete-retention event log다. compaction은 다음이 모두 정의된 contract만
|
||
별도 card로 선택한다.
|
||
|
||
- key가 entity state identity인지;
|
||
- tombstone 의미;
|
||
- intermediate event 손실 허용 여부;
|
||
- consumer bootstrap 의미;
|
||
- minimum compaction lag;
|
||
- delete retention;
|
||
- replay/ordering 영향.
|
||
|
||
integration event에 compaction을 기본 적용하지 않는다.
|
||
|
||
## 14. JSON envelope v1과 schema evolution
|
||
|
||
### 14.1 Dialect와 resource ownership
|
||
|
||
first baseline은 JSON Schema Draft 2020-12를 사용한다.
|
||
|
||
공통 envelope schema 예시 위치:
|
||
|
||
```text
|
||
src/shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json
|
||
```
|
||
|
||
sample payload schema 예시 위치:
|
||
|
||
```text
|
||
src/sample-portfolio/src/main/resources/contracts/messaging/
|
||
portfolio.worklog.reserved/v1.schema.json
|
||
```
|
||
|
||
실제 production project는 feature owner module에 payload schema를 둔다. 각 schema는:
|
||
|
||
- explicit `$schema`;
|
||
- immutable absolute `$id`;
|
||
- contract/payload version;
|
||
- checked-in checksum manifest;
|
||
- local prebundled `$ref` allowlist;
|
||
- owner와 compatibility vectors를 가진다.
|
||
|
||
runtime HTTP/file remote `$ref` resolution은 금지한다.
|
||
|
||
### 14.2 Envelope shape
|
||
|
||
개념적인 envelope v1:
|
||
|
||
```json
|
||
{
|
||
"envelopeVersion": 1,
|
||
"eventId": "019...",
|
||
"contractId": "portfolio.worklog.reserved",
|
||
"payloadVersion": 1,
|
||
"logicalDestination": "portfolio-domain-events",
|
||
"aggregate": {
|
||
"type": "worklog",
|
||
"id": "worklog-42",
|
||
"sequence": 17,
|
||
"eventIndex": 0
|
||
},
|
||
"occurredAt": "2026-07-28T05:10:30.123Z",
|
||
"correlationId": "corr-...",
|
||
"causationId": "cause-...",
|
||
"contentType": "application/json",
|
||
"payload": {
|
||
"workLogId": "worklog-42"
|
||
}
|
||
}
|
||
```
|
||
|
||
tenant가 실제로 활성인 deployment만 bounded `tenantId`를 포함한다. causation ID가 없을 때
|
||
null로 넣을지 field를 생략할지는 envelope schema가 하나로 고정한다.
|
||
|
||
envelope에는 다음을 넣지 않는다.
|
||
|
||
- physical topic/cluster/bootstrap server;
|
||
- delivery status/attempt/backoff/claim token;
|
||
- Kafka offset/partition;
|
||
- credential/security profile;
|
||
- Java class name;
|
||
- raw exception;
|
||
- arbitrary baggage;
|
||
- mutable consumer state.
|
||
|
||
### 14.3 Envelope/header ownership
|
||
|
||
authoritative semantic metadata는 envelope다. Kafka header는 transport 기능에 필요한 bounded
|
||
allowlist만 사용한다.
|
||
|
||
초기 header allowlist 후보:
|
||
|
||
```text
|
||
id
|
||
contract-id
|
||
payload-version
|
||
traceparent
|
||
tracestate
|
||
```
|
||
|
||
first header의 `id`는 Debezium Outbox Event Router와 polling producer가 공유하는 event ID다.
|
||
envelope와 header에 중복된 identity가 다르면 producer와 consumer 모두 reject한다. header 이름,
|
||
개수, key bytes, total bytes를 제한한다. arbitrary inbound header forwarding은 금지한다.
|
||
|
||
### 14.4 Strict versioning
|
||
|
||
first baseline은 `STRICT_VERSIONED` 정책을 사용한다.
|
||
|
||
- 같은 schema version file을 수정하지 않는다;
|
||
- optional field 추가도 새 payload version을 만든다;
|
||
- consumer가 새 version을 지원한 뒤 producer를 배포한다;
|
||
- rolling overlap 동안 consumer는 최소 명시된 N/N-1 version allowlist를 가진다;
|
||
- producer가 지원 종료된 version을 emit하지 않는다는 release assertion을 둔다;
|
||
- unsupported future version은 일반 retry 대상이 아니다.
|
||
|
||
JSON Schema diff heuristic만으로 backward/full compatibility를 주장하지 않는다. 실제 old/new
|
||
reader/writer golden vectors가 compatibility evidence다. Kafka/DLT/archive/inbox replay horizon
|
||
안에 남아 있는 **모든** payload version은 reader support를 유지한다. version retirement는
|
||
해당 version의 source/DLT/archive가 더 이상 replay 가능하지 않거나 versioned upcaster가
|
||
qualification됐다는 purge proof가 있어야 한다. N/N-1은 replay horizon을 대신하지 않는다.
|
||
|
||
### 14.5 Object와 unknown-field policy
|
||
|
||
- envelope v1은 `unevaluatedProperties: false`로 닫는다;
|
||
- payload schema도 first baseline에서 explicit property set을 사용한다;
|
||
- additive evolution은 in-place field 추가가 아니라 payload version 증가로 처리한다;
|
||
- tolerant-reader card를 나중에 추가할 수 있지만 그때 unknown-field behavior와 rolling vectors를
|
||
별도 증명한다.
|
||
|
||
### 14.6 Scalar/collection policy
|
||
|
||
각 schema가 표현할 수 있는 범위는 최소 다음을 명시한다.
|
||
|
||
- `required`;
|
||
- null과 missing의 차이;
|
||
- string `minLength/maxLength`와 Unicode normalization policy;
|
||
- array maximum items;
|
||
- integer/number semantic range;
|
||
- enum evolution;
|
||
- timestamp string format;
|
||
- object property count;
|
||
|
||
JSON Schema `format`은 implementation에 따라 annotation일 수 있다. runtime validator에서 format
|
||
assertion을 켰다는 evidence를 만들거나 timestamp/UUID 등을 explicit parser로 검증한다.
|
||
|
||
Draft 2020-12가 직접 표현하지 않는 exact UTF-8 byte 수, nesting depth, numeric precision,
|
||
exponential notation/canonical lexical form, parser time/memory는
|
||
`json-codec-admission-v1`의 별도 규칙이다. `maxLength`를 byte limit으로 오해하거나 custom
|
||
keyword 없이 schema가 이 제한을 증명한다고 쓰지 않는다.
|
||
|
||
### 14.7 Parser hardening
|
||
|
||
codec은 다음을 거부한다.
|
||
|
||
- malformed UTF-8;
|
||
- duplicate object member names;
|
||
- unpaired surrogate;
|
||
- excessive nesting;
|
||
- maximum을 넘는 string/array/object;
|
||
- resource budget을 넘는 arbitrary-precision number;
|
||
- trailing garbage;
|
||
- non-finite number;
|
||
- remote reference;
|
||
- polymorphic Java type metadata.
|
||
|
||
validation CPU/memory/time budget을 test한다. record size는 Java character 수가 아니라 최종 UTF-8
|
||
key + value + header bytes로 계산한다.
|
||
|
||
schema compiler는 exact offline resource registry만 사용한다.
|
||
|
||
- duplicate `$id`/unknown vocabulary/unknown dialect 거부;
|
||
- remote URI가 local allowlist resource로 정확히 resolve되지 않으면 거부;
|
||
- cyclic/recursive `$ref`는 명시적 depth/resource budget 안에서 지원하거나 compile-time 거부;
|
||
- pathological regular expression/validator recursion adversarial corpus;
|
||
- meta-schema와 vocabulary도 pinned local checksum 대상;
|
||
- startup precompile 뒤 runtime schema fetch 0.
|
||
|
||
### 14.8 Envelope document hash
|
||
|
||
§12.5의 exact envelope byte hash는:
|
||
|
||
- same event ID document collision 탐지;
|
||
- polling retry exact-document 확인;
|
||
- CDC shadow byte parity;
|
||
- audit/diagnosis
|
||
|
||
에 사용한다. algorithm/input은 §12.5 하나만 정본으로 사용한다. hash를 metric tag로 쓰지 않고
|
||
restricted storage 밖에 노출하지 않는다.
|
||
|
||
### 14.9 Schema registry future card
|
||
|
||
Avro/Protobuf/JSON Schema registry card가 추가되면 다음을 별도로 설계·검증한다.
|
||
|
||
- subject naming;
|
||
- compatibility mode;
|
||
- registry auth/TLS/readiness;
|
||
- schema ID cache와 outage behavior;
|
||
- generated code ownership;
|
||
- rolling compatibility;
|
||
- registry unavailable 시 write policy;
|
||
- schema deletion/retention;
|
||
- cross-cluster replication.
|
||
|
||
first JSON Schema card에는 registry 설정 key나 placeholder를 추가하지 않는다.
|
||
|
||
## 15. Application contract와 publication outcome
|
||
|
||
### 15.1 Durable append port
|
||
|
||
`OutboxAppendPort`는 provider-neutral same-store append 의미를 유지한다. target input은
|
||
legacy raw `String payload`가 아니라 validated integration event다.
|
||
|
||
```java
|
||
interface OutboxAppendPort {
|
||
void append(ValidatedIntegrationEvent event);
|
||
}
|
||
```
|
||
|
||
실제 이름은 implementation plan에서 정하지만 다음은 변하지 않는다.
|
||
|
||
- application/core 타입;
|
||
- same transaction requirement;
|
||
- provider/physical topic 없음;
|
||
- immutable event identity;
|
||
- validation/catalog evidence 포함.
|
||
|
||
### 15.2 Publish port
|
||
|
||
target publish port는 expected technical outcome을 exception 하나로 뭉치지 않는다.
|
||
|
||
```java
|
||
sealed interface PublicationOutcome {
|
||
record Acknowledged(PublicationReceipt receipt) implements PublicationOutcome {}
|
||
record AcknowledgedMismatch(PublicationReceipt receipt) implements PublicationOutcome {}
|
||
record Rejected(PublicationFailure failure) implements PublicationOutcome {}
|
||
record Indeterminate(PublicationFailure failure) implements PublicationOutcome {}
|
||
}
|
||
```
|
||
|
||
`PublicationReceipt`는 Kafka SDK 타입 대신 다음 같은 bounded provider-neutral reference를
|
||
가진다.
|
||
|
||
```text
|
||
providerId
|
||
logicalDestinationId
|
||
providerGeneration
|
||
ackObservedAt
|
||
opaque bounded providerRecordReference
|
||
```
|
||
|
||
physical topic/partition/offset가 persistence audit에 필요하면 adapter가 safe bounded string으로
|
||
만들며 application이 이를 routing에 사용하지 않는다. `ackObservedAt`은 broker clock이 아니라
|
||
local future-completion 관찰 시각이다.
|
||
|
||
#### Late completion observation boundary
|
||
|
||
동기 publish 결과가 `INDETERMINATE`로 반환된 뒤 Kafka future가 완료될 수 있으므로 다음
|
||
provider-neutral application ports를 둔다.
|
||
|
||
```java
|
||
interface LatePublicationObservationSourcePort {
|
||
List<LatePublicationObservation> pollBounded(int maximum);
|
||
void acknowledgePersisted(ObservationId id);
|
||
void releaseForRetry(ObservationId id);
|
||
}
|
||
|
||
interface OutboxAttemptObservationPort {
|
||
void appendLateObservations(List<LatePublicationObservation> observations);
|
||
}
|
||
```
|
||
|
||
- outbound messaging adapter가 bounded payload-free queue로 source port를 구현한다;
|
||
- persistence adapter가 append port를 구현한다;
|
||
- application의 `RecordLatePublicationObservationsUseCase`는 다음 순서를 고정한다:
|
||
`poll/lease bounded batch -> tx.inNew(idempotent append) 정상 반환 -> acknowledgePersisted`.
|
||
`tx.inNew(...)`의 정상 반환은 commit 완료를 뜻하며 source ACK를 transaction callback 안에서
|
||
호출하지 않는다;
|
||
- `app-bootstrap`은 provider ACTIVE일 때만 bounded drain scheduler를 조립한다;
|
||
- Kafka callback thread는 JPA/repository/transaction을 직접 호출하지 않는다;
|
||
- observation identity는
|
||
`(eventId, deliveryGeneration, publicationAttemptId, LATE_ACK_OBSERVED)`이고 DB unique/ON
|
||
CONFLICT로 duplicate drain을 흡수한다;
|
||
- callback/timeout은 adapter-local atomic terminal marker로 단 한 synchronous outcome을
|
||
결정한다. deadline marker가 먼저 이기고 ACK가 나중에 오면 queue에 late observation 하나만
|
||
제안한다;
|
||
- DB append 또는 commit 실패는 `releaseForRetry`로 item을 bounded retry에 되돌리고 delivery
|
||
state를 바꾸지 않는다;
|
||
- DB commit 뒤 source ACK 전 process crash는 같은 observation의 duplicate drain을 만들 수
|
||
있으며 DB unique/`ON CONFLICT`가 이를 흡수한다.
|
||
|
||
attempt admission과 deadline 시점의 `INDETERMINATE` outcome journal은 authoritative하고 반드시
|
||
Tx B/Tx C에서 영속화한다. 반면 process crash나 queue overflow로 late callback diagnostic 자체를
|
||
잃을 수 있으므로 `LATE_ACK_OBSERVED` capture를 delivery correctness 근거로 사용하지 않는다.
|
||
queue overflow/drop은 bounded metric, readiness degradation과 alert 대상이며 capacity
|
||
qualification에서는 0이어야 한다. 이 경계 덕분에 messaging→persistence project dependency를
|
||
추가하지 않는다.
|
||
|
||
### 15.3 Failure stage
|
||
|
||
최소 stage:
|
||
|
||
```text
|
||
CONTRACT_COMPILE
|
||
SERIALIZATION
|
||
LOCAL_ADMISSION
|
||
METADATA
|
||
SEND
|
||
BROKER_ACK
|
||
DEADLINE
|
||
SHUTDOWN
|
||
PROVIDER
|
||
```
|
||
|
||
최소 failure class:
|
||
|
||
```text
|
||
INVALID_CONTRACT
|
||
INVALID_PAYLOAD
|
||
RECORD_TOO_LARGE
|
||
DESTINATION_MISSING
|
||
UNAUTHORIZED
|
||
AUTHENTICATION_FAILED
|
||
TLS_FAILED
|
||
TOPIC_POLICY_MISMATCH
|
||
BUFFER_EXHAUSTED
|
||
BROKER_UNAVAILABLE
|
||
THROTTLED
|
||
DEADLINE_EXCEEDED
|
||
CLIENT_CLOSED
|
||
UNKNOWN_PROVIDER_FAILURE
|
||
```
|
||
|
||
exception class name이나 message를 stable application contract로 사용하지 않는다.
|
||
|
||
### 15.4 Certainty와 retry disposition
|
||
|
||
acceptance certainty와 retryability는 독립 축이다.
|
||
|
||
```text
|
||
acceptanceCertainty = NOT_ACCEPTED | ACCEPTED | INDETERMINATE | ACCEPTED_MISMATCH
|
||
retryDisposition = NO_RETRY | RETRY_WITHIN_BUDGET | STOP_PROVIDER | OPERATOR_HOLD
|
||
```
|
||
|
||
`REJECTED`는 provider가 broker acceptance가 없음을 확정할 수 있을 때만 사용한다.
|
||
|
||
예:
|
||
|
||
- schema/size/local catalog rejection;
|
||
- local admission 전 rejection;
|
||
- definitive broker authorization rejection;
|
||
- startup AdminClient attestation이 send admission 전에 확정한 missing destination.
|
||
|
||
`INDETERMINATE` 예:
|
||
|
||
- send 뒤 deadline;
|
||
- ACK response loss;
|
||
- connection break after request write;
|
||
- callback/cancel race;
|
||
- shutdown 중 unresolved in-flight;
|
||
- `NotEnoughReplicasAfterAppendException`처럼 append 뒤 실패할 수 있는 broker 응답;
|
||
- post-admission `UnknownTopicOrPartitionException`, retriable/unknown producer exception;
|
||
- provider가 acceptance를 증명할 수 없는 unknown exception.
|
||
|
||
Kafka `RetriableException`이라는 사실은 미수락 증거가 아니다. 분류가 애매하면
|
||
`INDETERMINATE`가 안전한 기본이다. retry 여부는 certainty를 바꾸지 않고 remaining
|
||
attempt/elapsed budget과 producer health로 결정한다.
|
||
|
||
### 15.5 Relay decision
|
||
|
||
| Publication outcome | Polling action |
|
||
| --- | --- |
|
||
| ACKNOWLEDGED | claim-token/valid-lease CAS로 `DELIVERY_RECORDED` 기록 |
|
||
| ACKNOWLEDGED_MISMATCH | relay scope HOLD, producer readiness DOWN, misroute incident |
|
||
| definite transient REJECTED | retry budget이 남으면 RETRY_WAIT |
|
||
| definite permanent REJECTED | 즉시 EXHAUSTED/operator disposition |
|
||
| INDETERMINATE | duplicate 가능성을 기록하고 bounded retry/reconciliation path |
|
||
| programming invariant failure | cycle 실패 + readiness/alert; 일반 transient로 숨기지 않음 |
|
||
|
||
report는 persisted transition이 성공한 뒤에만 emit한다. report adapter failure는 authoritative
|
||
state를 바꾸지 않는다.
|
||
|
||
### 15.6 Best-effort와 durable port
|
||
|
||
두 contract는 계속 분리한다.
|
||
|
||
- best-effort: persistence/replay 없음, bounded attempt 뒤 failure를 삼킬 수 있음;
|
||
- durable: transactionally stored event, polling/CDC, explicit terminal disposition.
|
||
|
||
best-effort가 내부적으로 같은 ACK-aware producer를 사용해도 durable로 승격되지 않는다.
|
||
durable append를 자동 수행하지도 않는다.
|
||
|
||
## 16. Spring Kafka producer protocol
|
||
|
||
### 16.1 Runtime ownership
|
||
|
||
`adapter:outbound:messaging`이 다음을 직접 만든다.
|
||
|
||
- `DefaultKafkaProducerFactory<byte[], byte[]>`;
|
||
- `KafkaTemplate<byte[], byte[]>`;
|
||
- bounded AdminClient/topology attestor;
|
||
- provider generation의 생성/drain/close/attestation primitive owner;
|
||
- observation convention;
|
||
- resolved credential/certificate material을 한 provider generation에 적용하는 owner.
|
||
|
||
`adapter:outbound:messaging`은 durable delivery state나 HOLD 정책을 결정하지 않는다.
|
||
`application-core`의 rotation use case가 provider-neutral lifecycle fact를 받아
|
||
`INDETERMINATE/HOLD` persistence와 generation barrier/admission 재개를 조정하고,
|
||
`app-bootstrap`이 secret refresh와 그 use case invocation을 compose한다.
|
||
|
||
classpath presence나 generic `spring.kafka.bootstrap-servers=localhost:9092` default로 활성화하지
|
||
않는다. canonical messaging binding이 ACTIVE일 때만 만든다.
|
||
|
||
### 16.2 Adapter-private gateway
|
||
|
||
provider-private SPI는 ACK를 표현해야 한다.
|
||
|
||
```java
|
||
interface KafkaPublishGateway {
|
||
KafkaAttemptOutcome publish(
|
||
CompiledKafkaRecord record,
|
||
MonotonicDeadline deadline);
|
||
}
|
||
```
|
||
|
||
이 SPI는 outbound adapter 내부 또는 package-private다. `KafkaTemplate`, `SendResult`,
|
||
`RecordMetadata`를 application/shared에 노출하지 않는다.
|
||
|
||
### 16.3 Send sequence
|
||
|
||
```text
|
||
1. compiled binding lookup
|
||
2. immutable envelope/key/header byte verification
|
||
3. local admission acquire
|
||
4. ProducerRecord<byte[], byte[]> construction
|
||
5. KafkaTemplate.send
|
||
6. send future를 monotonic deadline까지 await
|
||
7. RecordMetadata와 expected destination 검증
|
||
8. ACKNOWLEDGED / ACKNOWLEDGED_MISMATCH / REJECTED / INDETERMINATE map
|
||
9. admission/resource release
|
||
```
|
||
|
||
serialization은 prevalidated bytes를 사용하는 Kafka `ByteArraySerializer` 계열로 단순화한다.
|
||
Kafka serializer callback 안에서 business JSON serialization이나 remote schema lookup을 하지
|
||
않는다.
|
||
|
||
### 16.4 ACK condition
|
||
|
||
broker ACK 관찰은 다음으로 정의한다.
|
||
|
||
```text
|
||
future completed successfully
|
||
AND metadata is present
|
||
```
|
||
|
||
metadata topic이 compiled topic과 같으면 `ACKNOWLEDGED`, 다르면
|
||
`ACKNOWLEDGED_MISMATCH`다. deadline 뒤 future가 성공해도 ACK 관찰 사실은 append-only attempt
|
||
journal drain이 성공한 경우에만 `LATE_ACK_OBSERVED`로 남으며, 이미 정한 application
|
||
outcome/delivery state를 뒤집지 않는다. drain 전 crash/overflow로 진단 관찰을 잃을 수 있다는
|
||
§15.2의 한계가 적용된다. provider generation의 현재 선택 여부도 broker fact 자체를 바꾸지
|
||
않는다.
|
||
|
||
`acks=0`은 모든 selected profile에서 금지한다. first R2는 `acks=all`이다. `acks=all`은 모든
|
||
configured replica가 아니라 당시 ISR의 ACK를 뜻하므로 §13.5의 replication/min ISR/unclean
|
||
leader policy attestation과 함께 해석한다.
|
||
|
||
### 16.5 Deadline/cancellation/late completion
|
||
|
||
future await deadline이 끝나면:
|
||
|
||
- application outcome은 `INDETERMINATE`;
|
||
- `cancel()`이 broker delivery 취소를 보장한다고 가정하지 않는다;
|
||
- late callback은 §15.2의 bounded source port에 payload-free observation을 제안하고 application
|
||
drain이 성공한 경우에만 §18.3 append-only journal에 기록한다. persisted
|
||
retry/exhausted/HOLD transition을 뒤집지 않는다;
|
||
- attempt terminal state는 atomic one-way transition이다;
|
||
- late ACK와 다음 retry가 duplicate를 만들 수 있음을 관측한다.
|
||
|
||
deadline wrapper가 worker thread만 interrupt하고 producer request를 완전히 취소하지 못한다는
|
||
한계를 descriptor에 기록한다.
|
||
|
||
### 16.6 Effective producer configuration
|
||
|
||
first R2는 다음을 explicit setting과 startup assertion으로 고정한다.
|
||
|
||
```text
|
||
acks = all
|
||
enable.idempotence = true
|
||
retries = provider recommended effectively-unbounded/MAX
|
||
max.in.flight.requests.per.connection <= 5
|
||
delivery.timeout.ms = finite
|
||
request.timeout.ms = finite
|
||
max.block.ms = finite
|
||
buffer.memory = finite
|
||
batch.size = finite
|
||
linger.ms = finite
|
||
max.request.size = finite
|
||
```
|
||
|
||
그리고 다음 관계를 검증한다.
|
||
|
||
```text
|
||
delivery.timeout.ms >= request.timeout.ms + linger.ms
|
||
application attempt budget >=
|
||
admission wait budget + max.block.ms + delivery.timeout.ms + callback/transition reserve
|
||
claim remaining lease >
|
||
application attempt budget + DB transition reserve + clock/scheduling safety margin
|
||
```
|
||
|
||
Kafka library default가 현재 원하는 값과 같더라도 explicit effective config assertion을 둔다.
|
||
conflicting property가 idempotence를 끄면 startup을 실패시킨다.
|
||
|
||
`retries`를 작은 숫자로 잘라 broker retry를 임의 약화하지 않고 `delivery.timeout.ms`가 한
|
||
physical send의 시간 budget을 지배하게 한다. `request.timeout.ms`는 selected broker의
|
||
`replica.lag.time.max.ms`와 Kafka 권고 관계를 provisioning evidence로 검증한다.
|
||
|
||
size는 한 줄 부등식으로 합치지 않는다.
|
||
|
||
1. exact envelope + key + headers + record overhead가 contract record bound 안;
|
||
2. uncompressed record batch가 producer batch/request 제약 안;
|
||
3. compressed record batch가 topic `max.message.bytes`와 broker bound 안;
|
||
4. 여러 partition batch를 담을 수 있는 request가 `max.request.size` 안.
|
||
|
||
모든 limit에 protocol/header/batch headroom을 두며 payload와 request/topic candidate를 똑같이
|
||
1 MiB로 두지 않는다. adopted serializer/compression의 실제 encoded batch를 real broker에서
|
||
검증한다.
|
||
|
||
exact numeric default와 허용 범위는 implementation plan의 benchmark/fault test로 고정한다. 무한
|
||
또는 사실상 운영 shutdown/SLO를 넘는 값은 허용하지 않는다.
|
||
|
||
### 16.7 Retry ownership
|
||
|
||
Kafka client는 `delivery.timeout.ms` 안에서 같은 producer send를 retry할 수 있다. relay는 하나의
|
||
application attempt가 definite/indeterminate failure로 끝난 뒤 새 attempt를 만든다.
|
||
|
||
```text
|
||
physical Kafka retries
|
||
inside one publicationAttemptId
|
||
|
||
relay retries
|
||
new publicationAttemptId, same eventId and wire document
|
||
```
|
||
|
||
Kafka producer idempotence는 supported producer session의 client retries를 보호하지만 다음을
|
||
제거하지 않는다.
|
||
|
||
- producer restart 뒤 relay resend;
|
||
- ACK-to-DB gap;
|
||
- application deadline 뒤 late ACK + resend;
|
||
- polling과 CDC 이중 활성;
|
||
- operator replay.
|
||
|
||
fatal producer exception은 acceptance certainty와 별도로 generation lifecycle을 종료한다.
|
||
authorization/unsupported-version/out-of-order-sequence 또는 adopted client가 fatal로 정의한
|
||
상태는 즉시 new admission 차단, readiness DOWN, bounded close/recreate를 수행한다. 같은 defunct
|
||
producer를 계속 사용하지 않으며 새 generation이 application resend duplicate를 제거한다고
|
||
주장하지 않는다.
|
||
|
||
### 16.8 Flush
|
||
|
||
per-message `KafkaTemplate.flush()`를 금지한다. shared producer의 다른 batch를 강제로 flush하고
|
||
throughput/latency를 결합하기 때문이다. future completion으로 해당 record ACK를 기다린다.
|
||
|
||
flush는 bounded shutdown/explicit maintenance에서만 사용하고 그 보장과 timeout을 test한다.
|
||
|
||
### 16.9 Producer transaction
|
||
|
||
first polling provider는 Kafka transaction을 사용하지 않는다. Kafka transaction card가 later
|
||
추가되면 transactional ID uniqueness, producer fencing, cache size, timeout, abort, rolling deploy,
|
||
`read_committed` consumer까지 별도 evidence를 요구한다.
|
||
|
||
### 16.10 Producer generation과 rotation
|
||
|
||
credential/certificate/settings rotation은 immutable producer generation 교체로 처리한다.
|
||
소유권은 둘로 나뉜다. messaging adapter는 old/new provider generation의
|
||
pause/drain/create/attest/close primitive와 bounded fact만 제공한다. application rotation use
|
||
case는 그 fact를 바탕으로 unresolved attempt의 durable `INDETERMINATE/HOLD`, DB failure 시 전환
|
||
차단, generation barrier와 admission 재개 정책을 소유한다. bootstrap은 secret resolver와
|
||
application use case를 연결할 뿐 state policy를 구현하지 않는다.
|
||
|
||
```text
|
||
1. 신규 admission과 claim을 일시 중단
|
||
2. old generation의 admitted/in-flight future를 bounded drain
|
||
3. drain deadline의 unresolved attempt를 Tx C에서 INDETERMINATE로 기록하고 영향받은 ordering
|
||
scope를 HOLD
|
||
4. old generation을 bounded close하고 더 이상 callback을 authoritative outcome으로 사용하지 않음
|
||
5. 새 secret generation resolve
|
||
6. 새 producer compile/start/attest
|
||
7. 모든 old attempt가 ACK/REJECTED 또는 durable INDETERMINATE라는 application terminal
|
||
observation을 가진 뒤 generation barrier 전환
|
||
8. HOLD 없는 scope의 admission 재개; HOLD scope는 audited duplicate-risk disposition 뒤에만 재개
|
||
```
|
||
|
||
한 producer object의 mutable config를 바꾸지 않는다. old/new generation metric tag는 bounded
|
||
revision이어야 하며 secret value를 포함하지 않는다. first profile은 old/new generation send를
|
||
겹치지 않는 global barrier를 사용한다. 여기서 “resolved”는 broker acceptance가 definitively
|
||
밝혀졌다는 뜻이 아니라 state machine이 ACK/REJECTED/**INDETERMINATE** 중 하나를 durable하게
|
||
기록했다는 뜻이다. response loss의 영원한 확정을 기다리지 않는다.
|
||
|
||
barrier 전환 뒤 reorder-tolerant scope는 card가 허용한 bounded duplicate-aware retry를 자동
|
||
재개할 수 있다. ordering-required scope의 indeterminate head는 HOLD를 유지하고
|
||
§19.4 operator가 `REMEDIATE_AND_REQUEUE`, `SKIP_WITH_GAP`, `COMPENSATE` 중 하나를 선택한다. DB가
|
||
unavailable해 INDETERMINATE/HOLD를 durable하게 기록할 수 없으면 generation 전환과 admission을
|
||
계속 막는다. forced crash/indeterminate write 뒤 failure-path strict order는 주장하지 않고
|
||
aggregate sequence로 gap/regression을 탐지한다.
|
||
|
||
persistence가 없는 best-effort/direct caller는 durable HOLD 대상이 아니다. bounded drain 뒤
|
||
unresolved outcome을 caller/telemetry에 `INDETERMINATE`로 확정해 반환하고 새 generation을
|
||
전환하되, 자동 replay나 ordering 안전을 주장하지 않는다.
|
||
|
||
## 17. Ordering, retry budget, resource와 lifecycle
|
||
|
||
### 17.1 Ordering guarantee
|
||
|
||
Kafka가 제공하는 기본 ordering 범위는 한 partition 안이다. first R2의 정상 경로는 다음을
|
||
요구한다.
|
||
|
||
```text
|
||
stable physical topic generation
|
||
+ stable non-null partition key
|
||
+ idempotent producer-compatible config
|
||
+ aggregate total sequence
|
||
+ single authoritative aggregate-head claim/admission
|
||
+ same ordering scope의 concurrent out-of-order send 금지
|
||
+ partitioner.ignore.keys = false
|
||
+ unqualified custom partitioner 없음
|
||
+ one producer generation barrier
|
||
= same generation normal-path key order + failure-path sequence detectability
|
||
```
|
||
|
||
global order, 여러 topic 사이 order, partition expansion 중 order, operator replay와 live stream
|
||
사이 order는 보장하지 않는다. process crash, indeterminate send, forced producer rotation,
|
||
operator replay 뒤의 strict order도 첫 card 보장이 아니다. sequence metadata만으로 Kafka
|
||
append order를 강제했다고 주장하지 않는다. strict effect order가 필요하면 future
|
||
consumer-side sequence gate/reorder card를 추가한다.
|
||
|
||
### 17.2 Polling ordering gate
|
||
|
||
ordering-required contract의 다음 event는 같은 ordering scope의 앞선 delivery가
|
||
`DELIVERY_RECORDED` 또는 audited `SKIPPED/COMPENSATED`일 때만 claim한다.
|
||
|
||
`EXHAUSTED` head는 후행을 block한다. 자동 skip하지 않는다. hot aggregate가 전체 batch를
|
||
starve하지 않도록 batch selection은 scope별 head만 후보로 삼고 destination 전체 fairness를
|
||
관측한다.
|
||
|
||
### 17.3 Combined amplification budget
|
||
|
||
최악의 wire work는 대략 다음이다.
|
||
|
||
```text
|
||
relayAttempts
|
||
× Kafka client physical retries within delivery timeout
|
||
× number of destinations
|
||
× replay generations
|
||
```
|
||
|
||
first baseline은 event당 destination 하나다. 설정 compiler는:
|
||
|
||
- maximum relay attempts;
|
||
- delivery-generation DB-created-at 기준 maximum automatic publication age;
|
||
- per-attempt deadline;
|
||
- backoff/jitter;
|
||
- producer internal delivery timeout;
|
||
- shutdown budget;
|
||
- dead/exhausted transition
|
||
|
||
을 하나의 descriptor로 계산한다. max attempt만 있고 maximum automatic publication age가 없는 정책은
|
||
허용하지 않는다.
|
||
|
||
### 17.4 Failure class와 retry
|
||
|
||
| Failure | 기본 |
|
||
| --- | --- |
|
||
| invalid contract/schema/size | retry 없음, writer rejection 또는 operator path |
|
||
| auth/ACL/topic policy mismatch | readiness down, 빠른 반복 retry 금지 |
|
||
| pre-admission transient metadata/network | definite rejection일 때만 bounded retry |
|
||
| post-admission leader/network/retriable | 기본 indeterminate + bounded duplicate-aware retry |
|
||
| not-enough-replicas-after-append | indeterminate |
|
||
| throttle | broker signal과 remaining budget 안에서 retry |
|
||
| local buffer exhausted | bounded admission/backpressure, retry budget 공유 |
|
||
| deadline/response loss | indeterminate, duplicate-aware retry |
|
||
| application programming defect | fail fast/alert, transient로 숨기지 않음 |
|
||
|
||
### 17.5 Record and memory bounds
|
||
|
||
다음을 별도로 제한한다.
|
||
|
||
- key bytes;
|
||
- value UTF-8 bytes;
|
||
- header count/key/value/total bytes;
|
||
- uncompressed record bytes;
|
||
- compressed batch bytes;
|
||
- batch size;
|
||
- request size;
|
||
- producer buffer memory;
|
||
- application admitted in-flight records;
|
||
- pending callback/attempt contexts.
|
||
|
||
Kafka client `buffer.memory`는 전체 producer memory의 완전한 hard bound가 아니다. compression,
|
||
in-flight request, object overhead와 callback context를 포함한 process memory budget을
|
||
capacity test로 계산한다.
|
||
|
||
### 17.6 Large message
|
||
|
||
contract maximum을 넘는 payload는 outbox에 append하지 않는다. large payload가 실제 요구되면
|
||
object storage에 immutable object를 먼저 publish하고 checksum/size/authorization이 있는
|
||
claim-check event를 보내는 별도 design을 사용한다.
|
||
|
||
object upload와 DB business transaction 사이 atomicity가 없으므로 staged object, outbox,
|
||
orphan cleanup과 authorization을 함께 설계한다. 단순 URL을 Kafka에 넣는 것은 대안이 아니다.
|
||
|
||
### 17.7 Compression
|
||
|
||
compression은 provider profile이다.
|
||
|
||
- first profile은 `compression.type=none`으로 고정한다;
|
||
- 선택 시 broker/client version 지원과 CPU/memory를 test한다;
|
||
- decompression bomb 방어를 위해 consumer는 decoded envelope/payload bound를 별도로 검증한다;
|
||
- record limit은 wire/uncompressed 의미를 혼동하지 않는다.
|
||
|
||
### 17.8 Admission과 backpressure
|
||
|
||
producer 내부 buffer만을 application bulkhead로 사용하지 않는다.
|
||
|
||
```text
|
||
application admission semaphore
|
||
-> per-record JIT claim
|
||
-> Kafka producer buffer
|
||
-> broker
|
||
```
|
||
|
||
- admission wait는 attempt deadline에 포함한다;
|
||
- queue는 finite이며 queue timeout을 가진다;
|
||
- queue saturation 때 더 많은 outbox row를 claim하지 않는다;
|
||
- virtual thread를 사용해도 in-flight/message/memory bound는 유지한다;
|
||
- initial polling R2는 per-record JIT claim과 bounded sequential send를 사용한다. 성능 evidence가
|
||
필요할 때만 partition-key-aware concurrency card를 추가한다.
|
||
|
||
### 17.9 Graceful shutdown
|
||
|
||
순서:
|
||
|
||
```text
|
||
1. readiness에서 신규 relay admission 제거
|
||
2. scheduler/new claim 중단
|
||
3. active attempt를 bounded drain
|
||
4. 완료 ACK의 delivery transition을 bounded flush
|
||
5. unresolved attempt를 indeterminate로 남기거나 lease reclaim 가능하게 종료
|
||
6. KafkaTemplate/ProducerFactory/AdminClient close
|
||
7. metrics/secret refresh resource close
|
||
```
|
||
|
||
shutdown timeout이 끝났다고 delivery row를 성공 처리하지 않는다. unresolved row는 lease expiry 뒤
|
||
재claim되며 duplicate 가능성이 있다.
|
||
|
||
### 17.10 Startup
|
||
|
||
순서:
|
||
|
||
```text
|
||
1. typed settings bind
|
||
2. card/catalog/schema hash compile
|
||
3. secret resolve
|
||
4. producer runtime create
|
||
5. topic/security attestation
|
||
6. readiness ACTIVE
|
||
7. polling scheduler admission
|
||
```
|
||
|
||
relay scheduler를 producer/topic readiness보다 먼저 시작하지 않는다.
|
||
|
||
## 18. Polling outbox v2
|
||
|
||
### 18.1 Immutable event
|
||
|
||
목표 `outbox_event` conceptual columns:
|
||
|
||
```text
|
||
event_id VARCHAR(96) PK, canonical US-ASCII
|
||
envelope_version
|
||
contract_id
|
||
payload_version
|
||
logical_destination_id
|
||
destination_binding_revision
|
||
aggregate_type
|
||
aggregate_id
|
||
aggregate_sequence
|
||
aggregate_event_index
|
||
partition_key_text VARCHAR(64), canonical lowercase SHA-256 hex
|
||
occurred_at
|
||
created_at
|
||
tenant_scope NOT NULL canonical scope
|
||
correlation_id
|
||
causation_id nullable
|
||
traceparent nullable, validated
|
||
tracestate nullable, validated
|
||
content_type
|
||
envelope_bytes BYTEA, exact UTF-8 wire document
|
||
envelope_sha256
|
||
envelope_schema_hash
|
||
payload_schema_hash
|
||
schema_set_hash
|
||
contract_catalog_revision
|
||
publication_epoch
|
||
dispatch_authority VARCHAR + CHECK: LEGACY_POLLING | POLLING_V2 | CDC
|
||
transaction_resource_id
|
||
```
|
||
|
||
규칙:
|
||
|
||
- INSERT-only after migration cutover;
|
||
- identity/order unique constraint;
|
||
- event bytes/metadata immutable;
|
||
- `JSONB`나 재직렬화 가능한 `TEXT`를 wire authority로 사용하지 않음;
|
||
- `BYTEA`와 hash가 polling/CDC의 exact byte authority;
|
||
- polling status/attempt/owner 없음;
|
||
- CDC source predicate가 이 table의 INSERT만 받음;
|
||
- delete는 retention maintenance뿐이며 connector behavior를 test함.
|
||
|
||
### 18.2 Delivery control
|
||
|
||
목표 `outbox_delivery` conceptual columns:
|
||
|
||
```text
|
||
event_id FK
|
||
delivery_generation
|
||
authority_status CURRENT | SUPERSEDED
|
||
superseded_by_generation nullable
|
||
dispatch_profile_id
|
||
destination_binding_revision
|
||
state
|
||
claim_count
|
||
publication_attempt_count
|
||
first_attempt_at
|
||
next_attempt_at
|
||
claim_token
|
||
claim_owner
|
||
claim_until
|
||
last_outcome_certainty
|
||
last_failure_class
|
||
last_failure_stage
|
||
provider_generation
|
||
provider_record_reference
|
||
delivery_recorded_at
|
||
terminal_at
|
||
row_version
|
||
created_at delivery generation DB creation time
|
||
automatic_attempt_deadline DB time, immutable per generation
|
||
updated_at
|
||
```
|
||
|
||
primary identity:
|
||
|
||
```text
|
||
(event_id, delivery_generation)
|
||
```
|
||
|
||
`UNIQUE(event_id) WHERE authority_status='CURRENT'`로 event당 authoritative delivery
|
||
generation을 정확히 하나만 허용한다. requeue transaction은 current row를 lock하고 audit를
|
||
append한 뒤 기존 row를 `SUPERSEDED`로 바꾸고 `delivery_generation + 1`, `CURRENT`, `READY` row를
|
||
삽입한다. 새 row의 `created_at`과 `automatic_attempt_deadline`은 같은 DB transaction에서
|
||
§18.9대로 계산한다. `superseded_by_generation`은 새 generation을 가리킨다. update와 insert 중
|
||
하나라도 실패하면 transaction 전체가 rollback한다.
|
||
|
||
first baseline은 event 하나에 logical destination 하나다. multi-destination fan-out card를 나중에
|
||
추가하면 destination을 delivery identity와 current-authority constraint에 포함하고 한 destination
|
||
성공이 다른 destination을 완료시키지 않도록 별도 설계한다.
|
||
|
||
### 18.3 Append-only attempt journal
|
||
|
||
`outbox_delivery_attempt_observation`은 delivery control과 별도인 append-only audit다.
|
||
|
||
```text
|
||
event_id
|
||
delivery_generation
|
||
publication_attempt_id
|
||
observation_sequence
|
||
observation_type ATTEMPT_ADMITTED | OUTCOME_OBSERVED | LATE_ACK_OBSERVED
|
||
claim_token_digest
|
||
producer_generation
|
||
destination_binding_revision
|
||
observed_at
|
||
acceptance_certainty
|
||
retry_disposition
|
||
failure_class
|
||
failure_stage
|
||
provider_record_reference
|
||
```
|
||
|
||
흐름:
|
||
|
||
1. local admission permit를 먼저 확보한다;
|
||
2. 한 짧은 claim transaction에서 한 row만 claim하고 valid remaining lease를 확인한 뒤
|
||
`claim_count + 1`, `publication_attempt_id`, `publication_attempt_count + 1`,
|
||
`ATTEMPT_ADMITTED`를 함께 기록한다;
|
||
3. 이 durable marker 이후에만 Kafka send를 호출한다;
|
||
4. marker 뒤 process crash는 실제 send 전이어도 안전하게 `INDETERMINATE`로 복구한다;
|
||
5. provider outcome observation과 Tx C state transition은 같은 short transaction에서 기록한다;
|
||
6. deadline 뒤 ACK는 §15.2의 bounded source/drain 경계를 통해 성공적으로 persisted된 경우에만
|
||
`LATE_ACK_OBSERVED`를 append하고 delivery state를 뒤집지 않는다.
|
||
|
||
raw claim token은 journal/log/metric에 복제하지 않는다. attempt observation retention은 operator
|
||
reconciliation과 delivery retention보다 짧을 수 없다.
|
||
`LATE_ACK_OBSERVED`는 성공적으로 capture됐을 때 durable한 진단 사실이지만 late callback capture
|
||
자체는 crash-proof하지 않다. admission/outcome journal만 publication state machine의 필수
|
||
evidence다.
|
||
first profile에는 claim만 commit하고 나중에 queue에서 send하는 중간 상태가 없다. lease reclaim
|
||
수와 실제 publication admission 수는 별도 counter로 관측한다.
|
||
|
||
### 18.4 State
|
||
|
||
target polling state:
|
||
|
||
```text
|
||
READY
|
||
CLAIMED
|
||
RETRY_WAIT
|
||
DELIVERY_RECORDED
|
||
EXHAUSTED
|
||
HOLD
|
||
SKIPPED
|
||
COMPENSATED
|
||
LEGACY_RECORDED_UNVERIFIED
|
||
LEGACY_ACCEPTED_UNVERIFIED
|
||
```
|
||
|
||
`EXHAUSTED`는 “broker에 절대 전달되지 않았다”는 뜻이 아니다. 정해진 attempt/elapsed budget 안에
|
||
delivery recording을 완료하지 못해 자동 처리를 중단했다는 뜻이다. 마지막 certainty가
|
||
`INDETERMINATE`이면 이미 전달되었을 수 있다.
|
||
|
||
`EXHAUSTED`와 `HOLD`는 automation-terminal이지만 ordering/retention 관점에서는 unresolved다.
|
||
`HOLD`는 ACK destination mismatch, invariant violation 또는 audited operator pause 때문에
|
||
자동 재시도를 허용하지 않는 상태다.
|
||
`LEGACY_RECORDED_UNVERIFIED`는 current `void KafkaSender` normal return을 보존하는 migration-only
|
||
상태이며 broker ACK, offset, `delivery_recorded_at`을 채우지 않는다. downstream reconciliation과
|
||
operator approval 뒤 `LEGACY_ACCEPTED_UNVERIFIED`로만 전이할 수 있고 이 상태도 broker ACK를
|
||
뜻하지 않는다.
|
||
|
||
consumer-side Kafka DLT와 producer-side `EXHAUSTED`를 둘 다 “dead letter”라고 부르지 않는다.
|
||
legacy `DEAD/OUTBOX_DEAD_LETTER` vocabulary는 migration alias로만 유지하고 runbook을 분리한다.
|
||
|
||
### 18.5 State machine
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> READY
|
||
READY --> CLAIMED: claim(token, lease)
|
||
RETRY_WAIT --> CLAIMED: due + claim(token, lease)
|
||
CLAIMED --> DELIVERY_RECORDED: broker ACK + valid-lease token CAS
|
||
CLAIMED --> RETRY_WAIT: retryable/indeterminate + token CAS
|
||
CLAIMED --> EXHAUSTED: permanent/budget exhausted + token CAS
|
||
CLAIMED --> HOLD: ACK mismatch/invariant + token CAS
|
||
CLAIMED --> CLAIMED: lease expired + new token reclaim
|
||
EXHAUSTED --> NEW_READY: audited supersede + new generation row
|
||
HOLD --> NEW_READY: audited supersede + new generation row
|
||
EXHAUSTED --> SKIPPED: audited disposition
|
||
EXHAUSTED --> COMPENSATED: audited disposition
|
||
HOLD --> SKIPPED: audited disposition
|
||
HOLD --> COMPENSATED: audited disposition
|
||
LEGACY_RECORDED_UNVERIFIED --> LEGACY_ACCEPTED_UNVERIFIED: reconciliation + approval
|
||
state "READY (generation + 1)" as NEW_READY
|
||
DELIVERY_RECORDED --> [*]
|
||
SKIPPED --> [*]
|
||
COMPENSATED --> [*]
|
||
LEGACY_ACCEPTED_UNVERIFIED --> [*]
|
||
```
|
||
|
||
실제로 EXHAUSTED/HOLD row를 READY로 UPDATE하지 않는다. operator requeue는 같은 immutable
|
||
event를 참조하는 `deliveryGeneration + 1` current row와 audit record를 만들고 이전 row를
|
||
`SUPERSEDED` authority로 바꾸는 한 transaction이다. 상태 diagram의 `NEW_READY` 화살표는 이전
|
||
row의 state overwrite가 아니라 이 authority handoff를 뜻한다.
|
||
|
||
### 18.6 Claim token와 valid-lease CAS
|
||
|
||
active worker가 소유한 renew와 outcome transition은 다음 조건을 가진다.
|
||
|
||
```text
|
||
WHERE event_id = ?
|
||
AND delivery_generation = ?
|
||
AND authority_status = 'CURRENT'
|
||
AND state = 'CLAIMED'
|
||
AND claim_token = ?
|
||
AND claim_owner = ?
|
||
AND claim_until > database_now
|
||
```
|
||
|
||
affected row가 정확히 1이 아니면 stale-owner conflict다. stale worker는 broker ACK를 늦게 받아도
|
||
새 owner의 state를 `DELIVERY_RECORDED/RETRY_WAIT/EXHAUSTED`로 덮지 못한다. 새 worker가 아직
|
||
reclaim하지 않았더라도 lease가 만료된 old owner는 terminal state를 기록할 수 없다.
|
||
|
||
claim token은 추측 불가능한 opaque value이고 metric tag가 아니다. `row_version`은 JPA optimistic
|
||
locking 보조 수단일 뿐 claim token을 대체하지 않는다. worker renew와 worker-owned outcome
|
||
transition은 DB time으로 valid lease를 검사한다.
|
||
|
||
다른 mutation은 active-worker predicate를 흉내 내지 않고 각자 다음 fence를 사용한다.
|
||
|
||
| Mutation | Required predicate/fence |
|
||
| --- | --- |
|
||
| initial claim | `CURRENT` + `READY` 또는 due `RETRY_WAIT` + ordering eligibility + expected `row_version`; row lock 안에서 새 token/owner/DB-time lease 설정 |
|
||
| expired reclaim | `CURRENT` + `CLAIMED` + `claim_until <= database_now` + expected `row_version`; 이전 token을 새 opaque token으로 교체 |
|
||
| worker renew/outcome | 위의 current token/owner + `claim_until > database_now` predicate |
|
||
| operator HOLD/SKIP/COMPENSATE/legacy accept | `CURRENT` + expected generation/state/row_version + active unexpired claim 없음 + authorization/audit record in same transaction |
|
||
| requeue | current row lock + expected generation/state/row_version + active unexpired claim 없음; old authority `SUPERSEDED`와 new `CURRENT/READY` insert를 same transaction |
|
||
|
||
각 update의 affected row는 정확히 1이어야 한다. operator가 live worker의 token을 무시하고 raw
|
||
status를 덮어쓰지 않는다. 긴급 HOLD가 필요하면 신규 claim을 먼저 fence하고 active worker
|
||
drain 또는 lease expiry 뒤 operator CAS를 수행한다.
|
||
|
||
### 18.7 Claim eligibility
|
||
|
||
후보:
|
||
|
||
- READY;
|
||
- `RETRY_WAIT AND next_attempt_at <= database_now`;
|
||
- `CLAIMED AND claim_until <= database_now`.
|
||
|
||
ordering-required scope에서는 더 작은 aggregate order event의
|
||
`authority_status=CURRENT` generation이
|
||
`DELIVERY_RECORDED`, audited `SKIPPED/COMPENSATED` 또는 audited
|
||
`LEGACY_ACCEPTED_UNVERIFIED`가 아니면 claim하지 않는다.
|
||
`EXHAUSTED/HOLD/LEGACY_RECORDED_UNVERIFIED`를 단순 terminal로 보고 통과시키지 않는다. query는
|
||
stable total order와 `FOR UPDATE SKIP LOCKED`를 사용한다.
|
||
|
||
### 18.8 First claim/lease strategy
|
||
|
||
first implementation은 `postgresql-per-record-jit-claim.v1` 하나로 고정한다.
|
||
|
||
```text
|
||
local admission permit reserve
|
||
-> one eligible row JIT claim
|
||
-> valid remaining lease check
|
||
-> attempt admission journal
|
||
-> one send/observe/Tx C
|
||
-> permit release
|
||
```
|
||
|
||
- publish 시작 전 remaining lease가 attempt budget보다 작으면 send하지 않는다;
|
||
- renew도 claim token CAS다;
|
||
- lease expiry 뒤 late sender는 authoritative state를 바꾸지 못한다;
|
||
- duplicate publication 가능성은 남으므로 consumer inbox가 필요하다.
|
||
|
||
```text
|
||
claimLease >
|
||
admission-after-claim reserve
|
||
+ max.block.ms
|
||
+ delivery.timeout.ms
|
||
+ callback/TxC reserve
|
||
+ scheduling safety margin
|
||
```
|
||
|
||
batch/window와 partition-key-aware concurrent claim은 throughput evidence가 필요할 때 별도 profile로
|
||
추가한다.
|
||
|
||
### 18.9 Retry time
|
||
|
||
retry due와 lease는 database time을 사용한다. backoff는 bounded exponential + jitter를 사용할 수
|
||
있지만 다음을 descriptor에 고정한다.
|
||
|
||
- claim count와 publication attempt count;
|
||
- delivery-generation DB-created-at 기준 maximum automatic publication age;
|
||
- minimum/maximum delay;
|
||
- jitter source/range;
|
||
- failure-class override;
|
||
- operator hold;
|
||
- destination backlog capacity.
|
||
|
||
현재 fixed `maxAttempts=3`을 영구 정본으로 보지 않는다. real fault/capacity evidence로 first R2
|
||
profile 값을 고정한다.
|
||
|
||
initial generation은 DB-authoritative `outbox_event.created_at`에서 automatic publication age를
|
||
시작한다. audited requeue가 만든 새 generation은 그 delivery row의 DB `created_at`에서 새롭지만
|
||
여전히 finite한 한-generation attempt budget을 시작하되, 원본 event의 same-ID requeue horizon을
|
||
넘지 못한다.
|
||
|
||
```text
|
||
initial generation:
|
||
automaticAttemptDeadline =
|
||
min(
|
||
outbox_event.created_at + profile.maximumAutomaticPublicationAge,
|
||
outbox_event.created_at + contract.sameEventRequeueHorizon
|
||
)
|
||
|
||
requeue generation:
|
||
automaticAttemptDeadline =
|
||
min(
|
||
outbox_delivery.created_at + profile.maximumAutomaticPublicationAge,
|
||
outbox_event.created_at + contract.sameEventRequeueHorizon
|
||
)
|
||
```
|
||
|
||
계산 결과를 `outbox_delivery.automatic_attempt_deadline`에 immutable하게 저장해 profile reload로
|
||
기존 generation의 deadline이 움직이지 않게 한다. claim 시 database time이 deadline 이상이거나
|
||
한 full attempt budget이 남지 않으면, 첫 시도 전 backlog row라도 send하지 않고 `EXHAUSTED`로
|
||
fenced transition한다. `first_attempt_at`은 관측값일 뿐 budget을 새로 시작하지 않는다.
|
||
|
||
따라서 원본 event의 initial generation이 age로 EXHAUSTED된 뒤라도 requeue horizon 안에서 승인된
|
||
operator requeue는 새 generation에 한 번의 bounded automatic window를 부여한다. 그 window도
|
||
`requeueDeadline`에서 잘리며 horizon 뒤 same-ID resend는 여전히 금지한다. 오래된 event를 장애
|
||
복구 직후 발행해야 하면 이 audited requeue 또는 새 compensation/corrected event를 선택한다.
|
||
|
||
### 18.10 Leader election
|
||
|
||
PostgreSQL row claim과 token CAS가 correctness를 제공한다. single leader는 scheduler
|
||
amplification을 줄이는 efficiency option일 수 있지만 correctness의 유일한 근거가 아니다.
|
||
|
||
현재 `OutboxLeaderElectionToken` marker와 “leader election” test 이름이 실제 consensus leader를
|
||
증명한다고 표현하지 않는다. 여러 instance가 claim에 참여하는 profile이라면
|
||
`multi-worker-row-partitioning`처럼 정확히 이름 붙인다.
|
||
|
||
## 19. Polling transaction, crash, disposition과 retention
|
||
|
||
### 19.1 Crash matrix
|
||
|
||
| Crash/failure point | Persisted state | Broker 가능성 | Recovery |
|
||
| --- | --- | --- | --- |
|
||
| business write 전 | 없음 | 없음 | caller retry |
|
||
| business write 후 outbox append 전, same tx rollback | 없음 | 없음 | caller retry |
|
||
| event/delivery commit 후 claim 전 | READY | 없음 | normal claim |
|
||
| claim commit 후 send 전 crash | CLAIMED | 없음 | lease expiry/reclaim |
|
||
| send request 뒤 ACK 전 connection loss | CLAIMED | accepted 가능 | indeterminate + reclaim |
|
||
| broker ACK 뒤 delivery CAS 전 crash | CLAIMED | accepted | reclaim, duplicate 가능 |
|
||
| ACK 뒤 stale token | 새 owner state | accepted | late owner state mutation 거부 |
|
||
| definite transient rejection | RETRY_WAIT | 미수락 확정 | due retry |
|
||
| permanent definite rejection | EXHAUSTED | 미수락 확정 | operator remediation |
|
||
| DELIVERY_RECORDED commit 뒤 process crash | DELIVERY_RECORDED | accepted | no automatic re-send |
|
||
| shutdown timeout 중 unresolved send | CLAIMED | accepted 가능 | lease reclaim, duplicate 가능 |
|
||
|
||
이 표는 “중복 없음”이 아니라 중복 발생 지점과 authoritative recovery를 고정한다.
|
||
|
||
### 19.2 Transaction boundaries
|
||
|
||
```text
|
||
Tx A: business write + outbox_event + outbox_delivery
|
||
Tx B: one-row JIT claim + valid lease + publication attempt admission observation
|
||
No DB Tx: broker publish/ACK wait
|
||
Tx C: outcome observation + valid-lease token-CAS DELIVERY_RECORDED/RETRY_WAIT/EXHAUSTED
|
||
```
|
||
|
||
broker call을 Tx B/C 안에 넣어 DB connection/row lock을 ACK timeout 동안 잡지 않는다.
|
||
first relay command invocation은 최대 한 record만 처리해 per-record `REQUIRES_NEW` loop를 만들지
|
||
않는다. scheduler가 bounded rate로 다음 invocation을 요청하고, 각 invocation은 Tx B와 Tx C를
|
||
순차로 열되 capacity relation을 test한다.
|
||
|
||
### 19.3 Append disabled/misconfigured
|
||
|
||
R2 deployment에서 active durable contract가 하나라도 있으면 dispatch는 `polling` 또는 `cdc`여야
|
||
한다.
|
||
|
||
- `polling`인데 ACK-aware producer/topic binding이 없으면 startup fail;
|
||
- `cdc`인데 external connector expected-state/evidence가 없으면 deployment gate fail;
|
||
- `disabled`인데 durable contract binding이 있으면 startup fail;
|
||
- empty contract catalog + disabled는 resource 0.
|
||
|
||
현재 `.env`처럼 relay enabled + broker blank로 모든 row를 DEAD에 보내는 조합은 target에서
|
||
허용하지 않는다.
|
||
|
||
### 19.4 Exhausted head disposition
|
||
|
||
strict ordered aggregate head가 EXHAUSTED이면 operator는 다음 중 하나를 선택한다.
|
||
|
||
- REMEDIATE_AND_REQUEUE: 원인 수정 뒤 새 delivery generation;
|
||
- SKIP_WITH_GAP: business owner 승인과 reason/audit 뒤 후행 release;
|
||
- HOLD: 후행 계속 차단;
|
||
- COMPENSATE: 별도 compensating integration event.
|
||
|
||
raw SQL로 `status=PUBLISHED`를 설정해 skip을 숨기지 않는다. 모든 disposition은:
|
||
|
||
```text
|
||
operator identity
|
||
authorization
|
||
reason code/text bound
|
||
incident/change reference
|
||
old/new generation
|
||
payload/schema hash
|
||
affected ordering scope
|
||
timestamp
|
||
approval when destructive
|
||
```
|
||
|
||
를 immutable audit로 남긴다.
|
||
|
||
첫 R2의 operator control surface는 기존 `adapter:inbound:web` leaf의 인증된 internal HTTP
|
||
endpoint 하나로 고정한다.
|
||
|
||
```text
|
||
POST /internal/operations/messaging/outbox/{eventId}/dispositions
|
||
permission: outbox:disposition
|
||
destructive permission for SKIP/COMPENSATE: outbox:disposition:destructive
|
||
required: Idempotency-Key, expected deliveryGeneration, expected rowVersion,
|
||
disposition, bounded reason, incident/change reference
|
||
```
|
||
|
||
- web request/auth principal은 inbound DTO에서 application의
|
||
`ApplyOutboxDispositionCommand`로 mapping하고 application에 web/security 타입을 넘기지 않는다;
|
||
- application-core는 `ApplyOutboxDispositionUseCase`와
|
||
`OutboxDispositionPort`를 소유한다;
|
||
- use case는 existing framework-free `@RequiresPermission("outbox:disposition")` contract를
|
||
사용하고 destructive operation은 `AuthorizationPort`로 추가 permission을 검증한다;
|
||
- use case가 permission, allowed source state, requeue horizon, ordering impact,
|
||
destructive approval reference와 compensation event reference를 검증한다;
|
||
- persistence adapter는 §18.6 operator CAS, authority handoff와 immutable audit를 한 transaction에
|
||
구현한다;
|
||
- REQUEUE는 old authority supersede + new generation insert, HOLD/SKIP/COMPENSATE는 expected
|
||
current row transition이다;
|
||
- controller가 repository/entity를 직접 호출하거나 app-bootstrap이 policy를 구현하지 않는다;
|
||
- endpoint는 management/public business API와 구분한 internal network policy, strong
|
||
authentication, rate bound와 audit를 요구하고 OpenAPI/public-path/security snapshot test에
|
||
포함한다;
|
||
- raw SQL과 writable Actuator endpoint는 대체 control surface가 아니다.
|
||
|
||
`COMPENSATED`는 “보상할 예정”이 아니다. feature owner가 만든 immutable compensating event
|
||
reference가 같은 transaction에서 검증·audit된 뒤에만 기록한다. `SKIP`과 `COMPENSATE`는
|
||
destructive permission과 승인 reference 없이는 실패한다.
|
||
|
||
### 19.5 Replay/requeue
|
||
|
||
producer-side requeue는 같은 event ID와 document를 새 delivery generation으로 다시 publish한다.
|
||
한 transaction에서 기존 current generation의 authority를 `SUPERSEDED`로 넘기고 새
|
||
`CURRENT/READY` generation을 만든다. 새 business event를 만들지 않는다. 이미 consumer effect가
|
||
적용되었을 수 있으므로 duplicate를 전제로 한다.
|
||
|
||
same-event requeue는 무기한 허용하지 않는다.
|
||
|
||
```text
|
||
requeueDeadline =
|
||
outbox_event.created_at(DB time) + contract.sameEventRequeueHorizon
|
||
```
|
||
|
||
- horizon은 finite이고 contract/catalog hash에 포함한다;
|
||
- required consumer가 존재하면 horizon은 모든 required consumer의 inbox/dedupe archive coverage
|
||
중 최솟값 이하여야 한다;
|
||
- producer-only first R2는 end-to-end duplicate absorption을 주장하지 않더라도 deadline을
|
||
enforce하고 operator에게 downstream dedupe 확인 책임을 노출한다;
|
||
- deadline 이후 같은 event ID generation 생성은 fail-closed다;
|
||
- 오래된 EXHAUSTED/HOLD row는 audit/retention 때문에 남을 수 있지만 same-ID resend 대상은
|
||
아니다. business owner는 `SKIP`, 검증된 새 compensation/corrected event 또는 별도 durable
|
||
dedupe-archive card를 선택한다.
|
||
|
||
payload를 수정해야 하면 기존 event를 바꾸지 않고 새 event ID/contract version을 가진 corrected
|
||
또는 compensating event를 만든다.
|
||
|
||
### 19.6 Retention
|
||
|
||
polling retention 조건:
|
||
|
||
```text
|
||
the unique CURRENT authoritative generation resolved as
|
||
DELIVERY_RECORDED or audited SKIPPED/COMPENSATED/LEGACY_ACCEPTED_UNVERIFIED
|
||
AND no active claim/requeue
|
||
AND publication audit retention elapsed
|
||
AND operator/legal hold 없음
|
||
AND configured replay horizon elapsed
|
||
```
|
||
|
||
삭제 순서는 delivery/audit FK와 partition strategy가 결정한다. cascade가 audit를 조용히
|
||
없애지 않도록 test한다. reaper는 claim/requeue와 CAS로 경합하고 event를 먼저 지우지 않는다.
|
||
CURRENT generation이 `EXHAUSTED`, `HOLD`, `LEGACY_RECORDED_UNVERIFIED`이거나 unresolved attempt
|
||
observation이 있으면 automation-terminal이어도 삭제하지 않는다. superseded generation과 그
|
||
authority-handoff audit도 current generation의 전체 retention 조건이 충족되기 전에 따로
|
||
삭제하지 않는다.
|
||
|
||
Kafka topic retention이 consumer replay source라 해도 outbox event retention과 동일한 기간이라고
|
||
가정하지 않는다.
|
||
|
||
### 19.7 Partitioning
|
||
|
||
`outbox_event`는 occurred/created time 기준 range partition을 사용할 수 있다. 하지만 strict
|
||
aggregate ordering query, active delivery FK와 cleanup을 함께 benchmark한다.
|
||
|
||
closed partition 삭제는:
|
||
|
||
- polling에서는 terminal/replay 조건;
|
||
- CDC에서는 connector checkpoint proof
|
||
|
||
가 다르다. polling `DELIVERY_RECORDED` status를 CDC cleanup proof로 재사용하지 않는다.
|
||
|
||
### 19.8 Backlog capacity
|
||
|
||
durable API는 broker outage 중 DB에 event를 안전하게 쌓을 수 있으므로 Kafka 순간 장애만으로
|
||
모든 write endpoint readiness를 즉시 내릴 필요는 없다.
|
||
|
||
대신 다음을 구분한다.
|
||
|
||
- relay readiness: producer/topic에 의존;
|
||
- write admission readiness: DB free space, oldest age, backlog count/growth, retention/SLO;
|
||
- direct required producer readiness: Kafka에 직접 의존;
|
||
- liveness: 외부 dependency와 무관.
|
||
|
||
backlog capacity/SLO threshold를 넘으면 새 durable writes를 받을지 degrade할지는 deployment
|
||
policy로 명시한다.
|
||
|
||
## 20. Best-effort publication
|
||
|
||
### 20.1 정확한 의미
|
||
|
||
best-effort는 다음만 보장한다.
|
||
|
||
```text
|
||
closed contract validation
|
||
+ bounded local/producer attempt
|
||
+ outcome observation
|
||
- durable persistence
|
||
- automatic replay
|
||
- business transaction atomicity
|
||
```
|
||
|
||
first provider는 같은 ACK-aware Kafka gateway를 사용할 수 있다. failure를 caller에게 전파하지
|
||
않더라도 metric/log에는
|
||
ACKNOWLEDGED/ACKNOWLEDGED_MISMATCH/REJECTED/INDETERMINATE를 정확히 기록한다.
|
||
|
||
### 20.2 Naming
|
||
|
||
`MessagePublisher`처럼 durability가 모호한 이름은 migration 동안 유지할 수 있으나 target
|
||
application-facing 이름은 `BestEffort...`를 포함한다. durable event는 `Outbox...` contract를
|
||
사용한다.
|
||
|
||
### 20.3 Failure policy
|
||
|
||
- non-critical telemetry-like side effect만 fail-open을 선택한다;
|
||
- failure를 삼킨다고 outbox가 자동으로 대신하지 않는다;
|
||
- caller가 같은 semantic event를 best-effort와 outbox로 동시에 보내지 않는다;
|
||
- disabled best-effort binding은 호출 시 fail-fast하고 silent no-op이 아니다;
|
||
- business correctness가 delivery에 의존하면 best-effort를 선택할 수 없다.
|
||
|
||
### 20.4 Async optional card
|
||
|
||
caller latency를 위해 local enqueue 뒤 즉시 반환하는 truly asynchronous best-effort card를
|
||
나중에 추가할 수 있다. 그 card는 결과를 `ENQUEUED`로만 표현하고 broker ACK/durability를
|
||
주장하지 않는다. bounded queue, drop policy, shutdown drain과 loss metric을 별도 evidence로
|
||
가져야 한다.
|
||
|
||
## 21. Activation, configuration과 expected state
|
||
|
||
### 21.1 Canonical target shape
|
||
|
||
다음은 설계 목표 shape이며 현재 `application.yml`에 그대로 추가하라는 뜻이 아니다. first R2
|
||
구현이 존재할 때 구현된 필드만 live configuration으로 추가한다.
|
||
|
||
```yaml
|
||
app:
|
||
messaging:
|
||
expected-state: ACTIVE
|
||
|
||
publication:
|
||
producer-provider: kafka-spring
|
||
producer-profile: acknowledged-idempotent-v1
|
||
serialization-profile: json-schema-envelope-v1
|
||
topic-profile: externally-provisioned-and-validated-v1
|
||
ordering-profile: per-key-normal-path-sequence-detectable-v1
|
||
compression-profile: none-v1
|
||
|
||
outbox:
|
||
dispatch-mode: polling
|
||
polling-profile: postgresql-polling-v2
|
||
claim-profile: postgresql-per-record-jit-claim-v1
|
||
transaction-resource-id: primary-jpa
|
||
operator-control-profile: authenticated-internal-web-disposition-v1
|
||
claim-lease: 90s
|
||
maximum-relay-attempts: 5
|
||
maximum-automatic-publication-age: 15m
|
||
same-event-requeue-horizon: 7d
|
||
|
||
kafka:
|
||
cluster-id: primary
|
||
bootstrap-servers:
|
||
- kafka-1.example.internal:9093
|
||
- kafka-2.example.internal:9093
|
||
security-profile: kafka-sasl-ssl-scram-sha-512-v1
|
||
secret-reference: secret://messaging/kafka/producer
|
||
producer:
|
||
admission-timeout: 1s
|
||
delivery-timeout: 60s
|
||
request-timeout: 35s
|
||
max-block-timeout: 5s
|
||
application-attempt-budget: 68s
|
||
buffer-memory-bytes: 33554432
|
||
maximum-request-bytes: 4194304
|
||
maximum-admitted-records: 1
|
||
|
||
destinations:
|
||
portfolio-domain-events:
|
||
binding-revision: portfolio-domain-events-r1
|
||
topic: portfolio.domain-events.v1
|
||
expected-partitions: 12
|
||
minimum-replication-factor: 3
|
||
minimum-in-sync-replicas: 2
|
||
maximum-record-bytes: 1048576
|
||
maximum-envelope-bytes: 786432
|
||
required: true
|
||
```
|
||
|
||
숫자는 설명을 위한 candidate다. implementation plan에서 adopted Kafka/Broker version,
|
||
Testcontainers/fault/capacity evidence로 기본값과 상한을 고정한다. candidate도 protocol/header
|
||
headroom과 §16.6 budget 관계를 만족하도록 서로 같은 1 MiB 값을 복제하지 않는다.
|
||
|
||
### 21.2 Expected state
|
||
|
||
```text
|
||
DISABLED
|
||
ACTIVE
|
||
```
|
||
|
||
`DISABLED`:
|
||
|
||
- active contract/destination 0;
|
||
- producer factory/template/AdminClient 0;
|
||
- polling scheduler 0;
|
||
- listener container 0;
|
||
- secret refresh 0;
|
||
- network connection 0.
|
||
|
||
`ACTIVE`:
|
||
|
||
- exact selected tuple가 모두 known/release-eligible;
|
||
- active contract가 모두 compiled;
|
||
- required security material이 resolved;
|
||
- runtime state는 `STARTING | ACTIVE_NOT_READY | ACTIVE_READY`;
|
||
- required destination/security/topology attestation이 fresh할 때만 `ACTIVE_READY`.
|
||
|
||
`enabled=true`와 provider 이름을 여러 곳에서 조합하지 않는다.
|
||
|
||
static schema/card/security/deadline conflict는 resource 생성 전 startup failure다. exact tuple은
|
||
유효하지만 broker/topic이 일시적으로 unavailable한 경우 first profile은 context를
|
||
`ACTIVE_NOT_READY`로 시작하고 scheduler admission을 막은 채 bounded backoff로 재-attest한다.
|
||
credential 누락, plaintext downgrade, unknown topic binding처럼 static/authorization failure를
|
||
transient로 숨기지 않는다. `QUALIFICATION_ONLY`는 test harness mode이지 deployment expected
|
||
state가 아니다.
|
||
|
||
### 21.3 Cross-field validation
|
||
|
||
최소 startup failure:
|
||
|
||
- ACTIVE + provider blank/unknown;
|
||
- ACTIVE + empty bootstrap server;
|
||
- ACTIVE + empty contract/destination;
|
||
- ACTIVE + unqualified card;
|
||
- polling + ACK-aware producer 없음;
|
||
- polling + delivery schema/claim settings 없음;
|
||
- polling + claim profile가 per-record JIT가 아님;
|
||
- polling + authenticated operator disposition control 없음;
|
||
- business/outbox transaction resource identity 불일치;
|
||
- CDC + polling scheduler active;
|
||
- DB publication epoch와 expected authority 불일치;
|
||
- disabled + active durable contract;
|
||
- production + plaintext;
|
||
- production + literal credential;
|
||
- idempotence와 충돌하는 `acks/retries/max.in.flight`;
|
||
- delivery/request/linger deadline 관계 위반;
|
||
- attempt budget과 claim lease 관계 위반;
|
||
- automatic publication/requeue/inbox dedupe horizon 관계 위반;
|
||
- contract bytes > destination/provider/topic bound;
|
||
- ordering-required + null key;
|
||
- duplicate topic/binding/schema ID;
|
||
- schema/catalog/evidence hash mismatch;
|
||
- legacy와 target activation이 동시에 설정됨.
|
||
|
||
### 21.4 Typed settings
|
||
|
||
raw `Map<String, Object> kafkaProperties`를 R2 public config로 노출하지 않는다. first profile이
|
||
실제로 support하는 setting만 typed field로 제공한다.
|
||
|
||
Kafka client upgrade로 새 setting이 필요하면:
|
||
|
||
1. threat/guarantee 영향 검토;
|
||
2. typed setting/validation;
|
||
3. effective config assertion;
|
||
4. fault/security/compatibility test;
|
||
5. card version 또는 evidence fingerprint update
|
||
|
||
를 함께 수행한다.
|
||
|
||
### 21.5 Legacy migration
|
||
|
||
현재:
|
||
|
||
```text
|
||
APP_MESSAGING_BROKER
|
||
APP_MESSAGING_KAFKA_BROKERS
|
||
ca-skeleton.outbox.relay-enabled
|
||
```
|
||
|
||
목표 migration:
|
||
|
||
- legacy 값은 R0 `external-kafka-sender-legacy.v1` descriptor와 endpoint seed로만 해석한다;
|
||
- target R2 exact tuple은 새 contract/destination/security/dispatch 설정을 모두 명시해야 한다;
|
||
- target key와 legacy key가 동시에 존재하면 fail;
|
||
- `broker=kafka`가 `kafka-spring` R2를 자동 의미하지 않는다;
|
||
- `relay-enabled=true/false`는 `dispatch-mode`로 대체한다;
|
||
- warning과 removal release를 명시한다;
|
||
- legacy seam 사용은 descriptor에 R0로 노출한다;
|
||
- runbook/.env/README/env registry를 같은 변경에서 갱신한다.
|
||
|
||
조용한 precedence는 없다.
|
||
|
||
### 21.6 Environment registry
|
||
|
||
새 environment key는 `docs/registries/env-keys.yaml`에:
|
||
|
||
- owner;
|
||
- type/default/allowed values;
|
||
- secret classification;
|
||
- validation;
|
||
- compatibility impact;
|
||
- required test
|
||
|
||
를 등록한다.
|
||
|
||
logical destination/topic key는 fork가 실제 contract를 추가할 때 등록한다. skeleton은 존재하지
|
||
않는 sample production destination을 global env registry에 강제로 추가하지 않는다.
|
||
|
||
### 21.7 Secret reference
|
||
|
||
configuration에는 secret value가 아니라 reference만 둔다.
|
||
|
||
```text
|
||
secret://messaging/kafka/producer
|
||
```
|
||
|
||
secret resolver가 반환하는 material은:
|
||
|
||
- char/byte lifecycle을 제한;
|
||
- log/toString/config dump에서 redact;
|
||
- generation과 expiry만 sanitized descriptor에 노출;
|
||
- rotation 실패 시 old generation 사용 가능 기간을 bounded policy로 관리한다.
|
||
|
||
### 21.8 Compiled runtime descriptor
|
||
|
||
startup 뒤 sanitized endpoint는 다음을 보여준다.
|
||
|
||
```text
|
||
expectedState
|
||
runtimeState
|
||
semantic/provider/dispatch/serialization/topic/security card IDs
|
||
provider/client versions
|
||
contract catalog hash
|
||
schema set hash
|
||
destination IDs
|
||
destination binding revisions
|
||
transaction resource ID
|
||
publication epoch/dispatch authority
|
||
settings digest
|
||
producer generation
|
||
readiness level
|
||
evidence fingerprint/status
|
||
explicit non-guarantees
|
||
runbook IDs
|
||
```
|
||
|
||
bootstrap server, topic이 민감한 deployment에서는 hash/alias만 노출한다. credential, raw headers,
|
||
payload는 절대 노출하지 않는다.
|
||
|
||
### 21.9 Future consumer/CDC settings
|
||
|
||
consumer와 CDC는 §24–§27의 contract가 구현될 때만 typed setting을 추가한다. 지금 live YAML에:
|
||
|
||
```text
|
||
consumer.enabled
|
||
inbox.provider
|
||
cdc.enabled
|
||
schemaRegistry.url
|
||
```
|
||
|
||
같은 미구현 switch를 먼저 만들지 않는다.
|
||
|
||
## 22. Security와 topic governance
|
||
|
||
### 22.1 Threat model
|
||
|
||
| Threat | Control |
|
||
| --- | --- |
|
||
| arbitrary topic publish | closed destination binding |
|
||
| broker MITM | TLS hostname verification + trusted CA |
|
||
| credential leak | secret reference, redaction, generation rotation |
|
||
| over-privileged principal | destination별 least-privilege ACL |
|
||
| plaintext downgrade | production startup fail |
|
||
| payload/header injection | schema/header allowlist + byte bounds |
|
||
| cross-tenant leak | tenant-aware contract/key/auth, no dynamic topic |
|
||
| replay abuse | audited replay authorization/rate bound |
|
||
| poison/oversized record | writer validation + consumer hardening |
|
||
| dependency compromise | lock/SBOM/signature/vulnerability gate |
|
||
| topic policy drift | startup/pre-deploy attestation |
|
||
| DLT sensitive-data accumulation | restricted ACL, retention, redaction policy |
|
||
|
||
### 22.2 Network profile
|
||
|
||
허용 profile:
|
||
|
||
```text
|
||
local-plaintext-v1 local/dev only
|
||
tls-server-auth-v1 controlled non-production or explicit policy
|
||
sasl-ssl-scram-v1 production candidate
|
||
sasl-ssl-oauth-v1 future/qualified candidate
|
||
mtls-v1 deployment requirement가 있을 때
|
||
```
|
||
|
||
production은 `SSL` 또는 `SASL_SSL`만 허용한다. PLAIN/SCRAM credential을 TLS 없이 사용하지
|
||
않는다. custom trust-all, hostname verification disable, insecure callback handler를 금지한다.
|
||
|
||
first production reference는 `SASL_SSL + SCRAM-SHA-512`로 고정한다. OAuth, mTLS와
|
||
server-auth-only TLS는 future profile이며 first tuple의 보장을 자동 상속하지 않는다.
|
||
|
||
### 22.3 TLS
|
||
|
||
- endpoint hostname verification 활성;
|
||
- protocol/cipher allowlist는 platform security policy와 정렬;
|
||
- truststore/keystore location과 password를 secret material로 취급;
|
||
- certificate expiry/chain/hostname negative test;
|
||
- rotation generation swap;
|
||
- emergency revocation runbook;
|
||
- clock skew와 certificate validity 관측;
|
||
- local self-signed CA는 explicit dev test profile에만 허용.
|
||
|
||
### 22.4 SASL
|
||
|
||
mechanism은 typed allowlist다. JAAS literal string을 일반 application YAML/log에 노출하지 않는다.
|
||
|
||
- SCRAM: username/password secret generation과 broker-side iteration/security 정책;
|
||
- OAUTHBEARER: issuer/audience/token endpoint TLS, token refresh deadline, secret/key rotation;
|
||
- GSSAPI: 실제 platform 요구와 qualification이 있을 때만;
|
||
- PLAIN: SASL_SSL에서만 explicit qualification.
|
||
|
||
auth refresh thread/resource도 disabled profile에서 0이어야 한다.
|
||
|
||
### 22.5 ACL
|
||
|
||
producer principal의 최소 권한:
|
||
|
||
```text
|
||
Describe on required cluster/topic scope
|
||
DescribeConfigs on exact production topics
|
||
Write on exact production topics
|
||
IdempotentWrite/transactional permissions only when adopted version/profile requires
|
||
```
|
||
|
||
기본 금지:
|
||
|
||
```text
|
||
Create
|
||
Delete
|
||
Alter
|
||
Write to wildcard all topics
|
||
consumer Read
|
||
Connect internal topic access
|
||
```
|
||
|
||
AdminClient attestation 때문에 broker-wide config나 ACL enumeration 권한을 요구하지 않는다.
|
||
§13.5에서 runtime으로 확인할 수 없는 policy는 fresh signed/provenance-attested deployment
|
||
provisioning evidence로 보완하고 descriptor에 verification source를 기록한다.
|
||
|
||
consumer, DLT publisher, Connect worker는 서로 다른 principal/ACL을 사용한다.
|
||
|
||
### 22.6 Topic provisioning
|
||
|
||
production topic은 infrastructure-as-code가 만든다.
|
||
|
||
- topic name/config review;
|
||
- partition/RF/min ISR;
|
||
- retention/cleanup;
|
||
- max message bytes;
|
||
- quota;
|
||
- ACL;
|
||
- ownership/contact;
|
||
- change/rollback record.
|
||
|
||
application startup은 validate하지 create/alter하지 않는다.
|
||
|
||
### 22.7 Data classification
|
||
|
||
contract descriptor는 payload sensitivity를 분류한다.
|
||
|
||
- credential/token/password를 event payload로 보내지 않는다;
|
||
- 필요한 personal data만 최소화;
|
||
- tenant/user raw identity를 partition key로 쓸 때 bounded digest/pseudonymization 검토;
|
||
- topic/DLT/outbox/inbox retention과 data deletion 법적 요구를 맞춘다;
|
||
- encryption-at-rest는 broker/DB/platform control과 evidence로 관리;
|
||
- payload encryption field-level card는 key lifecycle과 consumer authorization을 함께 설계할 때만
|
||
추가한다.
|
||
|
||
### 22.8 Header/tracing security
|
||
|
||
- W3C `traceparent`/`tracestate` grammar와 size를 검증;
|
||
- baggage는 default propagation하지 않음;
|
||
- inbound credential/auth/cookie/header forwarding 금지;
|
||
- exception stack/Java class를 header에 넣지 않음;
|
||
- DLT header는 원본 allowlist + safe failure code만;
|
||
- repeated retry/DLT로 header가 무한 증식하지 않게 canonical rewrite.
|
||
|
||
### 22.9 Tenant isolation
|
||
|
||
tenant-aware deployment는 다음을 명시한다.
|
||
|
||
- event에 tenant metadata가 필요한지;
|
||
- partition key에 tenant dimension 포함 여부;
|
||
- topic을 tenant별로 나눌지 shared로 둘지;
|
||
- producer/consumer ACL isolation;
|
||
- inbox unique scope;
|
||
- metric/log pseudonymization;
|
||
- replay authorization.
|
||
|
||
request tenant input으로 topic을 동적 생성하지 않는다. tenant topic isolation은 finite
|
||
provisioned catalog로만 허용한다.
|
||
|
||
## 23. Observability, health와 readiness
|
||
|
||
### 23.1 관측 단위
|
||
|
||
다음을 분리한다.
|
||
|
||
```text
|
||
business event append
|
||
polling claim
|
||
logical publication attempt
|
||
Kafka physical request/retry
|
||
broker acknowledgement
|
||
delivery state transition
|
||
consumer receive
|
||
application effect
|
||
offset commit
|
||
CDC source/connector checkpoint
|
||
```
|
||
|
||
한 `publish latency`에 queue/admission/broker/DB transition을 모두 합쳐 원인을 숨기지 않는다.
|
||
|
||
### 23.2 Producer/outbox metrics
|
||
|
||
최소 후보:
|
||
|
||
```text
|
||
messaging.producer.attempts
|
||
messaging.producer.ack.latency
|
||
messaging.producer.queue.time
|
||
messaging.producer.outcome
|
||
messaging.producer.indeterminate
|
||
messaging.producer.buffer.available
|
||
messaging.producer.inflight
|
||
messaging.producer.throttle
|
||
messaging.producer.generation
|
||
|
||
outbox.append.total
|
||
outbox.delivery.claim.total
|
||
outbox.delivery.claim.conflict
|
||
outbox.delivery.lease.expired
|
||
outbox.delivery.outcome
|
||
outbox.delivery.exhausted
|
||
outbox.backlog.count
|
||
outbox.backlog.oldest.age
|
||
outbox.ordering.blocked.count
|
||
outbox.replay.total
|
||
```
|
||
|
||
exact metric name은 metrics registry naming convention에 맞춰 구현 계획에서 확정한다.
|
||
|
||
### 23.3 Metric tag
|
||
|
||
허용 후보:
|
||
|
||
```text
|
||
provider_id
|
||
logical_destination_id
|
||
contract_id catalog budget 안에서만
|
||
outcome
|
||
certainty
|
||
failure_stage
|
||
failure_class
|
||
security_profile
|
||
dispatch_profile
|
||
```
|
||
|
||
금지:
|
||
|
||
```text
|
||
event_id
|
||
aggregate_id
|
||
partition_key
|
||
tenant_id
|
||
user_id
|
||
correlation_id
|
||
physical offset
|
||
exception message
|
||
payload/schema hash
|
||
raw topic when not finite catalog
|
||
```
|
||
|
||
현재 `event_type cardinality_limit=50` 문서 값만 있고 runtime enforcement가 없는 상태를
|
||
readiness evidence로 보지 않는다. compiled catalog cardinality와 global meter filter를 함께
|
||
test한다.
|
||
|
||
### 23.4 Tracing
|
||
|
||
producer span:
|
||
|
||
```text
|
||
logical publish span
|
||
-> Kafka client send observation
|
||
-> delivery-state DB span
|
||
```
|
||
|
||
consumer span:
|
||
|
||
```text
|
||
Kafka receive/process span
|
||
-> application use-case span
|
||
-> inbox/business DB span
|
||
-> offset commit observation
|
||
```
|
||
|
||
Spring Kafka Micrometer Observation을 단일 instrumentation owner로 선택하고 manual trace header
|
||
writer와 중복하지 않는다. trace propagation은 W3C allowlist를 사용한다. payload, key, tenant,
|
||
event ID를 span attribute로 기본 기록하지 않는다.
|
||
|
||
### 23.5 Logs
|
||
|
||
정상 record마다 INFO log를 남기지 않는다. structured warning/error의 safe field:
|
||
|
||
```text
|
||
error code/category
|
||
provider/logical destination/contract
|
||
outcome/certainty/failure stage
|
||
attempt count/delivery generation
|
||
opaque event ID와 correlation ID는 approved error log에서만
|
||
runbook link
|
||
```
|
||
|
||
payload, raw key/header, credential, full broker config는 금지한다. exception cause는 logging
|
||
framework throwable로만 연결하고 message-derived arbitrary field를 만들지 않는다.
|
||
|
||
확인된 persistence transition이 canonical ERROR 한 번을 소유한다. producer callback, relay,
|
||
report adapter가 같은 failure를 ERROR 세 번 남기지 않는다.
|
||
|
||
### 23.6 Audit
|
||
|
||
다음은 일반 log가 아니라 durable audit가 필요하다.
|
||
|
||
- destination/topic binding 변경;
|
||
- capability/profile/security generation 변경;
|
||
- outbox requeue/skip/hold/compensate;
|
||
- consumer replay;
|
||
- group/consumer identity migration;
|
||
- CDC slot/offset reset;
|
||
- polling/CDC cutover/rollback;
|
||
- ACL/secret emergency action.
|
||
|
||
### 23.7 Liveness
|
||
|
||
Kafka/PostgreSQL/Connect outage가 JVM liveness를 내리지 않는다. liveness는 process/event-loop
|
||
deadlock 같은 내부 생존성만 본다.
|
||
|
||
### 23.8 Startup/readiness
|
||
|
||
role별 readiness:
|
||
|
||
| Role | Readiness |
|
||
| --- | --- |
|
||
| direct required producer | provider/topic/security가 unavailable이면 DOWN |
|
||
| polling relay | producer + DB claim path + contract catalog |
|
||
| durable write API | DB/outbox append + backlog capacity; 순간 Kafka outage와 분리 가능 |
|
||
| optional best-effort producer | app readiness와 분리, descriptor DEGRADED |
|
||
| future required consumer | listener assignment/contract/inbox path |
|
||
| future CDC deployment | connector task/slot/offset/WAL/topic |
|
||
|
||
continuous broker probe 하나로 모든 role을 동시에 DOWN시키지 않는다.
|
||
|
||
### 23.9 Readiness hysteresis
|
||
|
||
단일 transient timeout으로 readiness가 flap하지 않게:
|
||
|
||
- startup hard failure와 runtime degradation을 구분;
|
||
- consecutive failure/success 또는 freshness window;
|
||
- last successful metadata/ACK/connector progress timestamp;
|
||
- backlog/SLO threshold;
|
||
- manual maintenance state;
|
||
- recovery proof
|
||
|
||
를 descriptor에 둔다. 오래된 success를 영구 healthy로 사용하지 않는다.
|
||
|
||
### 23.10 Alerts/dashboard
|
||
|
||
최소 dashboard:
|
||
|
||
- publish ACK/error/indeterminate rate와 latency;
|
||
- producer buffer/admission/throttle;
|
||
- outbox backlog/oldest age/state/lease conflict;
|
||
- destination/contract별 bounded view;
|
||
- consumer phase에는 lag/rebalance/retry/DLT/inbox duplicate;
|
||
- CDC phase에는 connector state/LSN lag/WAL retained bytes/offset progress/queue.
|
||
|
||
alert는 runbook ID와 guarantee impact를 포함한다. stub runbook에 alert 이름만 있는 상태는
|
||
operational evidence가 아니다.
|
||
|
||
## 24. Future inbound Kafka consumer
|
||
|
||
### 24.1 Phase와 module gate
|
||
|
||
consumer는 first producer/polling R2와 별도 phase/card다. 구현 시작 전에:
|
||
|
||
1. `modules.json`에 inbound Kafka leaf 추가;
|
||
2. settings include/mapping;
|
||
3. app-bootstrap allowed edge;
|
||
4. nearest `CLAUDE.md`;
|
||
5. architecture tests;
|
||
6. focused test path
|
||
|
||
를 먼저 승인한다.
|
||
|
||
기존 19-leaf topology는 producer/polling phase까지 유지하고 consumer phase에서 정확히 20개로
|
||
registry migration한다.
|
||
|
||
### 24.2 Baseline listener configuration
|
||
|
||
첫 consumer card:
|
||
|
||
```text
|
||
record listener
|
||
enable.auto.commit = false
|
||
AckMode = MANUAL_IMMEDIATE
|
||
asyncAcks = false
|
||
syncCommits = true
|
||
syncCommitTimeout = finite explicit value
|
||
max.poll.records = 1
|
||
bounded concurrency
|
||
bounded fetch/message bytes
|
||
finite max.poll.interval.ms
|
||
finite session/heartbeat/request timeout
|
||
DefaultErrorHandler.ackAfterHandle = false
|
||
DefaultErrorHandler.commitRecovered = false
|
||
DefaultErrorHandler.resetStateOnRecoveryFailure = false
|
||
default logging/no-op recoverer = forbidden
|
||
```
|
||
|
||
batch listener는 unfinished record를 건너뛰는 offset high-water, partial failure와 memory bound를
|
||
별도 증명하기 전에는 baseline이 아니다.
|
||
|
||
first card는 handler, DB transaction, retry/DLT wait와 `Acknowledgment.acknowledge()`를 모두
|
||
listener/consumer thread에서 동기 실행한다. off-thread worker가 ACK하지 않는다.
|
||
`MANUAL_IMMEDIATE`의 immediate 의미는 listener thread 호출과 explicit synchronous commit
|
||
profile에서만 주장한다. async handoff/batch는 별도 card다.
|
||
|
||
expected decode/application outcomes는 listener가 typed result로 처리하고 §24.5의 explicit
|
||
ACK/DLT/HOLD 결정을 수행한다. container `DefaultErrorHandler`는 listener가 놓친 unexpected
|
||
exception의 seek/redelivery safety net일 뿐 disposition owner가 아니다.
|
||
|
||
- `setAckAfterHandle(false)`와 `setCommitRecovered(false)`를 explicit effective assertion으로
|
||
고정한다;
|
||
- retry exhaustion 뒤 정상 반환하는 Spring default logging recoverer,
|
||
`CommonLoggingErrorHandler`와 no-op recoverer를 금지한다;
|
||
- unexpected exception의 bounded retry가 소진되면 custom terminal recoverer가 application
|
||
`HoldUnexpectedConsumerFailureUseCase`를 호출해 stable record identity, failed offset,
|
||
failure-class와 attempt evidence를 durable consumer HOLD로 기록한다. 성공하면 listener thread가
|
||
failed offset으로 seek하고 해당 partition을 pause한 뒤 recoverer가 반환한다. assignment
|
||
callback은 §25.7과 같은 durable HOLD를 재적용하므로 restart/rebalance도 자동 재시작 경로가
|
||
아니다;
|
||
- durable HOLD 기록/seek/pause 중 하나라도 실패하면 recoverer는 예외를 던지고 별도
|
||
`recoveryFailed` lifecycle listener가 container를 bounded stop하며 readiness를 DOWN으로
|
||
만든다. `resetStateOnRecoveryFailure=false`를 effective assertion으로 고정해 stop과 경합해도
|
||
전체 backoff cycle을 다시 시작하지 않는다. stop이 lifecycle deadline 안에 완료되지 않으면
|
||
process liveness를 fail-closed하고 source offset은 commit하지 않는다;
|
||
- baseline은 key/value `byte[]` deserializer를 사용하므로 content decode failure는 listener 안의
|
||
explicit poison path를 탄다. framework-level deserializer를 나중에 쓰면 동일 no-commit
|
||
invariant를 별도 evidence로 증명한다;
|
||
- DLT 성공은 error handler의 “recovered” 반환이 아니라 §25.7 ACK-aware DLT gateway 성공 뒤
|
||
listener thread의 명시적 source ACK로만 표현한다;
|
||
- recoverer/DLT가 실패하거나 indeterminate이면 source commit 0이며, durable HOLD partition 또는
|
||
stopped container라는 terminal automation state가 반드시 관찰돼야 한다.
|
||
|
||
### 24.3 Receive sequence
|
||
|
||
```text
|
||
1. ConsumerRecord receive
|
||
2. key/header/value byte bounds
|
||
3. header allowlist + envelope decode
|
||
4. envelope/payload schema/version validation
|
||
5. destination/subscription/contract allowlist
|
||
6. application command mapping
|
||
7. consume use case / MessageConsumptionExecutor
|
||
8. APPLIED 또는 DUPLICATE commit 확인
|
||
9. acknowledgement
|
||
10. offset commit result observation
|
||
```
|
||
|
||
Kafka SDK type은 step 6에서 끝난다. application command는 provider-neutral event metadata와
|
||
typed payload만 가진다.
|
||
|
||
### 24.4 Deserialization failure
|
||
|
||
listener method 전에 발생하는 deserializer exception도 다룬다.
|
||
|
||
- byte[]로 먼저 받고 bounded envelope codec에서 decode하는 방식을 baseline 후보로 한다;
|
||
- framework deserializer를 쓰면 `ErrorHandlingDeserializer`/동등 error path를 명시한다;
|
||
- trusted Java package/default typing으로 arbitrary class를 만들지 않는다;
|
||
- malformed UTF-8/schema/unknown version은 무한 retry하지 않는다;
|
||
- DLT publish ACK 전 source offset을 진행하지 않는다.
|
||
|
||
### 24.5 Ack result
|
||
|
||
| Application result | Listener |
|
||
| --- | --- |
|
||
| APPLIED | ACK |
|
||
| DUPLICATE with same document hash/effect generation | ACK |
|
||
| RETRYABLE_FAILURE | no ACK, bounded retry |
|
||
| REJECTED/PERMANENT, reorder-tolerant subscription | DLT ACK 뒤 source ACK |
|
||
| REJECTED/PERMANENT, strict ordered subscription | idempotent quarantine 뒤 partition HOLD |
|
||
| IDENTITY_COLLISION | idempotent quarantine 뒤 subscription policy |
|
||
| DB commit outcome unknown | no ACK, retry; inbox로 reconcile |
|
||
| listener shutdown/revoke before commit | no ACK |
|
||
|
||
ack 호출 뒤 offset commit failure도 관측한다. commit failure는 record redelivery를 만들 수 있으며
|
||
inbox가 business duplicate를 막아야 한다.
|
||
|
||
strict ordered subscription은 DLT ACK만으로 gap을 승인하지 않는다. operator가 audited
|
||
`ADVANCE_WITH_GAP` 또는 compensation을 승인한 뒤에만 source offset을 진행한다.
|
||
|
||
### 24.6 Bounded processing
|
||
|
||
- handler + bounded retry/backoff + DB pool/lock/deadlock retry + GC/scheduler reserve +
|
||
synchronous offset commit의 worst case가 `max.poll.interval.ms` 안에 들어야 한다;
|
||
- handler concurrency는 partition ordering, DB pool, executor queue에 맞춘다;
|
||
- one in-flight per partition가 first ordered baseline이다;
|
||
- first baseline은 async executor를 사용하지 않는다;
|
||
- queue saturation 때 container/partition pause로 poll heartbeat를 유지;
|
||
- capacity 회복 때 resume;
|
||
- pause가 buffer/fetch memory를 무한하게 만들지 않게 monitoring한다.
|
||
|
||
### 24.7 Rebalance
|
||
|
||
first synchronous card에서 rebalance callback은 long-running handler drain 장소가 아니다. handler
|
||
budget이 poll membership deadline 안에서 끝나야 하며 callback은 finite한 다음 작업만 한다.
|
||
|
||
1. revoked partition의 신규 dispatch 중단;
|
||
2. 이미 commit된 APPLIED/DUPLICATE offset만 callback budget 안에서 commit 시도;
|
||
3. commit-failed/rebalance-in-progress는 redelivery로 분류;
|
||
4. 미완료 work는 ACK하지 않음;
|
||
5. resource/context 정리와 assignment generation 갱신.
|
||
|
||
consumer는 thread-safe하다고 가정하지 않는다. cooperative assignor/static membership는
|
||
rebalance evidence를 통과한 optional profile이다. off-thread processing을 추가하면 continued
|
||
polling, per-partition unfinished high-water와 listener-thread ordered ACK handoff를 별도 설계한다.
|
||
|
||
### 24.8 Shutdown
|
||
|
||
```text
|
||
readiness DOWN
|
||
-> listener pause/new dispatch stop
|
||
-> active DB transaction bounded drain
|
||
-> eligible ACK/commit
|
||
-> unresolved no-ACK
|
||
-> container close
|
||
-> DLT producer close
|
||
```
|
||
|
||
shutdown timeout 뒤 unfinished record를 ACK하지 않는다.
|
||
|
||
### 24.9 External side effect
|
||
|
||
consumer handler가 DB inbox transaction 안에서 HTTP/email/object storage side effect를 직접
|
||
수행하면 same-store atomicity가 없다.
|
||
|
||
기본 pattern:
|
||
|
||
```text
|
||
inbox + business state + follow-up outbox intent
|
||
same DB transaction
|
||
|
||
external side effect
|
||
별도 durable worker/provider
|
||
```
|
||
|
||
외부 side effect를 반드시 inline 수행해야 하면 idempotency/reconciliation/compensation을
|
||
feature-specific design으로 추가하고 inbox만으로 exactly-once라고 표현하지 않는다.
|
||
|
||
## 25. Inbox, retry, DLT, replay와 Kafka EOS
|
||
|
||
### 25.1 Inbox contract
|
||
|
||
`application-core`는 framework-free `InboxStorePort`와 `MessageConsumptionExecutor`를 소유한다.
|
||
PostgreSQL provider는 persistence adapter가 구현한다.
|
||
|
||
conceptual `inbox_consumption`:
|
||
|
||
```text
|
||
consumer_id
|
||
event_id
|
||
tenant_scope NOT NULL canonical scope
|
||
contract_id
|
||
payload_version
|
||
document_sha256
|
||
effect_contract_version
|
||
effect_generation default 0
|
||
source_reference safe bounded diagnostic
|
||
applied_at
|
||
created_at
|
||
|
||
PK/UNIQUE (consumer_id, effect_generation, tenant_scope, event_id)
|
||
```
|
||
|
||
tenant-disabled deployment도 non-null canonical system scope를 사용한다. 일반 nullable UNIQUE에
|
||
dedupe를 맡기지 않는다.
|
||
|
||
### 25.2 Same transaction algorithm
|
||
|
||
```text
|
||
tx.inWrite:
|
||
validate handler/contract
|
||
INSERT ... ON CONFLICT DO NOTHING RETURNING inbox identity
|
||
inserted:
|
||
execute business mutation
|
||
optional follow-up outbox append
|
||
commit -> APPLIED
|
||
no returned row:
|
||
load existing bounded metadata
|
||
same document hash/effect version/generation -> DUPLICATE
|
||
mismatch -> IDENTITY_COLLISION
|
||
```
|
||
|
||
business mutation이 실패하면 inbox insert도 rollback한다. `PROCESSING` row를 먼저 별도 transaction에
|
||
commit해 영구 stuck 상태를 만들지 않는다. plain INSERT unique exception을 catch한 뒤 같은
|
||
PostgreSQL/JPA transaction을 계속 사용하지 않는다. native
|
||
`ON CONFLICT DO NOTHING RETURNING` 또는 동일 의미의 검증된 atomic primitive를 사용하고 두
|
||
consumer 동시 claim을 real PostgreSQL에서 test한다.
|
||
|
||
### 25.3 Crash behavior
|
||
|
||
| Point | Result |
|
||
| --- | --- |
|
||
| inbox insert 전 crash | redelivery, normal apply |
|
||
| insert 뒤 business mutation 전 crash/rollback | row 없음, redelivery |
|
||
| business + inbox commit 전 crash | rollback, redelivery |
|
||
| commit 뒤 ACK 전 crash | redelivery -> DUPLICATE -> ACK |
|
||
| ACK 뒤 offset commit response loss | redelivery 가능 -> DUPLICATE |
|
||
|
||
### 25.4 Inbox retention
|
||
|
||
inbox retention은 최소 다음보다 길어야 한다.
|
||
|
||
```text
|
||
Kafka replayable retention
|
||
DLT retention
|
||
maximum audited replay horizon
|
||
maximum producer duplicate/requeue horizon
|
||
cross-region/cold-recovery horizon when applicable
|
||
```
|
||
|
||
inbox를 먼저 지우고 Kafka/DLT record를 다시 replay하면 effect가 재적용된다. cleanup은 consumer
|
||
contract version, legal retention과 archive policy를 검증한다.
|
||
|
||
각 consumer card는 finite `dedupeHorizon`과 source/DLT/archive replay cutoff를 pin한다.
|
||
`dedupeHorizon`은 자신이 소비하는 모든 producer contract의 `sameEventRequeueHorizon` 이상이어야
|
||
하며 release compiler가 compatibility matrix에서 이를 검증한다.
|
||
무한 Kafka retention, legal hold 또는 cold archive가 있으면 inbox도 보존하거나 별도 durable
|
||
dedupe archive를 제공해야 한다. purge cutoff보다 오래된 replay는 자동 earliest/apply가 아니라
|
||
unsupported incident로 fail한다.
|
||
|
||
### 25.5 Baseline retry
|
||
|
||
첫 consumer card는 짧고 bounded한 blocking/seek retry다.
|
||
|
||
- retryable failure class allowlist;
|
||
- small maximum attempts;
|
||
- total elapsed bound;
|
||
- backoff가 max.poll/rebalance와 호환;
|
||
- same partition ordering 유지;
|
||
- long dependency outage를 listener thread에서 오래 sleep하지 않음;
|
||
- remaining attempts/age가 끝나면 DLT/operator path.
|
||
|
||
구체 횟수/시간은 handler SLO와 real fault test로 고정한다.
|
||
|
||
### 25.6 Retry topic optional card
|
||
|
||
non-blocking retry topic은 main record를 retry topic으로 publish하고 source offset을 진행한다.
|
||
Kafka ordering을 잃으므로:
|
||
|
||
- unordered/explicitly reorder-tolerant contract만;
|
||
- original event ID/contract/exact document hash 유지;
|
||
- retry generation/attempt metadata bounded;
|
||
- retry/DLT topic provisioning/ACL/retention;
|
||
- retry publish ACK 뒤 source ACK;
|
||
- retry ACK 뒤 source commit crash가 duplicate retry record를 만들므로 stable retry identity/dedupe;
|
||
- container transaction과 adopted Spring Kafka version의 제약 검증;
|
||
- live/retry stream의 stale effect policy
|
||
|
||
를 요구한다.
|
||
|
||
### 25.7 DLT
|
||
|
||
consumer DLT는 producer-side outbox `EXHAUSTED`와 다르다.
|
||
|
||
future consumer tuple은 별도
|
||
`kafka-consumer-dlt-acknowledged.v1` provider card를 반드시 선택한다. 이 provider는 inbound
|
||
Kafka leaf가 소유하며 outbound messaging leaf에 의존하지 않는다. 이는 §5의 HARD invariant 3에
|
||
둔 consumer-processing-local publisher 예외이며 closed DLT/retry binding 외 publish에는 사용할
|
||
수 없다. 최소 exact profile:
|
||
|
||
```text
|
||
acks=all
|
||
enable.idempotence=true
|
||
retries=effectively-unbounded/MAX within finite delivery.timeout.ms
|
||
max.in.flight.requests.per.connection<=5
|
||
finite admission/max.block/request/delivery/buffer/record/header bounds
|
||
ByteArraySerializer key/value with prevalidated DLT bytes
|
||
closed pre-provisioned DLT binding, auto-create disabled
|
||
SASL_SSL/SCRAM least-privilege Write/Describe ACL
|
||
future metadata ACK + expected topic verification
|
||
bounded producer generation rotation/shutdown
|
||
```
|
||
|
||
DLT gateway outcome도 `ACKNOWLEDGED`, `ACKNOWLEDGED_MISMATCH`, `REJECTED`,
|
||
`INDETERMINATE`를 구분한다. mismatch/indeterminate/timeout/close는 source ACK를 허용하지
|
||
않는다. application outbox producer의 card/evidence를 이름만 재사용하지 않고 consumer leaf에서
|
||
real broker/security/fault evidence를 별도로 만든다. 공통 구현 추출은 §31.5의 module-split
|
||
trigger가 실제로 충족될 때만 한다.
|
||
|
||
DLT record:
|
||
|
||
- stable `dltIdentity =
|
||
hash(clusterAlias, topic, partition, offset, consumerId, effectGeneration)`;
|
||
- original event ID/contract/version/key/value 또는 approved sanitized representation;
|
||
- original topic/partition/offset safe reference;
|
||
- bounded failure code/stage;
|
||
- first/last failure timestamp;
|
||
- consumer/effect contract version;
|
||
- replay generation;
|
||
- no raw credential;
|
||
- no unbounded stacktrace/header chain.
|
||
|
||
first DLT publish와 source offset commit은 Kafka transaction으로 원자적이지 않다. DLT ACK 뒤
|
||
source commit 전 crash/response loss는 duplicate DLT를 만든다. DLT tooling은 `dltIdentity`로
|
||
dedupe하고 이 crash를 test한다.
|
||
|
||
reorder-tolerant subscription의 source ACK 조건:
|
||
|
||
```text
|
||
DLT producer future ACKNOWLEDGED
|
||
AND DLT metadata verified
|
||
THEN source acknowledgement
|
||
```
|
||
|
||
DLT publish가 실패/indeterminate면 source offset을 진행하지 않는다.
|
||
|
||
quarantine profile은 source와 같거나 더 엄격한 sensitivity ACL, encryption-at-rest, finite byte
|
||
bound와 retention을 가진다. poison raw key/header/value를 재생 가능하게 보존할지 sanitized
|
||
non-replayable evidence만 보존할지는 contract별로 하나를 고정한다. sanitized mode는 자동 replay
|
||
불가를 descriptor에 노출한다. strict ordered subscription은 successful quarantine 뒤 partition을
|
||
listener thread에서 failed offset으로 seek한 뒤 pause/HOLD하고 audited disposition 전 source ACK를
|
||
하지 않는다.
|
||
|
||
strict-order HOLD는 container memory에만 두지 않는다. application-core가
|
||
`ConsumerPartitionHoldPort`와 hold/disposition use case를 소유하고 persistence adapter가 다음
|
||
durable control을 구현한다.
|
||
|
||
```text
|
||
logical_subscription_id
|
||
consumer_id
|
||
effect_generation
|
||
cluster_alias
|
||
topic_binding_revision
|
||
partition
|
||
failed_offset
|
||
event_id
|
||
document_sha256
|
||
state HOLD | ADVANCED_WITH_GAP | COMPENSATED | RELEASED_FOR_RETRY
|
||
reason/incident/approval
|
||
row_version
|
||
created_at/updated_at
|
||
|
||
UNIQUE(logical_subscription_id, effect_generation,
|
||
cluster_alias, topic_binding_revision, partition)
|
||
```
|
||
|
||
- quarantine ACK와 HOLD insert는 동일한 provider transaction이 아니므로 source ACK는 여전히
|
||
하지 않으며, 두 결과를 reconciliation 가능한 stable identities로 기록한다;
|
||
- assignment callback은 dispatch 전에 application hold query를 호출하고 held partition을
|
||
failed offset에 seek/pause한다;
|
||
- restart/rebalance/new pod도 durable HOLD를 다시 적용하며 in-memory pause 소실로 poison을
|
||
진행하지 않는다;
|
||
- operator disposition은 expected row version CAS, permission, approval/audit를 요구한다;
|
||
- `ADVANCED_WITH_GAP` 또는 verified compensation 뒤에만 listener thread가 failed offset 이후로
|
||
명시적 commit/resume한다;
|
||
- HOLD partition lag는 정상 retry lag와 분리하고 required subscription readiness를 DEGRADED/DOWN
|
||
정책에 따라 표시한다.
|
||
|
||
### 25.8 Replay
|
||
|
||
live consumer group offset을 임의 rewind하지 않는다. 별도 replay job/group은:
|
||
|
||
```text
|
||
replayOperationId
|
||
source (DLT/topic/archive)
|
||
contract/version allowlist
|
||
time/partition/offset/event-id scope
|
||
target consumer/effect version
|
||
reuse or new replay generation
|
||
dry-run count/hash
|
||
rate/concurrency limit
|
||
operator/approver/reason
|
||
start/stop/progress/result
|
||
```
|
||
|
||
를 가진다.
|
||
|
||
기본 replay는 같은 inbox identity를 사용하므로 이미 APPLIED event는 DUPLICATE가 된다. 의도적으로
|
||
effect를 다시 적용하려면 unique key에 참여하는 새 `effectGeneration`, business owner 승인,
|
||
compensation 위험을 명시한다. consumer/effect generation별 durable replay lease는 overlapping
|
||
replay job, live replay와 inbox cleanup race를 막는다.
|
||
|
||
### 25.9 Offset out of range
|
||
|
||
topic retention 뒤 offset이 사라졌을 때 자동 earliest/latest reset으로 data gap을 숨기지 않는다.
|
||
`auto.offset.reset`은 profile에 explicit하며 required consumer의 offset out-of-range는 startup 또는
|
||
runtime incident다. replay/archive/bootstrap 절차를 선택한다.
|
||
|
||
### 25.10 Kafka EOS optional card
|
||
|
||
DB-free Kafka consume-process-produce는 Kafka transaction으로:
|
||
|
||
```text
|
||
input read_committed
|
||
process
|
||
output records + source offsets in one Kafka transaction
|
||
```
|
||
|
||
을 구성할 수 있다.
|
||
|
||
이는:
|
||
|
||
- DB write;
|
||
- HTTP/email/storage side effect;
|
||
- PostgreSQL inbox;
|
||
- 다른 non-transactional system
|
||
|
||
을 포함하지 않는다. 해당 card만 “Kafka transaction 범위의 exactly-once processing”이라고 제한해
|
||
표현한다.
|
||
|
||
Spring의 DB/Kafka transaction synchronization은 commit 순서를 조정할 뿐 distributed atomic
|
||
commit이 아니다. 두 번째 commit failure compensation을 별도 설계해야 한다.
|
||
|
||
## 26. Future PostgreSQL Debezium CDC
|
||
|
||
### 26.1 위치
|
||
|
||
CDC는 application process 안의 scheduler가 아니다.
|
||
|
||
```text
|
||
PostgreSQL logical decoding
|
||
-> replication slot/publication
|
||
-> Debezium PostgreSQL connector
|
||
-> Outbox Event Router
|
||
-> Kafka Connect producer
|
||
-> Kafka topic
|
||
```
|
||
|
||
Java repository는 immutable event schema/contract와 deployment expected-state descriptor를
|
||
제공한다. connector worker/image/config는 deployment asset이다.
|
||
|
||
### 26.2 Prerequisite
|
||
|
||
first future CDC qualification target는 다음 exact family다.
|
||
|
||
```text
|
||
PostgreSQL 16 + pgoutput
|
||
Debezium PostgreSQL/Outbox Event Router 3.6.0.Final
|
||
Kafka Connect worker exact patch/image digest pinned by the implementation plan
|
||
snapshot.mode = no_data for cutover connectors
|
||
publication.autocreate.mode = disabled
|
||
production publication = exact outbox table + CDC row filter + INSERT only
|
||
partition_key_text VARCHAR + StringConverter key
|
||
envelope_bytes BYTEA + Debezium BinaryDataConverter value
|
||
header.converter = Kafka SimpleHeaderConverter
|
||
binary.handling.mode = bytes
|
||
errors.tolerance = none
|
||
transforms.outbox.table.op.invalid.behavior = fatal
|
||
skipped.operations = t
|
||
ordering = commit-order/detectable-sequence, strict aggregate order unsupported
|
||
```
|
||
|
||
resolved Connect/Kafka/plugin patch와 image digest가 없으면 이 card는
|
||
`not-implemented`다. floating `stable/current` documentation은 discovery일 뿐 evidence가 아니다.
|
||
|
||
CDC card를 활성화하기 전:
|
||
|
||
- `outbox_event` 신규 row가 insert-only;
|
||
- legacy status UPDATE writer 0;
|
||
- connector invalid UPDATE behavior가 fatal/alert로 검증;
|
||
- event ID/key/envelope/schema fields가 CDC mapping 가능;
|
||
- event row에 `publication_epoch`와 `dispatch_authority=CDC`가 존재;
|
||
- PostgreSQL production publication이 exact outbox table의
|
||
`WHERE (dispatch_authority = 'CDC')` row filter와 `publish='insert'`만 사용;
|
||
- polling/CDC wire golden parity;
|
||
- PostgreSQL logical replication prerequisites;
|
||
- dedicated publication/slot;
|
||
- Connect internal topics;
|
||
- connector/task security;
|
||
- snapshot/cutover/retention runbook;
|
||
- real end-to-end evidence
|
||
|
||
를 모두 만족한다.
|
||
|
||
현 mutable V3 table에 connector만 붙이는 것은 금지한다. strict aggregate ordering contract도
|
||
§26.10의 별도 serialization/reorder card 없이 first CDC profile에 bind하지 못한다.
|
||
|
||
### 26.3 Connector mapping
|
||
|
||
Outbox Event Router mapping은 최소 다음을 고정한다.
|
||
|
||
```text
|
||
event ID column -> canonical `id` header
|
||
partition_key_text VARCHAR -> StringConverter -> exact US-ASCII Kafka key bytes
|
||
envelope_bytes BYTEA -> Kafka value bytes
|
||
occurredAt column -> record timestamp policy
|
||
contract/version -> bounded headers when required
|
||
logical destination -> closed route mapping
|
||
traceparent/tracestate columns -> validated bounded headers
|
||
```
|
||
|
||
Debezium default `aggregateType -> dynamic topic`를 그대로 사용하지 않는다. closed logical
|
||
destination/topic allowlist와 route regex/replacement를 exact config로 관리한다. first CDC
|
||
profile은 finite DB CHECK/catalog value, exact-match route, pre-provisioned topic,
|
||
auto-create disabled와 connector ACL을 모두 사용한다. unknown route는 다른 topic으로 fallback하지
|
||
않고 connector를 실패시킨다.
|
||
|
||
EventRouter는 heartbeat/schema/transaction/tombstone 같은 non-outbox record에 적용하지 않는다.
|
||
exact source-topic/table SMT predicate를 사용한다. production authority filter는 scripting SMT가
|
||
아니라 PostgreSQL 16 publication row filter로 고정한다.
|
||
|
||
```sql
|
||
CREATE PUBLICATION <pinned_name>
|
||
FOR TABLE ONLY <schema>.outbox_event
|
||
WHERE (dispatch_authority = 'CDC')
|
||
WITH (publish = 'insert', publish_via_partition_root = true);
|
||
```
|
||
|
||
`publication.autocreate.mode=disabled`와 pinned `publication.name`을 사용한다. startup/deployment
|
||
attestation은 `pg_publication`, `pg_publication_tables`/row-filter catalog를 읽어 exact table,
|
||
row filter, `pubinsert=true`, `pubupdate/pubdelete/pubtruncate=false`,
|
||
`publish_via_partition_root=true`와 다른 connector publication이 섞이지 않았음을 확인한다.
|
||
`dispatch_authority`는 PostgreSQL user-defined enum이 아니라 bounded `VARCHAR` + CHECK로 저장해
|
||
PostgreSQL row-filter의 built-in type/operator 제약 안에 둔다.
|
||
outbox를 실제로 partition하지 않는 implementation에서도 이 값을 pin해 future partition
|
||
동작을 조용히 바꾸지 않는다. first profile은 Debezium scripting Filter SMT/plugin을 요구하지
|
||
않는다.
|
||
|
||
UPDATE/TRUNCATE 또는 unexpected operation을 한 설정으로 뭉뚱그리지 않는다.
|
||
|
||
- UPDATE는 `transforms.outbox.table.op.invalid.behavior=fatal`로 EventRouter가 connector를
|
||
중지하게 한다. INSERT-only publication 때문에 정상적으로 관찰될 수 없고, publication drift에
|
||
대한 defense-in-depth다. 기본값 `warn`은 허용하지 않는다;
|
||
- DELETE/TRUNCATE는 production publication에서 publish하지 않는다. §26.13의 승인된 retention
|
||
DELETE가 production event/tombstone을 emit하지 않음을 검증한다;
|
||
- `skipped.operations=t`도 connector defense-in-depth로 pin한다. runtime role의
|
||
DELETE/TRUNCATE privilege 제거와 migration gate/audit가 주 방어선이며, 예상 밖
|
||
UPDATE/DELETE/TRUNCATE가 DB audit에서 발견되면 connector를 중지하고
|
||
`DATA_GAP_SUSPECTED`로 전이한다;
|
||
- `errors.tolerance=none`은 converter/SMT가 실제로 throw한 오류를 skip/DLQ로 우회하지 않는
|
||
정책이지 UPDATE/TRUNCATE 자체의 분류 설정이 아니다.
|
||
|
||
polling-era event는 PostgreSQL publication row filter에서 production CDC source에 들어오지
|
||
않는다. shadow connector만 별도 insert-only publication/slot과 격리 topic에서 authority 전체를
|
||
비교할 수 있다.
|
||
|
||
### 26.4 Insert-only behavior
|
||
|
||
- application은 event row를 UPDATE하지 않는다;
|
||
- polling state는 delivery table에만 있다;
|
||
- cleanup DELETE는 retention proof 뒤에만 실행하고 INSERT-only publication의 production output
|
||
0을 검증;
|
||
- update event가 관찰되면
|
||
`transforms.outbox.table.op.invalid.behavior=fatal`로 warning 없이 stop/alert;
|
||
- CDC connector는 outbox table만 capture하도록 include list/predicate를 제한한다.
|
||
- runtime role의 UPDATE/DELETE/TRUNCATE/DDL privilege를 제거하고 migration role만 별도 승인;
|
||
- exact publication row filter + `publish=insert`, `skipped.operations=t`, table list,
|
||
partition-root behavior를 config/catalog attestation으로 pin;
|
||
- DROP/DETACH/TRUNCATE는 logical decoding alert에만 의존하지 않고 migration gate/audit에서 차단.
|
||
|
||
### 26.5 Payload
|
||
|
||
first CDC profile은 PostgreSQL `binary.handling.mode=bytes`로 읽은 `BYTEA envelope_bytes`를
|
||
EventRouter 결과 value로 만들고
|
||
`value.converter=io.debezium.converters.BinaryDataConverter`로 exact bytes를 emit한다.
|
||
heartbeat 같은 non-outbox record는 BinaryDataConverter가 처리할 수 없으므로 공식
|
||
`value.converter.delegate.converter.type=org.apache.kafka.connect.json.JsonConverter`와
|
||
`value.converter.delegate.converter.type.schemas.enable=false` 설정을 pin한다. delegate가 만든
|
||
record는 exact source-table predicate와 closed route에 의해 production event topic으로 들어갈 수
|
||
없어야 한다. `JsonConverter`/String expansion으로 event envelope 자체를 다시 직렬화하는
|
||
profile은 first profile이 아니다.
|
||
|
||
key는 `partition_key_text VARCHAR(64)`를 EventRouter key field로 선택하고
|
||
`key.converter=org.apache.kafka.connect.storage.StringConverter`로 직렬화한다. polling
|
||
producer가 쓰는 US-ASCII bytes와 동일함을 golden vector로 검증한다. event ID의 EventRouter 기본
|
||
`id` header를 canonical header로 채택하며 alias를 하나 더 남기지 않는다.
|
||
`header.converter=org.apache.kafka.connect.storage.SimpleHeaderConverter`를 default에 맡기지 않고
|
||
explicit pin한다. event ID/contract/version/trace header source column은 bounded canonical
|
||
ASCII/UTF-8 STRING Connect type으로 유지하고 null/optional placement을 exact config로 고정한다.
|
||
polling의 exact UTF-8 header bytes와 adopted Kafka Connect version의
|
||
SimpleHeaderConverter output을 golden test로 비교한다. key/header binary representation과 full
|
||
SMT/converter chain을 golden test로 고정한다.
|
||
|
||
다음이 polling과 같아야 한다.
|
||
|
||
- event ID;
|
||
- key bytes;
|
||
- contract/payload/envelope versions;
|
||
- exact value bytes와 `envelope_sha256`;
|
||
- required headers;
|
||
- record timestamp policy.
|
||
|
||
malformed JSON은 writer admission에서 거부되어야 한다. CDC converter가 malformed string을 정상
|
||
string value로 우회시키는 profile은 qualification 실패다.
|
||
|
||
### 26.6 Offset와 internal topics
|
||
|
||
Kafka Connect distributed mode는 최소:
|
||
|
||
- config storage topic;
|
||
- offset storage topic;
|
||
- status storage topic
|
||
|
||
의 partition/RF/cleanup/ACL을 운영 profile로 고정한다. offset reset/alter는 destructive audited
|
||
operation이다. connector REST API 노출과 authorization도 제한한다.
|
||
|
||
connector/task restart 뒤 마지막 committed offset부터 중복 change event가 재방출될 수 있다.
|
||
consumer inbox가 이를 흡수해야 한다.
|
||
|
||
source connector producer도 application producer card의 보장을 자동 상속하지 않는다. exact
|
||
Connect worker/connector profile은 최소 다음을 별도로 pin한다.
|
||
|
||
```text
|
||
acks=all
|
||
enable.idempotence=true
|
||
max.in.flight.requests.per.connection<=5
|
||
finite delivery/request/max.block/buffer/request bounds
|
||
SASL_SSL/SCRAM credential and least-privilege exact-topic ACL
|
||
topic auto-creation disabled
|
||
errors.tolerance=none
|
||
converter/SMT failure -> task FAILED, no skip
|
||
```
|
||
|
||
Connect internal topic producer/consumer 권한과 outbox destination 권한은 분리한다.
|
||
|
||
### 26.7 Replication slot와 WAL
|
||
|
||
monitor:
|
||
|
||
```text
|
||
slot exists/active
|
||
confirmed_flush_lsn/restart_lsn
|
||
current WAL LSN
|
||
retained WAL bytes
|
||
connector source lag
|
||
last event/heartbeat
|
||
database disk free
|
||
publication/table inclusion
|
||
slot catalog state
|
||
```
|
||
|
||
low-traffic database는 heartbeat/action query가 WAL progress에 미치는 영향을 exact connector
|
||
version으로 검증한다.
|
||
|
||
slot drop/recreate는 이전 LSN history를 복구하지 못할 수 있고 silent gap을 만들 수 있다. 자동
|
||
recreate 뒤 healthy 표시를 금지한다.
|
||
|
||
`max_slot_wal_keep_size`, database disk free admission과 operator emergency threshold를 finite
|
||
deployment 값으로 pin한다. cap 초과로 required WAL segment가 제거되면 slot/card는
|
||
`DATA_GAP_SUSPECTED`이며 resnapshot/reconciliation 전 READY로 복귀하지 않는다. monitoring만
|
||
있고 WAL/disk bound가 없는 profile은 R2가 아니다.
|
||
|
||
### 26.8 Snapshot
|
||
|
||
first shadow와 production cutover connector는 `snapshot.mode=no_data`를 사용한다. 각 connector의
|
||
unique logical slot을 writes-frozen boundary에서 생성하고 slot consistent point 이후 INSERT만
|
||
stream한다. existing outbox history를 snapshot으로 다시 publish하지 않는다. initial snapshot이
|
||
필요한 다른 profile은 historical duplicate scope와 inbox coverage를 별도 card로 증명한다.
|
||
|
||
snapshot/restart 중 duplicate, schema change, queue saturation, connector crash를 test한다.
|
||
|
||
### 26.9 PostgreSQL 16 failover limitation
|
||
|
||
repository의 first CDC database target은 PostgreSQL 16이다. 이 profile은 PostgreSQL 17+의
|
||
failover logical slot continuity를 주장하지 않는다.
|
||
|
||
- primary failover 시 connector/card는 즉시 NOT_READY;
|
||
- 새 primary의 slot, Connect offset과 source LSN을 수동 reconcile;
|
||
- missing/ahead/behind 또는 WAL loss가 있으면 `DATA_GAP_SUSPECTED`;
|
||
- resnapshot/audited backfill/inbox reconciliation 전 writes 재개 조건을 runbook으로 판단;
|
||
- automatic slot recreate와 no-gap 표현 금지.
|
||
|
||
PostgreSQL 17+ synchronized failover slot은 별도 future card와 real failover evidence가 있을 때만
|
||
추가한다.
|
||
|
||
### 26.10 Ordering compatibility
|
||
|
||
logical decoding은 transaction commit order를 emit한다. application이 allocate한
|
||
`aggregateSequence`와 commit order는 두 transaction이 역순 commit하면 다를 수 있다.
|
||
|
||
따라서 first CDC card는:
|
||
|
||
- strict aggregate ordering guarantee를 제공하지 않는다;
|
||
- key와 aggregate sequence를 보존해 gap/regression을 탐지 가능하게 한다;
|
||
- shadow compare도 global/aggregate sequence order가 아니라 source offset, event set, key와 exact
|
||
bytes를 비교한다.
|
||
|
||
strict ordered CDC가 필요하면 같은 aggregate transaction serialization으로
|
||
`sequence order == commit order`를 증명하거나 Kafka 전 reorder/consumer sequence gate를 가진
|
||
별도 card가 필요하다.
|
||
|
||
### 26.11 DDL와 source limitations
|
||
|
||
qualification은 최소 다음을 다룬다.
|
||
|
||
- logical decoding이 DDL event를 직접 제공하지 않는 한계;
|
||
- outbox schema migration과 rolling application;
|
||
- partition root publication behavior;
|
||
- primary key/replica identity 변경;
|
||
- TOAST/unchanged value behavior가 envelope column에 미치는 영향;
|
||
- delete/tombstone;
|
||
- TRUNCATE/DROP/DETACH와 `skipped.operations`;
|
||
- connector/plugin/JDBC/PostgreSQL version compatibility.
|
||
|
||
### 26.12 CDC readiness
|
||
|
||
CDC readiness는 polling metric을 재사용하지 않는다.
|
||
|
||
```text
|
||
connector/task RUNNING
|
||
AND slot/publication valid
|
||
AND WAL retained bytes within bound
|
||
AND topic/security/schema attested
|
||
AND last cutover epoch/watermark reconciled
|
||
AND expected connector name/config/image/SMT hash matches
|
||
AND expected task count/source producer profile matches
|
||
AND last successful source interaction/offset commit is fresh for observed source activity
|
||
```
|
||
|
||
idle database에서 LSN이 움직이지 않는다는 이유만으로 DOWN시키지 않는다. worker/task liveness,
|
||
last source interaction, actual source activity, Kafka offset commit freshness와 measured lag를
|
||
분리한다. `RUNNING`이나 stale prior attestation만으로 no-gap/READY를 주장하지 않는다.
|
||
application liveness와 분리한다.
|
||
|
||
### 26.13 CDC retention proof
|
||
|
||
time partition이 오래됐다는 이유만으로 삭제하지 않는다.
|
||
|
||
closed partition의 모든 source transaction/batch와 destination partition이:
|
||
|
||
1. persisted Connect source offset과 slot checkpoint에서 coverage됐고;
|
||
2. expected Kafka destination의 event ID/hash manifest에서 관찰됐고;
|
||
3. replay retention이 지났고;
|
||
4. connector offset/slot continuity가 검증되었고;
|
||
5. legal/operator hold가 없다는
|
||
|
||
구체적 proof를 남긴 뒤 purge한다. 한 sentinel의 한 Kafka partition 관찰이나
|
||
`confirmed_flush_lsn` 하나만으로 다른 destination partition coverage를 추론하지 않는다.
|
||
|
||
## 27. Polling/CDC shadow, cutover와 rollback
|
||
|
||
### 27.1 Mutual exclusion은 두 층이다
|
||
|
||
application setting 하나만으로 외부 Connect deployment를 막을 수 없다.
|
||
|
||
두 층을 모두 사용한다.
|
||
|
||
1. database publication epoch/dispatch authority fence;
|
||
2. infrastructure authority: deployment replica state, connector state, producer/connector ACL.
|
||
|
||
같은 production topic에 polling producer principal과 CDC connector principal의 Write authority를
|
||
동시에 열지 않는다.
|
||
|
||
`outbox_publication_epoch` conceptual control row:
|
||
|
||
```text
|
||
epoch_id monotonic PK
|
||
authority LEGACY_POLLING | POLLING_V2 | CDC
|
||
state PREPARED | ACTIVE | RETIRED
|
||
source_boundary
|
||
settings_digest
|
||
activated_at
|
||
activated_by/approved_by
|
||
row_version
|
||
```
|
||
|
||
정확히 한 ACTIVE row를 partial unique constraint로 보호한다. append adapter는 business
|
||
transaction 안에서 ACTIVE row를 `FOR SHARE`로 읽고 event에 `epoch_id/authority`를 기록한다.
|
||
`POLLING_V2`면 같은 transaction에서 delivery row를 만들고, `CDC`면 만들지 않는다. epoch
|
||
activation은 같은 row를 `FOR UPDATE`로 전환하므로 concurrent append와 serializes한다.
|
||
|
||
old binary가 cached config로 다른 mode를 쓰지 못하도록:
|
||
|
||
- target append는 DB epoch를 authority로 사용;
|
||
- activation 전 old writer/relay binary 0을 deployment fingerprint로 확인;
|
||
- DB trigger/constraint가 stale epoch, CDC event의 delivery row, polling event의 delivery 누락을
|
||
거부;
|
||
- active authority와 맞지 않는 legacy status mutation을 DB guard가 거부한다.
|
||
|
||
application setting은 expected epoch/authority assertion일 뿐 DB truth를 덮지 않는다.
|
||
|
||
### 27.2 Shadow qualification
|
||
|
||
shadow CDC는:
|
||
|
||
- production과 다른 topic;
|
||
- production consumer가 읽지 않는 group;
|
||
- same immutable event source;
|
||
- same schema/key mapping;
|
||
- event ID/count/exact byte hash/key/source-offset/lag compare;
|
||
- bounded retention와 restricted ACL
|
||
|
||
을 사용한다.
|
||
|
||
shadow record를 production effect에 적용하지 않는다. shadow 성공은 cutover rehearsal/evidence지
|
||
production CDC ACTIVE가 아니다.
|
||
|
||
topology는 두 connector/두 slot으로 고정한다.
|
||
|
||
```text
|
||
shadow connector + shadow logical slot + shadow topic
|
||
production connector + production logical slot + production topic
|
||
```
|
||
|
||
slot이나 Connect offset을 공유·복사·재사용하지 않는다. shadow connector의 advanced offset을
|
||
production topic으로 retarget하지 않는다. production connector/slot은 writes-frozen cutover에서
|
||
새로 만든다.
|
||
|
||
### 27.3 Cutover invariant
|
||
|
||
cutover는 다음을 증명해야 한다.
|
||
|
||
```text
|
||
모든 pre-cutover event가 polling 또는 reconciled duplicate로 처리
|
||
AND 모든 post-cutover event가 CDC source boundary에 포함
|
||
AND 같은 event가 두 authority에서 production topic으로 발행되는 window가 없음
|
||
AND rollback boundary가 기록됨
|
||
```
|
||
|
||
### 27.4 Polling -> CDC high-level sequence
|
||
|
||
exact Debezium 3.6.0.Final/PostgreSQL 16/Connect worker runbook이 세부 명령을 소유한다. 고수준
|
||
순서:
|
||
|
||
1. 별도 shadow connector/slot/topic을 `no_data`로 qualification;
|
||
2. bounded write maintenance 시작, 신규 business transaction admission 중단, active write drain;
|
||
3. polling new claim 중단, active attempt drain, indeterminate/backlog reconcile;
|
||
4. polling producer production Write ACL revoke, producer close, old relay fence 확인;
|
||
5. writes가 frozen인 상태에서 exact CDC row filter + INSERT-only인 dedicated production
|
||
publication과 **새 production slot** 생성;
|
||
6. slot creation이 반환한 consistent point/source boundary와 empty Connect offset identity 기록;
|
||
7. `snapshot.mode=no_data`, `publication.autocreate.mode=disabled`, pinned publication,
|
||
exact source-table SMT predicate, StringConverter-key/BinaryDataConverter-value chain인
|
||
production connector를 준비하고 exact topic Write ACL만 부여;
|
||
8. DB transaction에서 new CDC epoch ACTIVE 전환과 CDC-authority cutover sentinel INSERT를
|
||
원자적으로 수행; sentinel에는 polling delivery row가 생기지 않음;
|
||
9. production connector를 start/resume하여 unique slot boundary부터 stream;
|
||
10. production topic에서 sentinel exact event ID/hash를 확인하고 persisted Connect offset,
|
||
slot LSN과 reconcile;
|
||
11. target application expected epoch를 확인한 뒤 regular writes 재개;
|
||
12. event set/key/hash/source lag와 consumer inbox duplicate를 관측;
|
||
13. rollback window 동안 polling artifacts와 production slot/offset을 보존.
|
||
|
||
`pg_current_wal_lsn()`을 application transaction에서 읽은 값이나 shadow connector offset을 commit
|
||
boundary로 추정하지 않는다. authoritative boundary는 writes-frozen 상태에서 생성한 production
|
||
logical slot consistent point, persisted Connect source offset과 CDC-epoch marker transaction의
|
||
correlation evidence다.
|
||
|
||
### 27.5 CDC -> polling high-level sequence
|
||
|
||
1. incident/cutback 승인, 신규 business write admission fence와 active write transaction drain;
|
||
2. ACTIVE epoch를 `FOR SHARE`로 잡고 있던 writer가 0이고 writes가 frozen임을 DB session/epoch
|
||
evidence로 확인;
|
||
3. current CDC epoch의 **마지막** transaction으로 controlled rollback-boundary sentinel INSERT;
|
||
4. connector가 sentinel을 Kafka에 emit하고 sentinel transaction end LSN까지의 source offset과
|
||
slot high-water coverage가 persisted됐는지 확인;
|
||
5. connector pause/stop, exact offset/LSN 기록, production Write ACL revoke;
|
||
6. polling producer/topic/security를 attest하되 scheduler는 아직 정지;
|
||
7. DB transaction에서 새 POLLING_V2 epoch ACTIVE 전환과 polling sentinel
|
||
event+delivery INSERT;
|
||
8. polling producer Write ACL grant와 scheduler start;
|
||
9. polling sentinel `DELIVERY_RECORDED`와 Kafka record 확인;
|
||
10. target application expected epoch 확인 뒤 regular writes 재개;
|
||
11. suspected CDC gap만 immutable event에서 audited delivery generation으로 backfill;
|
||
12. duplicate/inbox/reconciliation 확인.
|
||
|
||
sentinel event ID만 보였다는 이유로 connector를 멈추지 않는다. writes-frozen boundary,
|
||
sentinel transaction end LSN, persisted Connect source offset와 slot state가 같은 high-water를
|
||
가리켜야 한다. maintenance 전에 시작한 transaction이 sentinel 뒤 commit할 수 있는 상태에서는
|
||
step 3으로 진행하지 않는다.
|
||
|
||
CDC-era event 전체를 무조건 polling delivery로 backfill하지 않는다. 이미 emit된 event를 대량
|
||
duplicate할 수 있기 때문이다. 범위와 certainty를 계산한 audited backfill만 허용한다.
|
||
|
||
### 27.6 Rollback
|
||
|
||
cutover 실패 시:
|
||
|
||
- 어느 authority가 마지막으로 production Write를 가졌는지;
|
||
- 마지막 confirmed event ID/WAL/offset;
|
||
- indeterminate range;
|
||
- consumer inbox coverage;
|
||
- duplicate-safe replay 범위;
|
||
- slot/offset 보존 여부
|
||
|
||
를 먼저 판정한다. “둘 다 켜서 빨리 복구”는 허용하지 않는다.
|
||
|
||
### 27.7 Automatic failover 금지
|
||
|
||
CDC connector health DOWN을 감지해 application이 polling을 자동 활성화하지 않는다. external
|
||
connector와 in-process scheduler 사이 split-brain을 만들기 때문이다. bounded backlog가 source
|
||
DB에 남고 required WAL이 finite retention bound 안에 있는 동안 operator-run cutover/rollback을
|
||
수행한다. WAL loss가 생기면 automatic failover가 아니라 `DATA_GAP_SUSPECTED` reconciliation이다.
|
||
|
||
## 28. Test strategy
|
||
|
||
### 28.1 원칙
|
||
|
||
messaging readiness는 fake 하나나 happy-path broker 하나로 증명하지 않는다.
|
||
|
||
```text
|
||
pure contract/property
|
||
+ application state machine
|
||
+ real PostgreSQL
|
||
+ real Kafka
|
||
+ security/topology
|
||
+ fault/crash/lifecycle
|
||
+ compatibility
|
||
= exact card evidence
|
||
```
|
||
|
||
unit test는 빠른 feedback이고 real-service test는 실제 guarantee evidence다. 둘 중 하나가 다른
|
||
하나를 대체하지 않는다.
|
||
|
||
### 28.2 Current characterization
|
||
|
||
첫 변경 전에 현재 behavior를 고정한다.
|
||
|
||
- broker blank -> disabled sentinel;
|
||
- broker selected + sender 없음 -> startup failure;
|
||
- broker ID mismatch -> startup failure;
|
||
- current sender normal return -> current PUBLISHED transition;
|
||
- current exception -> FAILED/DEAD;
|
||
- ACK-to-mark failure -> IN_FLIGHT/reclaim duplicate possibility;
|
||
- same-transaction append rollback;
|
||
- current timestamp FIFO;
|
||
- current runbook/config drift 목록.
|
||
|
||
characterization은 current behavior를 정당화하는 것이 아니라 migration 중 accidental loss를
|
||
방지한다.
|
||
|
||
### 28.3 Contract catalog unit/property
|
||
|
||
- duplicate contract/destination/schema IDs;
|
||
- unknown provider/card;
|
||
- maximum bound intersection;
|
||
- ordering-required + null key;
|
||
- deterministic partition key golden vectors;
|
||
- tenant/no-tenant scope;
|
||
- aggregate sequence/eventIndex uniqueness;
|
||
- tenant ACTIVE/DISABLED non-null scope uniqueness;
|
||
- stable catalog/schema/settings digest;
|
||
- config cannot relax code maximum;
|
||
- legacy + target conflict;
|
||
- disabled resource-0 descriptor;
|
||
- unsupported card rejection.
|
||
|
||
### 28.4 JSON Schema v1
|
||
|
||
최소:
|
||
|
||
- Draft 2020-12 meta-schema validation;
|
||
- immutable `$id`와 checksum;
|
||
- no remote `$ref`;
|
||
- offline meta-schema/vocabulary registry, duplicate `$id`, cyclic `$ref`, pathological regex;
|
||
- valid/invalid envelope golden corpus;
|
||
- contract별 valid/invalid payload;
|
||
- required/null/missing;
|
||
- unknown property;
|
||
- duplicate JSON key;
|
||
- invalid UTF-8/unpaired surrogate;
|
||
- depth/string/array/object/number bound;
|
||
- timestamp/format assertion;
|
||
- exact UTF-8 bytes;
|
||
- envelope/header mismatch;
|
||
- N/N-1 rolling vectors와 replay horizon 전체 version vectors;
|
||
- retired/future payload version;
|
||
- parser CPU/memory/time bound.
|
||
|
||
official JSON Schema Test Suite 또는 Bowtie 호환성 증거를 adopted validator에 대해 추가한다.
|
||
그 결과가 모든 custom contract compatibility를 대신한다고 주장하지 않는다.
|
||
|
||
### 28.5 Application-core
|
||
|
||
- business + event append same transaction;
|
||
- validation failure rolls back business write;
|
||
- publication outcome exhaustive mapping;
|
||
- ACKNOWLEDGED/ACKNOWLEDGED_MISMATCH/REJECTED/INDETERMINATE;
|
||
- acceptance certainty와 retry disposition의 독립 mapping;
|
||
- permanent failure no repeated retry;
|
||
- combined attempt/elapsed budget;
|
||
- report only after persisted transition;
|
||
- reporter failure containment;
|
||
- exhausted ordered head blocking;
|
||
- audited requeue/hold/skip/compensate policy와 authorization;
|
||
- same-event requeue horizon cutoff;
|
||
- operator endpoint unauthenticated/forbidden/destructive-permission, idempotency와 stale
|
||
generation/row-version rejection;
|
||
- late-completion bounded source/drain race, duplicate drain과 persistence failure;
|
||
- no provider/SDK type in contract;
|
||
- disabled active-contract failure.
|
||
|
||
future consumer:
|
||
|
||
- APPLIED/DUPLICATE/RETRY/REJECTED mapping;
|
||
- inbox same transaction;
|
||
- payload collision;
|
||
- follow-up outbox same transaction;
|
||
- external side effect prohibited/defaulted to outbox.
|
||
|
||
### 28.6 PostgreSQL polling integration
|
||
|
||
real PostgreSQL Testcontainers lane:
|
||
|
||
- forward-only migration from V3;
|
||
- business + event + delivery commit/rollback;
|
||
- same transaction resource identity mismatch startup rejection;
|
||
- immutable event update rejection after cutover;
|
||
- exact `BYTEA` round trip와 envelope hash;
|
||
- delivery FK/unique/order constraints;
|
||
- tenant scope nullable-dedupe 공격;
|
||
- two or more workers disjoint claim;
|
||
- same aggregate same timestamp with sequence/eventIndex total order;
|
||
- different aggregates parallel progress;
|
||
- stale token cannot ACK/FAIL/DEAD;
|
||
- expired-but-not-yet-reclaimed token cannot record delivery;
|
||
- lease expiry during send;
|
||
- same-token renew;
|
||
- database time authority;
|
||
- initial automatic publication age starts at event DB created_at;
|
||
- requeue generation deadline is
|
||
`min(generation DB created_at + maximum age, event created_at + same-event requeue horizon)`;
|
||
- ACK-to-mark crash;
|
||
- append-only attempt admission/outcome/late-ACK journal;
|
||
- late observation DB commit before source ACK, commit-to-ACK crash duplicate absorption;
|
||
- late observation queue overflow/drop does not mutate delivery state;
|
||
- claim count와 publication attempt count;
|
||
- mark transaction failure;
|
||
- exhausted head/fairness/hot aggregate;
|
||
- one CURRENT generation partial-unique constraint;
|
||
- concurrent requeue generation race와 atomic authority handoff rollback;
|
||
- EXHAUSTED/HOLD supersede 뒤 old generation이 다시 claim되지 않음;
|
||
- legacy/v2 relay authority epoch and no dual claim/send;
|
||
- legacy PUBLISHED -> unverified migration only;
|
||
- reaper vs claim/requeue;
|
||
- retention partition/FK/audit;
|
||
- DB pool/batch capacity.
|
||
|
||
Docker unavailable이면 required R2 lane는 skip이 아니라 failure다.
|
||
|
||
### 28.7 Real Kafka producer integration
|
||
|
||
real Kafka broker에서:
|
||
|
||
- successful send returns actual topic/partition/offset metadata;
|
||
- metadata destination mismatch fatal incident;
|
||
- `acks=all` effective config;
|
||
- idempotence conflict startup failure;
|
||
- stable key/partition;
|
||
- record/header too large;
|
||
- missing topic with auto-create disabled;
|
||
- unauthorized Write/Describe;
|
||
- broker unavailable before send;
|
||
- leader move/retriable error;
|
||
- response loss/deadline/late ACK;
|
||
- local buffer saturation/max-block;
|
||
- broker throttle;
|
||
- retry ordering;
|
||
- old/new producer generation barrier와 forced indeterminate order downgrade;
|
||
- fatal/defunct producer generation recreation;
|
||
- no per-message flush;
|
||
- producer generation rotation;
|
||
- graceful drain/forced close;
|
||
- unresolved shutdown -> indeterminate;
|
||
- duplicate after ACK-to-DB gap;
|
||
- low-cardinality metric/trace/log.
|
||
|
||
single-node Testcontainers Kafka는 HA/min ISR/leader-failover 전체 증거가 아니다. R2 topology
|
||
profile에 필요한 multi-broker scenario는 별도 lane에서 수행한다.
|
||
|
||
### 28.8 Security integration
|
||
|
||
- trusted TLS success;
|
||
- untrusted CA failure;
|
||
- hostname mismatch failure;
|
||
- expired/not-yet-valid certificate;
|
||
- SASL valid/invalid credential;
|
||
- least-privilege producer ACL;
|
||
- denied Create/Delete/Alter;
|
||
- secret absent/expired;
|
||
- credential rotation old/new generation;
|
||
- production plaintext startup rejection;
|
||
- config/log/descriptor secret redaction.
|
||
|
||
### 28.9 Topic conformance
|
||
|
||
- expected partition/RF/min ISR;
|
||
- cleanup/retention/max bytes drift;
|
||
- topic missing;
|
||
- partition expansion mismatch;
|
||
- auto-create disabled;
|
||
- wrong cluster/topic binding;
|
||
- provisioning evidence/card fingerprint;
|
||
- readiness recovery after operator correction.
|
||
|
||
### 28.10 Fault/crash matrix
|
||
|
||
process kill/fault injection 지점:
|
||
|
||
```text
|
||
after DB event commit
|
||
after claim commit
|
||
before Kafka send
|
||
after request write
|
||
after broker append before ACK receipt
|
||
after ACK before DB transition
|
||
during DB transition commit
|
||
after DB success before scheduler result
|
||
during shutdown
|
||
```
|
||
|
||
각 시나리오는 DB state, broker observed event IDs, duplicates, late callback, claim token과 recovery를
|
||
검증한다.
|
||
|
||
### 28.11 Capacity and soak
|
||
|
||
- sustained throughput와 backlog drain;
|
||
- hot aggregate;
|
||
- many bounded contracts/destinations;
|
||
- producer buffer/memory/GC;
|
||
- DB claim query/index;
|
||
- poll batch vs claim lease;
|
||
- broker throttle/outage/recovery storm;
|
||
- retry amplification;
|
||
- shutdown under load;
|
||
- metric cardinality;
|
||
- long-running secret generation rotation.
|
||
|
||
benchmark 숫자는 repository/device 일반 성능 주장으로 사용하지 않고 selected deployment
|
||
capacity evidence로 기록한다.
|
||
|
||
### 28.12 Future consumer integration
|
||
|
||
inbound card가 구현될 때:
|
||
|
||
- auto commit disabled/manual immediate ACK;
|
||
- max.poll.records=1 synchronous listener-thread ACK, wrong-thread ACK rejection;
|
||
- `ackAfterHandle=false`, `commitRecovered=false`,
|
||
`resetStateOnRecoveryFailure=false`, default logging recoverer absent;
|
||
- unexpected exception retry exhaustion -> durable HOLD + seek/pause + source commit 0;
|
||
- recoverer/HOLD persistence failure -> bounded container stop/readiness DOWN, no retry-cycle reset;
|
||
- explicit sync commit timeout/failure/redelivery;
|
||
- commit 후 ACK;
|
||
- commit 뒤 ACK 전 crash duplicate;
|
||
- same event duplicate no side effect;
|
||
- two consumers concurrent `ON CONFLICT DO NOTHING RETURNING`;
|
||
- tenant scope/effect generation uniqueness;
|
||
- same event different hash quarantine;
|
||
- malformed bytes/schema/unknown version;
|
||
- pause/resume under bounded saturation;
|
||
- max.poll interval;
|
||
- rebalance revoke/assign while active;
|
||
- partition ordering;
|
||
- short retry;
|
||
- DLT exact ACK-aware provider effective config/metadata mismatch/rotation/shutdown;
|
||
- DLT ACK success/failure/indeterminate;
|
||
- DLT ACK 뒤 source commit crash의 duplicate DLT identity;
|
||
- strict ordered durable quarantine HOLD, restart/reassignment reapply와 approved gap CAS;
|
||
- header sanitization/growth;
|
||
- inbox retention/replay;
|
||
- live/replay overlap, effect-generation lease와 cleanup race;
|
||
- offset out of range;
|
||
- graceful shutdown;
|
||
- TLS/SASL/ACL;
|
||
- real consumer lag/metrics/trace.
|
||
|
||
### 28.13 Future CDC integration
|
||
|
||
exact PostgreSQL + Kafka + Connect + Debezium image set:
|
||
|
||
- insert-only event mapping;
|
||
- `table.op.invalid.behavior=fatal` UPDATE stop과 `skipped.operations=t`/DB-audit TRUNCATE policy;
|
||
- exact CDC-authority row-filtered INSERT-only PostgreSQL publication catalog;
|
||
- polling-authority row 0 emission, approved retention DELETE output 0;
|
||
- polling/CDC key/envelope golden parity;
|
||
- `partition_key_text`/StringConverter key parity,
|
||
`BYTEA`/Debezium BinaryDataConverter exact value bytes, canonical `id` header와 alias 0;
|
||
- pinned SimpleHeaderConverter와 polling/CDC exact header-byte parity;
|
||
- non-outbox heartbeat/schema/tombstone predicate;
|
||
- unknown route + auto-create disabled + ACL failure;
|
||
- `errors.tolerance=none` poison/converter failure;
|
||
- source producer idempotence/security/effective config;
|
||
- connector task crash/restart duplicate;
|
||
- Connect internal offset commit failure after Kafka append;
|
||
- Connect offset commit/replay;
|
||
- slot missing/drop/recreate;
|
||
- WAL retained/disk threshold;
|
||
- heartbeat low traffic;
|
||
- snapshot/no-data mode;
|
||
- existing history not republished by cutover `no_data`;
|
||
- schema migration during connector lifecycle;
|
||
- partitioned table mapping;
|
||
- PostgreSQL 16 failover -> NOT_READY/DATA_GAP_SUSPECTED, no no-gap claim;
|
||
- reverse commit-order aggregate sequence incompatibility;
|
||
- shadow event ID/count/hash;
|
||
- cutover sentinel;
|
||
- cutover crash/abort after every numbered step;
|
||
- polling/CDC Write authority exclusivity;
|
||
- publication epoch stale-writer/old-binary fence;
|
||
- rollback and audited backfill;
|
||
- retention checkpoint proof.
|
||
|
||
### 28.14 Compatibility
|
||
|
||
matrix:
|
||
|
||
```text
|
||
old producer -> new consumer
|
||
new producer -> old live consumer
|
||
old polling binary -> new DB schema
|
||
new polling binary -> compatibility DB schema
|
||
old/new Kafka client -> selected broker
|
||
old/new connector -> selected PostgreSQL/Kafka
|
||
rolling credential/schema/catalog generation
|
||
```
|
||
|
||
unsupported combination을 명시하고 자동 fallback하지 않는다.
|
||
|
||
### 28.15 Observability contract
|
||
|
||
test는 metric이 “존재한다”뿐 아니라:
|
||
|
||
- logical vs physical attempt 구분;
|
||
- outcome/certainty 정확성;
|
||
- ACK latency boundary;
|
||
- event/tenant/key/payload tag 부재;
|
||
- catalog cardinality enforcement;
|
||
- one canonical ERROR;
|
||
- trace context validation;
|
||
- readiness role 분리;
|
||
- stale health/hysteresis;
|
||
- sanitized capability descriptor
|
||
|
||
를 검증한다.
|
||
|
||
## 29. Gradle, CI, dependency와 supply chain
|
||
|
||
### 29.1 Dependency ownership
|
||
|
||
| Dependency | Owner |
|
||
| --- | --- |
|
||
| Spring Kafka/Kafka producer client | `adapter:outbound:messaging` |
|
||
| JSON/schema validator runtime | outbound messaging; future inbound leaf도 자기 runtime 소유 |
|
||
| envelope schema resource/vocabulary | `shared-contract` when truly generic |
|
||
| PostgreSQL/JPA/Flyway | `adapter:outbound:persistence-jpa` |
|
||
| future Kafka listener client | `adapter:inbound:messaging-kafka` |
|
||
| Kafka Connect/Debezium | deployment/integration-test asset |
|
||
| Testcontainers Kafka | provider/integration test configuration |
|
||
| Testcontainers PostgreSQL | persistence/bootstrap integration test |
|
||
| Micrometer/Spring observation composition | adapter/bootstrap ownership에 맞춤 |
|
||
|
||
application/domain은 Kafka/Jackson/schema validator에 의존하지 않는다.
|
||
|
||
### 29.2 Spring Boot BOM
|
||
|
||
Spring Boot 4.0.0 BOM이 관리하는 Spring Kafka/Kafka client 조합을 first implementation
|
||
candidate로 사용한다. 실제 resolved version은 dependency lock과 evidence fingerprint로 기록한다.
|
||
|
||
direct version override는:
|
||
|
||
- Boot/Spring Kafka compatibility;
|
||
- Kafka broker protocol compatibility;
|
||
- CVE/license;
|
||
- tests/locks/SBOM
|
||
|
||
을 함께 통과할 때만 허용한다.
|
||
|
||
### 29.3 Architecture change
|
||
|
||
producer/polling phase는 현재 19 leaf를 유지한다.
|
||
|
||
first R2 operator surface는 existing
|
||
`adapter-inbound-web -> application-core`와
|
||
`adapter-outbound-persistence-jpa -> application-core` edges를 composition root에서 조립한다.
|
||
web leaf가 persistence leaf/repository/entity에 직접 의존하지 않으므로 새 project edge가 없다.
|
||
|
||
standalone sample이 real messaging example을 실행하는 phase에는 leaf 수를 늘리지 않고
|
||
`sample-portfolio`의 allowed dependency/runtime dependency에
|
||
`adapter-outbound-messaging` edge만 추가한다. provider qualification 자체는 adapter
|
||
test-source contract로 가능하므로 이 sample edge가 production R2의 필수 전제는 아니다.
|
||
|
||
consumer phase는:
|
||
|
||
- registry에 20번째 leaf;
|
||
- settings include/mapping;
|
||
- app-bootstrap dependency;
|
||
- architecture tests/fixtures;
|
||
- module lockfile;
|
||
- documentation
|
||
|
||
을 한 migration으로 추가한다. inbound leaf에서 outbound/persistence adapter로 edge를 만들지 않는다.
|
||
|
||
### 29.4 Proposed tasks
|
||
|
||
구현 계획은 repository task naming convention을 확인한 뒤 정확한 이름을 확정한다. 목표 lane:
|
||
|
||
```text
|
||
:adapter:outbound:messaging:test
|
||
:application-core:test
|
||
:adapter:outbound:persistence-jpa:test
|
||
:app-bootstrap:test
|
||
|
||
verifyMessagingContracts
|
||
verifyMessagingJsonSchemaV1
|
||
verifyMessagingPollingOutboxR2
|
||
verifyMessagingKafkaProducerR2
|
||
verifyMessagingSecurityR2
|
||
verifyMessagingReleaseProfile
|
||
|
||
future:
|
||
verifyMessagingConsumerR2
|
||
verifyMessagingCdcR2
|
||
```
|
||
|
||
기존 공통 gate:
|
||
|
||
```text
|
||
test
|
||
check
|
||
verifyCleanArchitectureDependencies
|
||
verifyEnvKeys
|
||
verifyPublicPathSnapshot
|
||
dependency lock/SBOM/vulnerability/license gates
|
||
```
|
||
|
||
### 29.5 CI lanes
|
||
|
||
PR blocking:
|
||
|
||
- pure/unit/property;
|
||
- architecture/dependency/env/schema registry contract;
|
||
- JSON schema/golden/N/N-1;
|
||
- real PostgreSQL polling baseline;
|
||
- pinned real Kafka producer baseline;
|
||
- docs/config/runbook drift checks.
|
||
|
||
production-readiness:
|
||
|
||
- TLS/SASL/ACL;
|
||
- topic conformance;
|
||
- selected RF/min ISR의 multi-broker leader loss, below-min-ISR rejection과 recovery;
|
||
- multi-worker stale-token/crash matrix;
|
||
- response-loss/Toxiproxy;
|
||
- bounded shutdown/rotation;
|
||
- metrics/readiness artifact.
|
||
|
||
nightly/R3:
|
||
|
||
- prolonged leader churn과 repeated multi-broker failure;
|
||
- rolling broker/client upgrade;
|
||
- partition migration;
|
||
- soak/capacity;
|
||
- future consumer rebalance storm;
|
||
- future CDC restart/slot/failover.
|
||
|
||
### 29.6 No silent skip
|
||
|
||
developer local test는 Docker 없음 등을 명시적으로 보고할 수 있다. 그러나 selected R2 release gate는:
|
||
|
||
- required service unavailable;
|
||
- image pull failure;
|
||
- test skipped by assumption;
|
||
- credential fixture missing;
|
||
- no matching test;
|
||
- stale evidence
|
||
|
||
를 PASS로 변환하지 않는다.
|
||
|
||
### 29.7 Evidence artifact
|
||
|
||
sanitized artifact:
|
||
|
||
```text
|
||
git commit/source digest supplied by human/CI
|
||
commands and timestamps
|
||
task/test counts
|
||
broker/client/Spring/PostgreSQL/Connect/Debezium versions
|
||
image digests
|
||
effective non-secret settings
|
||
topology/security profile
|
||
schema/catalog/settings hashes
|
||
fault scenarios/results
|
||
readiness/card status
|
||
skips/failures
|
||
unsupported claims
|
||
runbook links
|
||
```
|
||
|
||
CI artifact 없이 문서 표를 수동으로 R2로 바꾸지 않는다.
|
||
|
||
### 29.8 Supply chain
|
||
|
||
- Gradle dependency locks;
|
||
- SBOM;
|
||
- vulnerability severity/suppression policy;
|
||
- license allowlist;
|
||
- Kafka/Connect/Debezium container digest pin;
|
||
- connector plugin inventory/classloader compatibility;
|
||
- JSON schema validator dependency review;
|
||
- image signature/provenance where platform supports;
|
||
- upgrade cadence/runbook.
|
||
|
||
## 30. Implementation and migration sequence
|
||
|
||
### 30.1 Phase 0 — Truth and characterization
|
||
|
||
목표:
|
||
|
||
- 이 설계 검토/승인;
|
||
- current ACK overclaim 표시;
|
||
- fake-only/real-service status 정리;
|
||
- current tests characterization;
|
||
- README/YAML/env/runbook drift 목록;
|
||
- implementation plan 작성.
|
||
|
||
완료 기준:
|
||
|
||
- §0 status ledger 갱신;
|
||
- current code behavior test;
|
||
- no implementation/R2 overclaim;
|
||
- human-only git policy 유지.
|
||
|
||
### 30.2 Phase 1 — Contract, catalog, envelope and JSON Schema
|
||
|
||
추가:
|
||
|
||
- application event metadata/value types;
|
||
- application-owned contract contribution SPI와 합법적인 sample/test composition;
|
||
- logical destination/contract descriptors;
|
||
- validated integration-event port;
|
||
- envelope v1/payload schema resources;
|
||
- schema/catalog checksum manifest;
|
||
- JSON codec/validator;
|
||
- sample payload contract/golden vectors;
|
||
- shared-contract/messaging leaf `CLAUDE.md` ownership update;
|
||
- compatibility/no-remote-ref/resource-bound tests;
|
||
- current raw payload/eventType migration adapter.
|
||
|
||
이 phase만으로 Kafka/polling R2가 아니다.
|
||
|
||
### 30.3 Phase 2 — Immutable event and polling delivery v2
|
||
|
||
기존 V3 migration을 수정하지 않고 새 forward-only migration을 추가한다.
|
||
|
||
base template의 기본 migration card는
|
||
`ADDITIVE_IN_PLACE_EMPTY_OR_DRAINED_V3.v1`이다. production data가 없거나 verified drain/reset
|
||
maintenance로 legacy row가 0인 새 템플릿 출발점을 대상으로 한다. 이 기본 card에서 P2의
|
||
rolling-safe 고수준 순서:
|
||
|
||
1. row/data classification preflight가 empty-or-drained 조건을 fail-closed로 확인;
|
||
2. existing `outbox_event`에 immutable v2 metadata를 additive하게 추가하고 legacy columns는
|
||
compatibility를 위해 유지;
|
||
3. `outbox_delivery`, append-only attempt journal, disposition audit,
|
||
`outbox_publication_epoch` 생성;
|
||
4. known legacy event type alias/catalog와 empty/drained evidence를 기록;
|
||
5. compatibility release가 legacy required columns와 v2 immutable metadata를 채우되
|
||
`LEGACY_POLLING` control만 사용;
|
||
retained V3 compatibility projection은 `event_type=contract_id`, `payload=exact v1 envelope
|
||
UTF-8 text`, `status=PENDING`, `attempt_count=0`, `next_attempt_at=occurred_at`,
|
||
`idempotency_key=event_id`로 고정한다. canonical metadata가 있는 row의 legacy publisher는
|
||
`OutboxEnvelopeJson`으로 다시 감싸지 않는다. compiled logical-destination binding, stored
|
||
partition-key bytes와 immutable `envelope_bytes`를 byte-for-byte passthrough한다. canonical
|
||
metadata가 없는 true legacy row만 기존 v0 wrapper를 사용한다;
|
||
6. compatibility binary의 legacy relay query가 ACTIVE
|
||
`LEGACY_POLLING` epoch/generation만 처리하도록 fence;
|
||
7. v2 constraint/index/trigger, one-CURRENT authority와 migration rollback을 rehearsal하되 active
|
||
legacy status mutation을 아직 막지 않음;
|
||
8. old pre-fence binary를 0으로 만들고 no-dual-writer probe 확인;
|
||
9. `LEGACY_POLLING` epoch와 legacy relay를 계속 ACTIVE로 유지;
|
||
10. P3 ACK-aware producer/new relay/operator tool이 scheduler-disabled 상태로 배포·attest되기 전
|
||
`POLLING_V2` authority 전환과 v2 claim을 금지.
|
||
|
||
P2는 schema/control-plane candidate일 뿐 publish path cutover가 아니다.
|
||
|
||
live non-empty deployment는 이 base card를 통과시켜 자동 backfill하지 않는다.
|
||
row volume, active state distribution, lock/replication budget, data classification, maintenance
|
||
window와 rollback rehearsal을 입력으로 다음 중 하나를 **별도 deployment migration design과
|
||
승인 gate**에서 고른다.
|
||
|
||
```text
|
||
LIVE_ADDITIVE_BACKFILL_IN_PLACE.v1
|
||
COPY_AND_CUTOVER_WITH_RECONCILIATION.v1
|
||
```
|
||
|
||
두 live card 모두 이 문서의 stable event identity, legacy ACK non-overclaim, relay authority fence,
|
||
same-transaction/no-dual-write invariant를 따라야 하지만 어느 것이 안전한지는 실제 database
|
||
evidence 없이 base template가 추측하지 않는다. 따라서 이 선택은 구현 계획에 숨겨 둔 선택이
|
||
아니라 명시적으로 차단된 deployment-specific gate다.
|
||
|
||
모든 migration card는:
|
||
|
||
- dual-write gap 없음;
|
||
- same transaction;
|
||
- rollback;
|
||
- old/new binary compatibility;
|
||
- relay authority가 어떤 순간에도 정확히 하나;
|
||
- legacy/v2 query generation과 backlog handoff watermark;
|
||
- no event identity change;
|
||
- backup/rollback
|
||
|
||
을 증명한다.
|
||
|
||
<!-- P3가 소유하는 cutover를 P2 migration 단계에 다시 넣지 않는다. -->
|
||
|
||
### 30.4 Legacy event migration
|
||
|
||
현재 pending row는 hand-written envelope/eventType/raw payload다.
|
||
|
||
base template default:
|
||
|
||
- verified empty/drained V3이면 legacy payload transform 없이 additive migration;
|
||
- drain/reset은 production data 삭제를 뜻하지 않으며 non-production 또는 승인된 maintenance
|
||
범위만 대상으로 evidence를 남김.
|
||
|
||
live-data deployment gate:
|
||
|
||
- live data면 `legacy-envelope-v0` read-only publisher;
|
||
- known contract는 승인된 live migration card에서만 v1로 deterministic transform하되 original
|
||
hash/audit 보존;
|
||
- unknown contract는 자동 topic publish하지 않고 operator quarantine.
|
||
|
||
이미 발행된 event의 wire contract를 몰래 바꾸지 않는다.
|
||
|
||
현재 `PUBLISHED`는 broker ACK가 아니라 `void KafkaSender` 정상 반환이다. migration은
|
||
`ackObservedAt`, provider metadata 또는 `DELIVERY_RECORDED`를 조작해 채우지 않는다. 운영자는
|
||
event 범위별로:
|
||
|
||
- downstream reconciliation 뒤 legacy terminal로 수용;
|
||
- duplicate-aware requeue;
|
||
- quarantine/hold
|
||
|
||
중 하나를 audit한다. legacy `IN_FLIGHT`는 old relay fence와 drain/expiry 전 변환하지 않는다.
|
||
|
||
final fenced reconciliation의 기본 상태 매핑은 다음과 같다.
|
||
|
||
```text
|
||
legacy PENDING -> READY
|
||
legacy FAILED -> RETRY_WAIT (preserved finite due/budget, 없으면 reviewed DB-time due)
|
||
legacy DEAD -> EXHAUSTED (acceptance certainty를 fabricated definite rejection으로 만들지 않음)
|
||
legacy PUBLISHED -> LEGACY_RECORDED_UNVERIFIED
|
||
legacy IN_FLIGHT -> HOLD + remaining-indeterminate audit
|
||
```
|
||
|
||
이미 provisional delivery가 있으면 final legacy state와 expected row version을 대조해 같은 current
|
||
generation을 migration-only CAS로 맞추고, 없으면 정확히 하나를 insert한다. duplicate CURRENT,
|
||
unknown status/contract, event/hash mismatch는 자동 추측하지 않고 cutover transaction을
|
||
rollback한다. 이 매핑은 migration에서만 허용되며 정상 v2 worker state machine을 우회하는 일반
|
||
운영 API가 아니다.
|
||
|
||
### 30.5 Phase 3 — Spring Kafka producer and polling reference path
|
||
|
||
구현:
|
||
|
||
- Spring Kafka dependency/lock;
|
||
- explicit producer factory/template;
|
||
- typed provider settings/compiler;
|
||
- ACK-aware gateway/outcome;
|
||
- topic attestation;
|
||
- finite producer/admission/deadline;
|
||
- polling relay outcome/state update;
|
||
- late-completion source/drain/attempt observation;
|
||
- application disposition use case + authenticated inbound-web operator endpoint;
|
||
- producer generation/lifecycle;
|
||
- best-effort migration;
|
||
- config/env/README/runbook update;
|
||
- real Kafka/PostgreSQL happy/failure tests.
|
||
|
||
P3 cutover는 다음 순서를 고정한다.
|
||
|
||
1. ACK-aware producer와 v2 relay를 scheduler-disabled로 배포;
|
||
2. contract/catalog/schema, producer/topic/security와 transaction-resource preflight;
|
||
3. operator disposition endpoint의 auth/CAS/audit negative test;
|
||
4. bounded write maintenance를 시작해 신규 business transaction admission을 막고 active writer와
|
||
ACTIVE epoch `FOR SHARE` holder가 0이 될 때까지 drain;
|
||
5. old legacy relay 신규 claim 중단, IN_FLIGHT maximum budget drain, remaining indeterminate audit;
|
||
6. old producer close/Write fence와 DB legacy mutation guard 활성;
|
||
7. 하나의 cutover DB transaction을 열어 ACTIVE legacy epoch를 `FOR UPDATE`로 잠그고, writes와
|
||
legacy mutation이 fenced된 snapshot에서 fixed legacy handoff watermark를 기록;
|
||
8. 같은 transaction에서 pre-backfill 뒤 watermark까지 생긴 delta를 포함해 모든 legacy event를
|
||
final reconcile한다. 각 row는 final legacy state에 대응하는 정확히 한 CURRENT v2 delivery
|
||
(`READY/RETRY_WAIT/EXHAUSTED/HOLD/LEGACY_RECORDED_UNVERIFIED`)를 가지며 active claim은 0이어야
|
||
한다. row count, event ID/hash manifest와 unmapped/duplicate count 0을 assertion;
|
||
9. 같은 transaction에서 `LEGACY_POLLING -> POLLING_V2` ACTIVE epoch 전환과 v2 cutover sentinel
|
||
event+delivery INSERT 뒤 commit. reconciliation/manifest/switch 중 하나라도 실패하면 전체
|
||
rollback;
|
||
10. v2 relay만 start하고 claim token/sequence/valid-lease CAS 사용;
|
||
11. canonical append path가 만든 v2 sentinel ACK/`DELIVERY_RECORDED`, legacy writer 0,
|
||
no-dual-send와 fresh readiness를 확인한 뒤 expected FROZEN generation/epoch/evidence CAS로
|
||
write admission을 `OPEN(generation+1)`하고 business writes 재개;
|
||
12. rollback window 뒤 obsolete legacy columns 제거는 별도 later forward migration.
|
||
|
||
write maintenance는 process-local boolean이 아니다. 모든 `TransactionPort.inWrite`는 같은
|
||
transaction에서 PostgreSQL admission singleton을 `FOR KEY SHARE`로 잡고 OPEN/fence generation을
|
||
확인한다. freeze transaction은 그 row를 `FOR UPDATE`로 잡아 기존 share holder가 commit/rollback할
|
||
때까지 기다린 뒤 FROZEN generation을 durable하게 기록한다. 새 writer는 그 뒤 fail-closed
|
||
rollback한다. live node lease와 deployment instance inventory가 expected source/fence protocol로
|
||
일치하지 않거나 legacy relay/reaper/producer Write의 zero-active/negative-Write probe가 없으면
|
||
one-shot precondition evidence를 만들지 않는다.
|
||
|
||
epoch commit 뒤에도 admission은 자동으로 열리지 않는다. v2 sentinel이
|
||
`DELIVERY_RECORDED`이고, frozen 상태에서 maintenance-only canonical append canary가 exact
|
||
envelope/projection/delivery를 만들고, topic/security/readiness가 fresh한 경우에만 application
|
||
resume use case가 expected FROZEN generation + `POLLING_V2` epoch + evidence digest CAS로
|
||
`OPEN(generation+1)`을 기록한다. ordinary `TransactionPort.inWrite`는 그 전까지 계속 거부된다.
|
||
resume mismatch/replay/failure는 FROZEN을 유지한다. epoch commit 뒤 runner가 실패하면 별도
|
||
`resume-polling-v2-writes` one-shot recovery operation만 같은 증거를 다시 검증할 수 있고 raw SQL
|
||
status update는 금지한다.
|
||
|
||
cutover는 authenticated public/web endpoint가 아니라 non-web one-shot maintenance runner가
|
||
opaque approval evidence ID와 expected target/source/epoch를 받아 수행한다. operation ID와
|
||
evidence consumption은 DB에서 재실행을 막고, 실패는 non-zero exit와 immutable audit를 남긴다.
|
||
epoch commit 전 실패는 DB transaction rollback만으로 끝내지 않는다. 먼저 모든 fence를 유지한
|
||
채 fresh evidence/approval deadline 안에서 bounded forward retry할 수 있다. abort-to-legacy를
|
||
선택하면 exact epoch가 여전히 `LEGACY_POLLING`이고 v2 business send/sentinel authority가 없으며
|
||
inventory가 보존됐음을 확인한다. 외부 ACL을 바꾸기 전에 DB에서 exact cutover attempt를 잠그고
|
||
`CUTOVER_PENDING -> RECOVERING_LEGACY`로 CAS하면서 recovery operation/lease/evidence digest를
|
||
결합한다. 이 전이는 같은 attempt를 모든 forward finalizer에서 원자적으로 무효화하며, lease가
|
||
만료돼도 `CUTOVER_PENDING`으로 돌아가지 않고 recovery-only takeover만 허용한다. 반대로
|
||
finalizer는 epoch transaction 안에서 `CUTOVER_PENDING -> FINALIZING_V2`를 먼저 CAS하고
|
||
성공 commit에서만 `CONSUMED_V2`로 바꾼다. 따라서 두 분기는 동일 attempt에서 함께 진행될 수 없다.
|
||
|
||
상호 배제 범위는 attempt 하나가 아니라 outbox authority 전체다. 모든 attempt는 상수
|
||
`OUTBOX_PUBLICATION` authority scope, ACTIVE legacy epoch, FROZEN fence generation과 target binding을
|
||
저장하고, partial unique constraint는 이 scope에 nonterminal
|
||
`CUTOVER_PENDING|FINALIZING_V2|RECOVERING_LEGACY` row를 정확히 하나만 허용한다. evidence 생성,
|
||
finalization, recovery prepare/completion은 모두 write-admission singleton `FOR UPDATE` 뒤 ACTIVE
|
||
epoch `FOR UPDATE`의 동일 lock order를 사용하고 exact generation/target/sole-attempt를 검증한다.
|
||
따라서 recovery 중 별도 attempt를 만들어 v2 epoch를 commit할 수 없다. 같은 attempt뿐 아니라
|
||
서로 다른 attempt의 생성/finalization과 recovery 사이 양방향 경쟁도 real PostgreSQL 시험으로
|
||
고정한다.
|
||
|
||
recovery claim이 commit된 뒤에만 외부 provisioning이 legacy principal Write를 재부여하고 fresh
|
||
positive probe를 만든다. 그 다음 exact attempt/owner/lease와 ACTIVE
|
||
`LEGACY_POLLING` epoch를 다시 잠가 검증한다. 불일치하면 legacy Write를 즉시 다시 revoke하고 fresh
|
||
negative probe를 만든 뒤 business writes를 FROZEN으로 유지한다. 검증이 성공하면 immutable
|
||
recovery/ACL audit 아래 in-process legacy Write fence, reaper, relay를 generation-CAS로 다시 열고
|
||
마지막에 write admission을 `OPEN(generation+1)`로 CAS하면서 attempt를
|
||
`RECOVERED_LEGACY`로 끝낸다. 어느 단계든 실패하면 business writes는 FROZEN을 유지하고 이미 연
|
||
legacy component를 다시 fence하거나 safe degraded state로 둔 채 recovery-only 재시도한다.
|
||
forward-finalization 대 recovery-claim의 양방향 lock-order 경쟁과 각 외부 mutation/crash
|
||
경계를 real PostgreSQL 계약 시험으로 고정한다. raw SQL이나 단순 boolean toggle은 금지한다.
|
||
|
||
epoch commit 뒤에는 business v2 send 여부와 무관하게 reverse epoch/legacy reactivation을 first
|
||
R2에서 지원하지 않는다. admission을 닫고 backlog/schema/epoch/audit를 보존한 채 forward-fix한다.
|
||
|
||
qualification fixture의 broker/principal은 `qualificationEnvironmentIdentity`, 실제 target은
|
||
`deploymentBindingIdentity`로 별도 기록한다. 공통 비교 대상은 capability/profile, supported
|
||
broker/client version constraint, settings/catalog/schema와 scenario contract다. 실제 cutover는
|
||
target cluster/topic/principal/secret generation에 대한 fresh topology/security/ACL attestation과
|
||
legacy principal negative-Write probe를 별도 요구하며 fixture identity를 target evidence로
|
||
재사용하지 않는다.
|
||
|
||
large live-data card는 bulk pre-backfill을 별도 bounded batch로 수행할 수 있지만, step 7
|
||
transaction 안의 fixed watermark, final delta, manifest assertion과 authority switch는 한 fenced
|
||
atomic unit에 남긴다. 그 transaction의 lock/statement/replication budget이 evidence로 안전하지
|
||
않으면 `COPY_AND_CUTOVER_WITH_RECONCILIATION.v1` 또는 더 긴 maintenance를 다시 승인하며 부분
|
||
authority switch를 허용하지 않는다.
|
||
|
||
이 phase 끝은 R1/R2 candidate다. security/fault/release evidence 없이 R2 완료가 아니다.
|
||
|
||
### 30.6 Phase 4 — R2 qualification and rollout
|
||
|
||
- production TLS/SASL_SSL;
|
||
- least-privilege ACL;
|
||
- topic topology policy;
|
||
- selected RF/min ISR multi-broker failure/recovery;
|
||
- secret/certificate rotation;
|
||
- fault/crash/late ACK/stale token;
|
||
- multi-worker/capacity/shutdown;
|
||
- observability/readiness/card registry;
|
||
- no-skip CI;
|
||
- sanitized evidence artifact;
|
||
- canary/rollback rehearsal;
|
||
- runbook completion.
|
||
|
||
exact first tuple만 R2로 승격한다.
|
||
|
||
### 30.7 Phase 5 — Inbound Kafka and inbox
|
||
|
||
- registry 19 -> 20 migration;
|
||
- inbound listener leaf;
|
||
- consumer contract/catalog decoder;
|
||
- manual ACK/bounded processing/rebalance;
|
||
- application `MessageConsumptionExecutor`;
|
||
- PostgreSQL inbox;
|
||
- DLT/replay tooling;
|
||
- security/observability;
|
||
- real Kafka/PostgreSQL consumer evidence.
|
||
|
||
producer/polling R2와 별도 card/status다.
|
||
|
||
### 30.8 Phase 6 — CDC
|
||
|
||
- insert-only source enforcement;
|
||
- deployment connector/slot/publication/internal topics;
|
||
- exact Debezium mapping;
|
||
- shadow topic;
|
||
- fault/WAL/offset/failover;
|
||
- cutover/rollback rehearsal;
|
||
- CDC retention proof;
|
||
- separate readiness/evidence.
|
||
|
||
polling R2를 제거하지 않는다. deployment가 둘 중 하나를 선택하되 같은 production destination에서
|
||
동시 활성화하지 않는다.
|
||
|
||
### 30.9 Phase 7 — Optional cards
|
||
|
||
실제 요구와 evidence가 있을 때:
|
||
|
||
- retry topic;
|
||
- Kafka EOS;
|
||
- schema registry;
|
||
- Avro/Protobuf;
|
||
- compaction;
|
||
- claim-check/large message;
|
||
- multi-cluster;
|
||
- alternate provider;
|
||
- module/artifact split.
|
||
|
||
### 30.10 Rollout
|
||
|
||
first polling rollout:
|
||
|
||
1. selected migration card preflight와 forward schema migration;
|
||
2. compatibility append + legacy relay-fence binary;
|
||
3. contract/catalog/schema artifact;
|
||
4. provisional delivery backfill/legacy unverified reconciliation;
|
||
5. ACK-aware producer + v2 relay의 **disabled** canary startup;
|
||
6. producer/topic/security/transaction-resource/operator-tool preflight;
|
||
7. business write admission freeze + active writer drain;
|
||
8. old relay stop, IN_FLIGHT drain, producer Write/DB legacy mutation fence;
|
||
9. 한 fenced DB transaction에서 ACTIVE epoch lock + fixed handoff watermark + final delta
|
||
reconciliation + count/hash manifest assertion;
|
||
10. 같은 transaction에서 atomic `LEGACY_POLLING -> POLLING_V2` authority switch + v2 sentinel
|
||
insert 뒤 commit;
|
||
11. v2 relay start와 sentinel ACK/`DELIVERY_RECORDED`, legacy writer 0 확인 뒤 writes 재개;
|
||
12. bounded subset/destination enable;
|
||
13. backlog/duplicate/indeterminate/no-dual-authority observation;
|
||
14. full enable;
|
||
15. rollback window 뒤 legacy seam/config removal.
|
||
|
||
rollback은 DB schema를 destructive downgrade하지 않는다. authority epoch, producer Write ACL과
|
||
relay fence를 먼저 판정하고 new relay disable/backlog preservation을 사용한다. 이미 v2 record가
|
||
Kafka에 갈 수 있는 시점부터 old/new relay를 동시에 켜는 rollback은 금지한다.
|
||
|
||
### 30.11 Status update discipline
|
||
|
||
각 phase가 끝날 때 §0에:
|
||
|
||
- implemented capability;
|
||
- not implemented;
|
||
- evidence/card level;
|
||
- exact test command/result;
|
||
- known limitation;
|
||
- next optional candidates
|
||
|
||
를 갱신한다. 본문 설계 문장을 “구현됨”으로 다시 쓰지 않는다.
|
||
|
||
## 31. Completion criteria and extension ledger
|
||
|
||
### 31.1 Design complete
|
||
|
||
설계 완료 조건:
|
||
|
||
- 사용자 review/approval;
|
||
- architecture/module ownership 확정;
|
||
- first tuple 확정;
|
||
- producer/outbox/consumer/CDC guarantee와 non-guarantee 명시;
|
||
- base template migration card 확정과 live-data deployment approval gate 명시;
|
||
- migration/test/runbook/evidence 계획;
|
||
- unresolved decision이 implementation plan에 숨지 않음;
|
||
- LLM Wiki capture.
|
||
|
||
현재 문서 상태는 사용자 승인에 따라
|
||
`상세 설계 승인, 실행 계획 작성·독립 검토 완료, 구현 미착수`다.
|
||
|
||
### 31.2 First R2 implementation complete
|
||
|
||
정본 완료 판정은 §10.5 machine registry의 exact selected tuple이 모두
|
||
`release-eligible`이고 required scenario/evidence fingerprint가 PASS인 경우뿐이다. 아래는 drift를
|
||
막기 위한 human review checklist다.
|
||
|
||
1. raw event type -> topic 제거;
|
||
2. closed contract/destination catalog;
|
||
3. envelope/payload schema v1;
|
||
4. append-before-persist validation;
|
||
5. immutable event + delivery split;
|
||
6. aggregate sequence + normal-path key order/non-guarantee;
|
||
7. claim token + unexpired-lease CAS와 JIT claim;
|
||
8. ACK-aware Spring Kafka producer;
|
||
9. ACK/ACK-mismatch/REJECTED/INDETERMINATE와 attempt journal;
|
||
10. legal late-completion observation drain과 bounded-loss semantics;
|
||
11. authenticated operator disposition use case/control surface;
|
||
12. finite effective config;
|
||
13. combined retry/requeue horizon budget;
|
||
14. production security/ACL/topic conformance;
|
||
15. multi-broker RF/min ISR failure evidence;
|
||
16. disabled resource 0;
|
||
17. bounded shutdown/rotation;
|
||
18. real Kafka/PostgreSQL/fault/security tests;
|
||
19. no-skip release gate;
|
||
20. descriptor/card/evidence artifact;
|
||
21. runbook;
|
||
22. legacy drift/migration;
|
||
23. Wiki capture.
|
||
|
||
### 31.3 Consumer/inbox complete
|
||
|
||
별도 조건:
|
||
|
||
- inbound leaf/registry;
|
||
- manual ACK after commit;
|
||
- inbox + business same transaction;
|
||
- duplicate/collision behavior;
|
||
- bounded poll/backpressure/rebalance;
|
||
- DLT ACK ordering;
|
||
- replay audit;
|
||
- security/retention;
|
||
- real fault evidence.
|
||
|
||
producer R2만으로 이 조건을 충족했다고 표시하지 않는다.
|
||
|
||
### 31.4 CDC complete
|
||
|
||
별도 조건:
|
||
|
||
- insert-only source;
|
||
- exact connector mapping;
|
||
- slot/offset/WAL/security;
|
||
- duplicate/restart/failover;
|
||
- shadow/cutover/rollback;
|
||
- authority exclusivity;
|
||
- retention proof;
|
||
- real multi-service evidence.
|
||
|
||
event/delivery table split만으로 CDC ready라고 표시하지 않는다.
|
||
|
||
### 31.5 추가 가능한 card
|
||
|
||
| 추가 요구 | 추가 card/설계 | 안정적으로 유지할 것 |
|
||
| --- | --- | --- |
|
||
| consumer | inbound Kafka + inbox | event ID, envelope, logical destination |
|
||
| CDC | Debezium dispatch | immutable event, key/wire semantics |
|
||
| binary schema | Avro/Protobuf registry | contract ID, payload version/outcome |
|
||
| delayed retry | retry topic | event ID/inbox, explicit ordering loss |
|
||
| Kafka-only workflow | transactional EOS | DB/non-Kafka scope 제외 |
|
||
| large payload | object-storage claim check | event identity/schema/security |
|
||
| alternate broker | provider card | semantic contract/evidence rule |
|
||
| multi-cluster | replication/failover card | no global ordering/exactly-once overclaim |
|
||
| topic compaction | contract-specific compacted log | key/tombstone/replay semantics |
|
||
|
||
outbound messaging leaf 분리는 다음 trigger 전에는 하지 않는다.
|
||
|
||
- 두 번째 broker provider가 독립 dependency/release lifecycle을 가짐;
|
||
- codec/schema runtime을 inbound leaf도 재사용해야 하지만 `shared-contract` purity로 수용할 수
|
||
없음;
|
||
- AdminClient/topology attestor가 독립 deployment artifact가 됨;
|
||
- producer와 compiler의 dependency/security ownership이 별도 release를 요구함.
|
||
|
||
trigger가 생기면 registry leaf/edge migration과 동일 semantic card/evidence compatibility를
|
||
함께 설계한다. package가 많다는 이유만으로 선제 분리하지 않는다.
|
||
|
||
### 31.6 금지하는 완료 표현
|
||
|
||
다음 표현은 조건 없이 사용하지 않는다.
|
||
|
||
- “Kafka 지원 완료” — seam인지 real selected card인지 명시;
|
||
- “broker ACK” — actual future metadata evidence 필요;
|
||
- “exactly once” — boundary를 좁힌 Kafka EOS card 외 금지;
|
||
- “중복 안전” — 해당 consumer/inbox evidence 필요;
|
||
- “strict FIFO” — sequence/key/topology뿐 아니라 failure/rotation/consumer order gate evidence 필요;
|
||
- “CDC ready” — connector/slot/offset/cutover evidence 필요;
|
||
- “DLT 처리 완료” — DLT publish와 business remediation 구분;
|
||
- “security ready” — TLS/SASL/ACL/rotation negative test 필요;
|
||
- “production ready” — exact R2 card/evidence 필요;
|
||
- “test passed” — command/result/skip을 기록.
|
||
|
||
### 31.7 구현 보고 template
|
||
|
||
후속 구현 완료 보고는 최소 다음을 포함한다.
|
||
|
||
```text
|
||
이번에 구현된 card/phase
|
||
변경 파일
|
||
architecture/dependency 변화
|
||
실행한 focused/real-service/common gate
|
||
test count/result/skip
|
||
evidence fingerprint/artifact
|
||
아직 미구현인 card
|
||
현재 보장과 non-guarantee
|
||
runbook
|
||
LLM Wiki capture
|
||
남은 위험
|
||
```
|
||
|
||
## 32. Required runbooks
|
||
|
||
### 32.1 First R2 baseline
|
||
|
||
1. `producer-unavailable-or-unauthorized`
|
||
- DNS/network/TLS/SASL/ACL/topic/min ISR 분기;
|
||
- direct/relay/write role별 guarantee 영향;
|
||
- safe pause/recovery proof.
|
||
2. `outbox-backlog-and-stale-lease`
|
||
- oldest age/count/growth;
|
||
- claim owner/token/lease conflict;
|
||
- hot/stuck aggregate;
|
||
- capacity/scale 한계.
|
||
3. `delivery-indeterminate-and-duplicate-burst`
|
||
- ACK loss/late ACK/mark failure;
|
||
- suspected event ID range;
|
||
- downstream inbox/reconciliation;
|
||
- resend duplicate 경고.
|
||
4. `schema-poison-or-record-too-large`
|
||
- contract/version/byte diagnosis;
|
||
- retry 중단;
|
||
- corrected/compensating event;
|
||
- payload 원문 log 금지.
|
||
5. `terminal-delivery-disposition`
|
||
- hold/requeue/skip/compensate;
|
||
- business-owner 승인;
|
||
- aggregate 후행 영향;
|
||
- audit/rollback.
|
||
6. `topic-policy-or-partition-change`
|
||
- topic drift;
|
||
- partition expansion order risk;
|
||
- new topic migration/cutback.
|
||
7. `shutdown-deploy-and-secret-rotation`
|
||
- new claim stop/drain;
|
||
- cert/secret expiry;
|
||
- old/new generation;
|
||
- forced timeout/rollback.
|
||
8. `legacy-to-v2-relay-authority-cutover`
|
||
- compatibility binary/fingerprint;
|
||
- legacy claim stop/IN_FLIGHT drain;
|
||
- DB epoch/legacy mutation fence;
|
||
- backlog watermark/no-dual-send probe;
|
||
- rollback stop condition.
|
||
|
||
### 32.2 Future consumer
|
||
|
||
- consumer lag/max.poll/rebalance loop;
|
||
- retry exhaustion/DLT publish failure;
|
||
- DLT duplicate identity/quarantine capacity/strict-order HOLD;
|
||
- schema/deserialization poison;
|
||
- inbox collision/retention;
|
||
- inbox purge vs live/replay effect-generation lease;
|
||
- audited replay;
|
||
- offset out of range;
|
||
- consumer identity/group migration;
|
||
- shutdown with active handler.
|
||
|
||
### 32.3 Future CDC
|
||
|
||
- connector/task down;
|
||
- WAL growth/disk pressure;
|
||
- slot missing/ahead/behind;
|
||
- offset reset/alter;
|
||
- snapshot restart/schema drift;
|
||
- PostgreSQL primary failover;
|
||
- polling -> CDC cutover;
|
||
- CDC -> polling rollback;
|
||
- shadow/production connector와 unique slot ownership;
|
||
- cutover 단계별 abort/authority proof;
|
||
- PostgreSQL 16 failover DATA_GAP_SUSPECTED recovery;
|
||
- WAL emergency without automatic slot drop;
|
||
- CDC retention/partition purge.
|
||
|
||
### 32.4 Common structure
|
||
|
||
모든 runbook:
|
||
|
||
```text
|
||
detection/trigger
|
||
blast radius
|
||
current guarantee degradation
|
||
safe first response
|
||
diagnosis evidence
|
||
non-destructive mitigation
|
||
destructive action approval boundary
|
||
reconciliation
|
||
recovery proof
|
||
rollback
|
||
audit/post-incident
|
||
related metrics/errors/cards
|
||
```
|
||
|
||
### 32.5 Existing runbook migration
|
||
|
||
`outbox-publish-failed.md`와 `outbox-dead-letter.md`는 현재 stub다. first implementation에서:
|
||
|
||
- removed `APP_MESSAGING_KAFKA_ENABLED` 제거;
|
||
- 실제 class/settings/table/state 이름;
|
||
- producer EXHAUSTED와 consumer DLT 구분;
|
||
- “consumer dedupe가 현재 안전 보장” 표현 제거;
|
||
- raw SQL status rewrite 제거;
|
||
- token/generation/audit operator tool;
|
||
- real dashboard/alert link;
|
||
- split event/delivery query;
|
||
- indeterminate/duplicate 절차
|
||
|
||
로 갱신한다.
|
||
|
||
## 33. Primary references
|
||
|
||
구현은 실제 BOM/lock에 resolve된 version 문서를 우선한다. 아래는 설계 시 확인한 official primary
|
||
reference다.
|
||
|
||
### Spring Boot and Spring Kafka
|
||
|
||
- [Spring Boot 4.0 — Apache Kafka Support](https://docs.spring.io/spring-boot/4.0/reference/messaging/kafka.html)
|
||
- [Spring Kafka 4.0 — Sending Messages](https://docs.spring.io/spring-kafka/reference/4.0/kafka/sending-messages.html)
|
||
- [Spring Kafka 4.0 — Message Listener Containers](https://docs.spring.io/spring-kafka/reference/4.0/kafka/receiving-messages/message-listener-container.html)
|
||
- [Spring Kafka 4.0 — Pausing and Resuming Listener Containers](https://docs.spring.io/spring-kafka/reference/4.0/kafka/pause-resume.html)
|
||
- [Spring Kafka 4.0 — Handling Exceptions](https://docs.spring.io/spring-kafka/reference/4.0/kafka/annotation-error-handling.html)
|
||
- [Spring Kafka 4.0 — DefaultErrorHandler API](https://docs.spring.io/spring-kafka/docs/4.0.x/api/org/springframework/kafka/listener/DefaultErrorHandler.html)
|
||
- [Spring Kafka 4.0 — Retry Topic Pattern](https://docs.spring.io/spring-kafka/reference/4.0/retrytopic/how-the-pattern-works.html)
|
||
- [Spring Kafka 4.0 — Transactions](https://docs.spring.io/spring-kafka/reference/4.0/kafka/transactions.html)
|
||
- [Spring Kafka 4.0 — Exactly Once Semantics](https://docs.spring.io/spring-kafka/reference/4.0/kafka/exactly-once.html)
|
||
- [Spring Kafka 4.0 — Monitoring](https://docs.spring.io/spring-kafka/reference/4.0/kafka/micrometer.html)
|
||
- [Spring Kafka 4.0 — Testing Applications](https://docs.spring.io/spring-kafka/reference/4.0/testing.html)
|
||
|
||
### Apache Kafka
|
||
|
||
- [Kafka 4.1 Producer Configs](https://kafka.apache.org/41/configuration/producer-configs/)
|
||
- [Kafka 4.1 Topic Configs](https://kafka.apache.org/41/configuration/topic-configs/)
|
||
- [Kafka 4.1 Consumer Configs](https://kafka.apache.org/41/configuration/consumer-configs/)
|
||
- [Kafka 4.1 Design — Message Delivery Semantics](https://kafka.apache.org/41/design/design/#message-delivery-semantics)
|
||
- [Kafka 4.1 Security Overview](https://kafka.apache.org/41/security/security-overview/)
|
||
- [Kafka 4.1 Kafka Connect User Guide](https://kafka.apache.org/41/kafka-connect/user-guide/)
|
||
- [Kafka 4.1 Kafka Connect Configs](https://kafka.apache.org/41/configuration/kafka-connect-configs/)
|
||
- [Kafka 4.1 KafkaProducer API](https://kafka.apache.org/41/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html)
|
||
- [Kafka 4.1 UnknownTopicOrPartitionException](https://kafka.apache.org/41/javadoc/org/apache/kafka/common/errors/UnknownTopicOrPartitionException.html)
|
||
- [Kafka 4.1 NotEnoughReplicasAfterAppendException](https://kafka.apache.org/41/javadoc/org/apache/kafka/common/errors/NotEnoughReplicasAfterAppendException.html)
|
||
|
||
### JSON and trace contract
|
||
|
||
- [JSON Schema Draft 2020-12](https://json-schema.org/draft/2020-12)
|
||
- [JSON Schema Core 2020-12](https://json-schema.org/draft/2020-12/json-schema-core)
|
||
- [JSON Schema Validation 2020-12](https://json-schema.org/draft/2020-12/json-schema-validation)
|
||
- [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
|
||
- [RFC 8259 — The JavaScript Object Notation Data Interchange Format](https://www.rfc-editor.org/rfc/rfc8259)
|
||
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
|
||
|
||
### Debezium, Kafka Connect and PostgreSQL CDC
|
||
|
||
- [Debezium 3.6 Release Series](https://debezium.io/releases/3.6/)
|
||
- [Debezium 3.6 Outbox Event Router](https://debezium.io/documentation/reference/3.6/transformations/outbox-event-router.html)
|
||
- [Debezium 3.6 PostgreSQL Connector](https://debezium.io/documentation/reference/3.6/connectors/postgresql.html)
|
||
- [PostgreSQL 16 Logical Decoding Concepts](https://www.postgresql.org/docs/16/logicaldecoding-explanation.html)
|
||
- [PostgreSQL 16 Logical Replication Security](https://www.postgresql.org/docs/16/logical-replication-security.html)
|
||
- [PostgreSQL 16 CREATE PUBLICATION](https://www.postgresql.org/docs/16/sql-createpublication.html)
|
||
- [PostgreSQL 16 Logical Replication Row Filters](https://www.postgresql.org/docs/16/logical-replication-row-filter.html)
|
||
- [PostgreSQL 16 INSERT / ON CONFLICT](https://www.postgresql.org/docs/16/sql-insert.html)
|
||
- [PostgreSQL 16 Unique Constraints](https://www.postgresql.org/docs/16/ddl-constraints.html)
|
||
|
||
### Test infrastructure
|
||
|
||
- [Testcontainers for Java — Kafka Module](https://java.testcontainers.org/modules/kafka/)
|
||
- [Testcontainers for Java — PostgreSQL Module](https://java.testcontainers.org/modules/databases/postgres/)
|
||
|
||
문서 링크는 executable evidence가 아니다. implementation 시 exact dependency/image version,
|
||
effective config, tests와 runbook으로 다시 확인한다.
|
||
|
||
## 34. Review 이후 다음 단계
|
||
|
||
base template의 핵심 architecture option과 empty/drained V3 기본 migration card는 확정됐다.
|
||
사용자에게 구현 세부 선택을 더 요구하지 않는다. 다만 실제 live non-empty database에 적용하는
|
||
시점에는 §30.3의 row/lock/data evidence를 수집한 뒤 live migration card를 별도 승인해야 한다.
|
||
그것은 지금 숨겨 둔 선택이 아니라 deployment-specific safety gate다. review에서 방향 수정이
|
||
없으면 다음 순서로 진행한다.
|
||
|
||
1. P0–P4 first R2 실행 계획과 독립 review 완료;
|
||
2. `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 계획 실행;
|
||
3. test-first로 contract/schema/polling/producer를 단계 구현;
|
||
4. exact first tuple R2 evidence 뒤 consumer/inbox 계획;
|
||
5. consumer evidence 뒤 CDC qualification/cutover 계획.
|
||
|
||
repository의 human-only commit 정책에 따라 agent는 stage/commit/amend/push하지 않는다.
|